🔨 graphql refactor for missingFiles flag
This commit is contained in:
@@ -780,6 +780,111 @@ export const resolvers = {
|
||||
throw new Error(`Failed to fetch active import session: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
searchComicVine: async (
|
||||
_: any,
|
||||
{ searchTerms, exactMatch }: { searchTerms: string; exactMatch?: boolean },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("library.volumeBasedSearch", { searchTerms, exactMatch });
|
||||
} catch (error) {
|
||||
console.error("Error searching ComicVine:", error);
|
||||
throw new Error(`Failed to search ComicVine: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
settings: async (
|
||||
_: any,
|
||||
{ settingsKey }: { settingsKey?: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("settings.getSettings", settingsKey ? { settingsKey } : {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings:", error);
|
||||
throw new Error(`Failed to fetch settings: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
hubs: async (
|
||||
_: any,
|
||||
{ host }: { host: any },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("airdcpp.getHubs", { host });
|
||||
} catch (error) {
|
||||
console.error("Error fetching hubs:", error);
|
||||
throw new Error(`Failed to fetch hubs: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
bundles: async (
|
||||
_: any,
|
||||
{ comicObjectId, config }: { comicObjectId: string; config?: any },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("library.getBundles", { comicObjectId, config });
|
||||
} catch (error) {
|
||||
console.error("Error fetching bundles:", error);
|
||||
throw new Error(`Failed to fetch bundles: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
torrentJobs: async (
|
||||
_: any,
|
||||
{ trigger }: { trigger: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("torrentjobs.getTorrentData", { trigger });
|
||||
} catch (error) {
|
||||
console.error("Error fetching torrent jobs:", error);
|
||||
throw new Error(`Failed to fetch torrent jobs: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
searchTorrents: async (
|
||||
_: any,
|
||||
{ query }: { query: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("prowlarr.search", { query });
|
||||
} catch (error) {
|
||||
console.error("Error searching torrents:", error);
|
||||
throw new Error(`Failed to search torrents: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
walkFolders: async (
|
||||
_: any,
|
||||
{ basePathToWalk, extensions }: { basePathToWalk: string; extensions?: string[] },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("library.walkFolders", { basePathToWalk, extensions });
|
||||
} catch (error) {
|
||||
console.error("Error walking folders:", error);
|
||||
throw new Error(`Failed to walk folders: ${error.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
@@ -1567,6 +1672,52 @@ export const resolvers = {
|
||||
throw new Error(`Failed to force complete session: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
applyComicVineMatch: async (
|
||||
_: any,
|
||||
{ comicObjectId, match }: { comicObjectId: string; match: any },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("library.applyComicVineMetadata", { comicObjectId, match });
|
||||
} catch (error) {
|
||||
console.error("Error applying ComicVine match:", error);
|
||||
throw new Error(`Failed to apply ComicVine match: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
analyzeImage: async (
|
||||
_: any,
|
||||
{ imageFilePath }: { imageFilePath: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
return await broker.call("imagetransformation.analyze", { imageFilePath });
|
||||
} catch (error) {
|
||||
console.error("Error analyzing image:", error);
|
||||
throw new Error(`Failed to analyze image: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
uncompressArchive: async (
|
||||
_: any,
|
||||
{ filePath, comicObjectId, options }: { filePath: string; comicObjectId: string; options?: any },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
const broker = context?.broker;
|
||||
if (!broker) throw new Error("Broker not available in context");
|
||||
await broker.call("library.uncompressFullArchive", { filePath, comicObjectId, options });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error uncompressing archive:", error);
|
||||
throw new Error(`Failed to uncompress archive: ${error.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1681,6 +1832,29 @@ export const resolvers = {
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
// Custom scalars
|
||||
JSON: {
|
||||
serialize: (value: any) => value,
|
||||
parseValue: (value: any) => value,
|
||||
parseLiteral: (ast: any) => {
|
||||
// Handle basic scalar literals; complex objects are passed as variables
|
||||
switch (ast.kind) {
|
||||
case "StringValue": return ast.value;
|
||||
case "IntValue": return parseInt(ast.value, 10);
|
||||
case "FloatValue": return parseFloat(ast.value);
|
||||
case "BooleanValue": return ast.value;
|
||||
case "NullValue": return null;
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
PredicateInput: {
|
||||
serialize: (value: any) => value,
|
||||
parseValue: (value: any) => value,
|
||||
parseLiteral: (ast: any) => ast.value ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,9 @@ import { gql } from "graphql-tag";
|
||||
* ```
|
||||
*/
|
||||
export const typeDefs = gql`
|
||||
# Arbitrary JSON scalar
|
||||
scalar JSON
|
||||
|
||||
# Metadata source enumeration
|
||||
enum MetadataSource {
|
||||
COMICVINE
|
||||
@@ -353,6 +356,27 @@ export const typeDefs = gql`
|
||||
|
||||
# Get active import session (if any)
|
||||
getActiveImportSession: ImportSession
|
||||
|
||||
# Search ComicVine for volumes by name
|
||||
searchComicVine(searchTerms: String!, exactMatch: Boolean): ComicVineSearchResult!
|
||||
|
||||
# Get all app settings (optionally filtered by key)
|
||||
settings(settingsKey: String): AppSettings
|
||||
|
||||
# Get AirDC++ hubs for a given host
|
||||
hubs(host: HostInput!): [Hub!]!
|
||||
|
||||
# Get AirDC++ bundles for a comic object
|
||||
bundles(comicObjectId: ID!, config: JSON): [Bundle!]!
|
||||
|
||||
# Enqueue a repeating torrent data polling job
|
||||
torrentJobs(trigger: String!): TorrentJob
|
||||
|
||||
# Search Prowlarr for torrents
|
||||
searchTorrents(query: String!): [TorrentSearchResult!]!
|
||||
|
||||
# Walk a folder and return matching comic file paths
|
||||
walkFolders(basePathToWalk: String!, extensions: [String!]): [String!]!
|
||||
}
|
||||
|
||||
# Mutations
|
||||
@@ -406,6 +430,15 @@ export const typeDefs = gql`
|
||||
|
||||
# Force complete a stuck import session
|
||||
forceCompleteSession(sessionId: String!): ForceCompleteResult!
|
||||
|
||||
# Apply a ComicVine volume match to a comic
|
||||
applyComicVineMatch(comicObjectId: ID!, match: ComicVineMatchInput!): Comic!
|
||||
|
||||
# Analyze an image file for color and metadata
|
||||
analyzeImage(imageFilePath: String!): ImageAnalysisResult!
|
||||
|
||||
# Uncompress an archive (enqueues background job)
|
||||
uncompressArchive(filePath: String!, comicObjectId: ID!, options: JSON): Boolean
|
||||
}
|
||||
|
||||
# Input types
|
||||
@@ -793,4 +826,128 @@ export const typeDefs = gql`
|
||||
filesSucceeded: Int!
|
||||
filesFailed: Int!
|
||||
}
|
||||
|
||||
# Host configuration (used by AirDC++, bittorrent, prowlarr)
|
||||
type HostConfig {
|
||||
hostname: String
|
||||
port: String
|
||||
protocol: String
|
||||
username: String
|
||||
password: String
|
||||
}
|
||||
|
||||
input HostInput {
|
||||
hostname: String!
|
||||
port: String!
|
||||
protocol: String!
|
||||
username: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
# App settings
|
||||
type DirectConnectClient {
|
||||
host: HostConfig
|
||||
airDCPPUserSettings: JSON
|
||||
hubs: [JSON]
|
||||
}
|
||||
|
||||
type DirectConnectSettings {
|
||||
client: DirectConnectClient
|
||||
}
|
||||
|
||||
type BittorrentClient {
|
||||
name: String
|
||||
host: HostConfig
|
||||
}
|
||||
|
||||
type BittorrentSettings {
|
||||
client: BittorrentClient
|
||||
}
|
||||
|
||||
type ProwlarrClient {
|
||||
host: HostConfig
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
type ProwlarrSettings {
|
||||
client: ProwlarrClient
|
||||
}
|
||||
|
||||
type AppSettings {
|
||||
directConnect: DirectConnectSettings
|
||||
bittorrent: BittorrentSettings
|
||||
prowlarr: ProwlarrSettings
|
||||
}
|
||||
|
||||
# AirDC++ Hub
|
||||
type Hub {
|
||||
id: Int
|
||||
name: String
|
||||
description: String
|
||||
userCount: Int
|
||||
}
|
||||
|
||||
# AirDC++ Bundle
|
||||
type Bundle {
|
||||
id: Int
|
||||
name: String
|
||||
size: String
|
||||
status: String
|
||||
speed: String
|
||||
}
|
||||
|
||||
# Torrent search result (from Prowlarr)
|
||||
type TorrentSearchResult {
|
||||
title: String
|
||||
size: Float
|
||||
seeders: Int
|
||||
leechers: Int
|
||||
downloadUrl: String
|
||||
guid: String
|
||||
publishDate: String
|
||||
indexer: String
|
||||
}
|
||||
|
||||
# Torrent job reference
|
||||
type TorrentJob {
|
||||
id: String
|
||||
name: String
|
||||
}
|
||||
|
||||
# Image analysis result
|
||||
type ImageAnalysisResult {
|
||||
analyzedData: JSON
|
||||
colorHistogramData: JSON
|
||||
}
|
||||
|
||||
# ComicVine volume search result
|
||||
type ComicVineVolume {
|
||||
id: Int
|
||||
name: String
|
||||
publisher: Publisher
|
||||
start_year: String
|
||||
count_of_issues: Int
|
||||
image: VolumeImage
|
||||
api_detail_url: String
|
||||
site_detail_url: String
|
||||
description: String
|
||||
}
|
||||
|
||||
type ComicVineSearchResult {
|
||||
results: [ComicVineVolume!]!
|
||||
total: Int!
|
||||
limit: Int
|
||||
offset: Int
|
||||
}
|
||||
|
||||
# Input for applying a ComicVine match
|
||||
input ComicVineMatchInput {
|
||||
volume: ComicVineVolumeRefInput!
|
||||
volumeInformation: JSON
|
||||
}
|
||||
|
||||
input ComicVineVolumeRefInput {
|
||||
api_detail_url: String!
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
@@ -111,6 +111,8 @@ export default {
|
||||
settings: {
|
||||
/** Remote metadata GraphQL endpoint URL */
|
||||
metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql",
|
||||
/** Remote acquisition GraphQL endpoint URL */
|
||||
acquisitionGraphqlUrl: process.env.ACQUISITION_GRAPHQL_URL || "http://localhost:3060/acquisition-graphql",
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -222,61 +224,34 @@ export default {
|
||||
|
||||
const localSchema = makeExecutableSchema({ typeDefs, resolvers });
|
||||
|
||||
const subschemas: any[] = [{ schema: localSchema }];
|
||||
|
||||
// Stitch metadata schema
|
||||
try {
|
||||
this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`);
|
||||
|
||||
const remoteSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl);
|
||||
const metadataSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl);
|
||||
subschemas.push({ schema: metadataSchema, executor: createRemoteExecutor(this.settings.metadataGraphqlUrl) });
|
||||
this.logger.info("✓ Successfully introspected remote metadata schema");
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`⚠ Metadata schema unavailable: ${error.message}`);
|
||||
}
|
||||
|
||||
const remoteQueryType = remoteSchema.getQueryType();
|
||||
const remoteMutationType = remoteSchema.getMutationType();
|
||||
// Stitch acquisition schema
|
||||
try {
|
||||
this.logger.info(`Attempting to introspect remote schema at ${this.settings.acquisitionGraphqlUrl}`);
|
||||
const acquisitionSchema = await fetchRemoteSchema(this.settings.acquisitionGraphqlUrl);
|
||||
subschemas.push({ schema: acquisitionSchema, executor: createRemoteExecutor(this.settings.acquisitionGraphqlUrl) });
|
||||
this.logger.info("✓ Successfully introspected remote acquisition schema");
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`⚠ Acquisition schema unavailable: ${error.message}`);
|
||||
}
|
||||
|
||||
if (remoteQueryType) {
|
||||
const remoteQueryFields = Object.keys(remoteQueryType.getFields());
|
||||
this.logger.info(`✓ Remote schema has ${remoteQueryFields.length} Query fields: ${remoteQueryFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (remoteMutationType) {
|
||||
const remoteMutationFields = Object.keys(remoteMutationType.getFields());
|
||||
this.logger.info(`✓ Remote schema has ${remoteMutationFields.length} Mutation fields: ${remoteMutationFields.join(', ')}`);
|
||||
}
|
||||
|
||||
this.schema = stitchSchemas({
|
||||
subschemas: [
|
||||
{ schema: localSchema },
|
||||
{ schema: remoteSchema, executor: createRemoteExecutor(this.settings.metadataGraphqlUrl) },
|
||||
],
|
||||
mergeTypes: true,
|
||||
});
|
||||
|
||||
const stitchedQueryType = this.schema.getQueryType();
|
||||
const stitchedMutationType = this.schema.getMutationType();
|
||||
|
||||
if (stitchedQueryType) {
|
||||
const stitchedQueryFields = Object.keys(stitchedQueryType.getFields());
|
||||
this.logger.info(`✓ Stitched schema has ${stitchedQueryFields.length} Query fields`);
|
||||
|
||||
// Verify critical remote fields are present
|
||||
const criticalFields = ['getWeeklyPullList'];
|
||||
const missingFields = criticalFields.filter(field => !stitchedQueryFields.includes(field));
|
||||
if (missingFields.length > 0) {
|
||||
this.logger.warn(`⚠ Missing expected remote fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stitchedMutationType) {
|
||||
const stitchedMutationFields = Object.keys(stitchedMutationType.getFields());
|
||||
this.logger.info(`✓ Stitched schema has ${stitchedMutationFields.length} Mutation fields`);
|
||||
}
|
||||
|
||||
this.logger.info("✓ Successfully stitched local and remote schemas");
|
||||
if (subschemas.length > 1) {
|
||||
this.schema = stitchSchemas({ subschemas, mergeTypes: true });
|
||||
this.logger.info(`✓ Stitched ${subschemas.length} schemas`);
|
||||
this.remoteSchemaAvailable = true;
|
||||
} catch (remoteError: any) {
|
||||
this.logger.error(`✗ Failed to connect to remote metadata GraphQL at ${this.settings.metadataGraphqlUrl}`);
|
||||
this.logger.error(`✗ Error: ${remoteError.message}`);
|
||||
} else {
|
||||
this.logger.warn("⚠ FALLING BACK TO LOCAL SCHEMA ONLY");
|
||||
this.logger.warn("⚠ Remote queries like 'getWeeklyPullList' will NOT be available");
|
||||
this.logger.warn(`⚠ To fix: Ensure metadata-graphql service is running at ${this.settings.metadataGraphqlUrl}`);
|
||||
this.schema = localSchema;
|
||||
this.remoteSchemaAvailable = false;
|
||||
}
|
||||
|
||||
@@ -832,6 +832,77 @@ export default class ImportService extends Service {
|
||||
},
|
||||
},
|
||||
|
||||
reconcileLibrary: {
|
||||
rest: "POST /reconcileLibrary",
|
||||
timeout: 120000,
|
||||
async handler(ctx: Context<{ directoryPath?: string }>) {
|
||||
const resolvedPath = path.resolve(
|
||||
ctx.params.directoryPath || COMICS_DIRECTORY
|
||||
);
|
||||
|
||||
// 1. Collect all comic file paths currently on disk
|
||||
const localPaths = new Set<string>();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
klaw(resolvedPath)
|
||||
.on("error", reject)
|
||||
.pipe(
|
||||
through2.obj(function (item, enc, next) {
|
||||
if (
|
||||
item.stats.isFile() &&
|
||||
[".cbz", ".cbr", ".cb7"].includes(
|
||||
path.extname(item.path)
|
||||
)
|
||||
) {
|
||||
this.push(item.path);
|
||||
}
|
||||
next();
|
||||
})
|
||||
)
|
||||
.on("data", (p: string) => localPaths.add(p))
|
||||
.on("end", resolve);
|
||||
});
|
||||
|
||||
// 2. Get every DB record that has a stored filePath
|
||||
const comics = await Comic.find(
|
||||
{ "rawFileDetails.filePath": { $exists: true, $ne: null } },
|
||||
{ _id: 1, "rawFileDetails.filePath": 1 }
|
||||
).lean();
|
||||
|
||||
const nowMissing: any[] = [];
|
||||
const nowPresent: any[] = [];
|
||||
|
||||
for (const comic of comics) {
|
||||
const stored = comic.rawFileDetails?.filePath;
|
||||
if (!stored) continue;
|
||||
if (localPaths.has(stored)) {
|
||||
nowPresent.push(comic._id);
|
||||
} else {
|
||||
nowMissing.push(comic._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Apply updates in bulk
|
||||
if (nowMissing.length > 0) {
|
||||
await Comic.updateMany(
|
||||
{ _id: { $in: nowMissing } },
|
||||
{ $set: { "importStatus.isRawFileMissing": true } }
|
||||
);
|
||||
}
|
||||
if (nowPresent.length > 0) {
|
||||
await Comic.updateMany(
|
||||
{ _id: { $in: nowPresent } },
|
||||
{ $set: { "importStatus.isRawFileMissing": false } }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: localPaths.size,
|
||||
markedMissing: nowMissing.length,
|
||||
cleared: nowPresent.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
getComicsMarkedAsWanted: {
|
||||
|
||||
rest: "GET /getComicsMarkedAsWanted",
|
||||
|
||||
@@ -123,6 +123,11 @@ export default class SettingsService extends Service {
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "missingFiles":
|
||||
Object.assign(eSQuery, {
|
||||
term: query,
|
||||
});
|
||||
break;
|
||||
}
|
||||
console.log(
|
||||
"Searching ElasticSearch index with this query -> "
|
||||
|
||||
Reference in New Issue
Block a user