🔨 graphql refactor for missingFiles flag

This commit is contained in:
2026-03-09 17:11:14 -04:00
parent e5e4e82f11
commit c9f323e610
5 changed files with 434 additions and 52 deletions

View File

@@ -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,
},
};
/**

View File

@@ -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!
}
`;

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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 -> "