From c9f323e610cbfbc110539ff299d48d7f87f45356 Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Mon, 9 Mar 2026 17:11:14 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20graphql=20refactor=20for=20missi?= =?UTF-8?q?ngFiles=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/graphql/resolvers.ts | 178 +++++++++++++++++++++++++++++++++++- models/graphql/typedef.ts | 157 +++++++++++++++++++++++++++++++ services/graphql.service.ts | 75 +++++---------- services/library.service.ts | 71 ++++++++++++++ services/search.service.ts | 5 + 5 files changed, 434 insertions(+), 52 deletions(-) diff --git a/models/graphql/resolvers.ts b/models/graphql/resolvers.ts index f429cfa..7ca6e1d 100644 --- a/models/graphql/resolvers.ts +++ b/models/graphql/resolvers.ts @@ -767,7 +767,7 @@ export const resolvers = { ) => { try { const broker = context?.broker; - + if (!broker) { throw new Error("Broker not available in context"); } @@ -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: { @@ -1547,7 +1652,7 @@ export const resolvers = { ) => { try { const broker = context?.broker; - + if (!broker) { throw new Error("Broker not available in context"); } @@ -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, + }, }; /** diff --git a/models/graphql/typedef.ts b/models/graphql/typedef.ts index d1255c2..b16b39c 100644 --- a/models/graphql/typedef.ts +++ b/models/graphql/typedef.ts @@ -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! + } + `; diff --git a/services/graphql.service.ts b/services/graphql.service.ts index 12db39d..f4c082b 100644 --- a/services/graphql.service.ts +++ b/services/graphql.service.ts @@ -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"); - - const remoteQueryType = remoteSchema.getQueryType(); - const remoteMutationType = remoteSchema.getMutationType(); - - 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"); + } catch (error: any) { + this.logger.warn(`⚠ Metadata schema unavailable: ${error.message}`); + } + + // 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 (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; } diff --git a/services/library.service.ts b/services/library.service.ts index 09b0e1a..6a6fc08 100644 --- a/services/library.service.ts +++ b/services/library.service.ts @@ -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(); + await new Promise((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", diff --git a/services/search.service.ts b/services/search.service.ts index ec07ec8..874aa3e 100644 --- a/services/search.service.ts +++ b/services/search.service.ts @@ -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 -> "