2017 lines
57 KiB
TypeScript
2017 lines
57 KiB
TypeScript
/**
|
|
* @fileoverview GraphQL resolvers for comic metadata operations
|
|
* @module models/graphql/resolvers
|
|
* @description Implements all GraphQL query and mutation resolvers for the comic library system.
|
|
* Handles comic retrieval, metadata resolution, user preferences, library statistics,
|
|
* and search operations. Integrates with the metadata resolution system to provide
|
|
* sophisticated multi-source metadata merging.
|
|
*
|
|
* @see {@link module:models/graphql/typedef} for schema definitions
|
|
* @see {@link module:utils/metadata.resolution.utils} for metadata resolution logic
|
|
*/
|
|
|
|
import Comic, { MetadataSource } from "../comic.model";
|
|
import UserPreferences, {
|
|
ConflictResolutionStrategy,
|
|
} from "../userpreferences.model";
|
|
import {
|
|
resolveMetadataField,
|
|
buildCanonicalMetadata,
|
|
MetadataField,
|
|
ResolutionPreferences,
|
|
} from "../../utils/metadata.resolution.utils";
|
|
|
|
/**
|
|
* GraphQL resolvers for canonical metadata queries and mutations
|
|
* @constant {Object} resolvers
|
|
* @description Complete resolver map implementing all queries, mutations, and field resolvers
|
|
* defined in the GraphQL schema. Organized into Query, Mutation, and type-specific resolvers.
|
|
*/
|
|
export const resolvers = {
|
|
Query: {
|
|
/**
|
|
* Get a single comic by ID
|
|
* @async
|
|
* @function comic
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {string} args.id - Comic ID (MongoDB ObjectId)
|
|
* @returns {Promise<Comic|null>} Comic document or null if not found
|
|
* @throws {Error} If database query fails
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* comic(id: "507f1f77bcf86cd799439011") {
|
|
* id
|
|
* canonicalMetadata { title { value } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
comic: async (_: any, { id }: { id: string }) => {
|
|
try {
|
|
const comic = await Comic.findById(id);
|
|
return comic;
|
|
} catch (error) {
|
|
console.error("Error fetching comic:", error);
|
|
throw new Error("Failed to fetch comic");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get comic books with advanced pagination and filtering
|
|
* @async
|
|
* @function getComicBooks
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {Object} args.paginationOptions - Pagination configuration (page, limit, sort, etc.)
|
|
* @param {Object} [args.predicate={}] - MongoDB query predicate for filtering
|
|
* @returns {Promise<PaginatedResult>} Paginated comic results with metadata
|
|
* @throws {Error} If database query fails
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getComicBooks(
|
|
* paginationOptions: { page: 1, limit: 20, sort: "createdAt" }
|
|
* predicate: {}
|
|
* ) {
|
|
* docs { id canonicalMetadata { title { value } } }
|
|
* totalDocs
|
|
* hasNextPage
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getComicBooks: async (
|
|
_: any,
|
|
{
|
|
paginationOptions,
|
|
predicate = {},
|
|
}: {
|
|
paginationOptions: any;
|
|
predicate?: any;
|
|
}
|
|
) => {
|
|
try {
|
|
// Parse predicate if it's a JSON string (from scalar type)
|
|
let parsedPredicate = predicate;
|
|
if (typeof predicate === 'string') {
|
|
try {
|
|
parsedPredicate = JSON.parse(predicate);
|
|
} catch (parseError) {
|
|
console.error("Error parsing predicate JSON:", parseError);
|
|
throw new Error("Invalid predicate format: must be valid JSON");
|
|
}
|
|
}
|
|
|
|
const result = await Comic.paginate(parsedPredicate, paginationOptions);
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error fetching comic books:", error);
|
|
throw new Error("Failed to fetch comic books");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get comic book groups (volumes with multiple issues)
|
|
* @async
|
|
* @function getComicBookGroups
|
|
* @returns {Promise<Array>} Array of volume groups with issue information
|
|
* @throws {Error} If aggregation fails
|
|
* @description Aggregates comics by volume using ComicVine volume information.
|
|
* Returns the 5 most recently updated volumes with their metadata.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getComicBookGroups {
|
|
* id
|
|
* volumes { name publisher { name } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getComicBookGroups: async () => {
|
|
try {
|
|
const volumes = await Comic.aggregate([
|
|
{
|
|
$project: {
|
|
volumeInfo:
|
|
"$sourcedMetadata.comicvine.volumeInformation",
|
|
},
|
|
},
|
|
{
|
|
$unwind: "$volumeInfo",
|
|
},
|
|
{
|
|
$group: {
|
|
_id: "$_id",
|
|
volumes: {
|
|
$addToSet: "$volumeInfo",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$unwind: "$volumes",
|
|
},
|
|
{ $sort: { updatedAt: -1 } },
|
|
{ $skip: 0 },
|
|
{ $limit: 5 },
|
|
]);
|
|
|
|
return volumes.map((vol) => ({
|
|
id: vol._id.toString(),
|
|
volumes: vol.volumes,
|
|
}));
|
|
} catch (error) {
|
|
console.error("Error fetching comic book groups:", error);
|
|
throw new Error("Failed to fetch comic book groups");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get library statistics
|
|
* @async
|
|
* @function getLibraryStatistics
|
|
* @returns {Promise<Object>} Library statistics including counts, sizes, and aggregations
|
|
* @throws {Error} If statistics calculation fails
|
|
* @description Calculates comprehensive library statistics including:
|
|
* - Total document count
|
|
* - Directory size and file count
|
|
* - File type distribution
|
|
* - Volume/issue groupings
|
|
* - Comics with/without ComicInfo.xml
|
|
* - Publisher statistics
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getLibraryStatistics {
|
|
* totalDocuments
|
|
* comicDirectorySize { totalSizeInGB }
|
|
* statistics { publisherWithMostComicsInLibrary { id count } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getLibraryStatistics: async () => {
|
|
try {
|
|
const { getSizeOfDirectory } = require("../../utils/file.utils");
|
|
const { COMICS_DIRECTORY } = require("../../constants/directories");
|
|
|
|
const comicDirectorySize = await getSizeOfDirectory(
|
|
COMICS_DIRECTORY,
|
|
[".cbz", ".cbr", ".cb7"]
|
|
);
|
|
const totalCount = await Comic.countDocuments({});
|
|
const comicsMissingFiles = await Comic.countDocuments({
|
|
"importStatus.isRawFileMissing": true,
|
|
});
|
|
const statistics = await Comic.aggregate([
|
|
{
|
|
$facet: {
|
|
fileTypes: [
|
|
{
|
|
$match: {
|
|
"rawFileDetails.extension": {
|
|
$in: [".cbr", ".cbz", ".cb7"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$group: {
|
|
_id: "$rawFileDetails.extension",
|
|
data: { $push: "$$ROOT._id" },
|
|
},
|
|
},
|
|
],
|
|
issues: [
|
|
{
|
|
$match: {
|
|
"sourcedMetadata.comicvine.volumeInformation":
|
|
{
|
|
$gt: {},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$group: {
|
|
_id: "$sourcedMetadata.comicvine.volumeInformation",
|
|
data: { $push: "$$ROOT._id" },
|
|
},
|
|
},
|
|
],
|
|
fileLessComics: [
|
|
{
|
|
$match: {
|
|
rawFileDetails: {
|
|
$exists: false,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
issuesWithComicInfoXML: [
|
|
{
|
|
$match: {
|
|
"sourcedMetadata.comicInfo": {
|
|
$exists: true,
|
|
$gt: { $size: 0 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
publisherWithMostComicsInLibrary: [
|
|
{
|
|
$unwind:
|
|
"$sourcedMetadata.comicvine.volumeInformation.publisher",
|
|
},
|
|
{
|
|
$group: {
|
|
_id: "$sourcedMetadata.comicvine.volumeInformation.publisher.name",
|
|
count: { $sum: 1 },
|
|
},
|
|
},
|
|
{ $sort: { count: -1 } },
|
|
{ $limit: 1 },
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
return {
|
|
totalDocuments: totalCount,
|
|
comicsMissingFiles,
|
|
comicDirectorySize,
|
|
statistics,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching library statistics:", error);
|
|
throw new Error("Failed to fetch library statistics");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Search issues using Elasticsearch
|
|
* @async
|
|
* @function searchIssue
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {Object} [args.query] - Search query with volumeName and issueNumber
|
|
* @param {Object} [args.pagination={size:10,from:0}] - Pagination options
|
|
* @param {string} args.type - Search type (all, volumeName, wanted, volumes)
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Object>} Elasticsearch search results
|
|
* @throws {Error} If search service is unavailable or search fails
|
|
* @description Delegates to the search service via Moleculer broker to perform
|
|
* Elasticsearch queries for comic issues.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* searchIssue(
|
|
* query: { volumeName: "Batman", issueNumber: "1" }
|
|
* pagination: { size: 10, from: 0 }
|
|
* type: all
|
|
* ) {
|
|
* hits { hits { _source { id } } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
searchIssue: async (
|
|
_: any,
|
|
{
|
|
query,
|
|
pagination = { size: 10, from: 0 },
|
|
type,
|
|
}: {
|
|
query?: { volumeName?: string; issueNumber?: string };
|
|
pagination?: { size?: number; from?: number };
|
|
type: string;
|
|
},
|
|
context: any
|
|
) => {
|
|
try {
|
|
// Get broker from context (set up in GraphQL service)
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
// Call the search service through the broker
|
|
const result = await broker.call("search.issue", {
|
|
query: query || {},
|
|
pagination,
|
|
type,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error searching issues:", error);
|
|
throw new Error(`Failed to search issues: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* List comics with pagination and filtering
|
|
* @async
|
|
* @function comics
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {number} [args.limit=10] - Items per page
|
|
* @param {number} [args.page=1] - Page number
|
|
* @param {string} [args.search] - Search term for title/series/filename
|
|
* @param {string} [args.publisher] - Filter by publisher
|
|
* @param {string} [args.series] - Filter by series
|
|
* @returns {Promise<Object>} Paginated comics with page info
|
|
* @throws {Error} If database query fails
|
|
* @description Lists comics with optional text search and filtering.
|
|
* Searches across canonical metadata title, series, and raw filename.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* comics(limit: 20, page: 1, search: "Batman", publisher: "DC Comics") {
|
|
* comics { id canonicalMetadata { title { value } } }
|
|
* totalCount
|
|
* pageInfo { hasNextPage currentPage totalPages }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
comics: async (
|
|
_: any,
|
|
{
|
|
limit = 10,
|
|
page = 1,
|
|
search,
|
|
publisher,
|
|
series,
|
|
}: {
|
|
limit?: number;
|
|
page?: number;
|
|
search?: string;
|
|
publisher?: string;
|
|
series?: string;
|
|
}
|
|
) => {
|
|
try {
|
|
const query: any = {};
|
|
|
|
// Build search query
|
|
if (search) {
|
|
query.$or = [
|
|
{ "canonicalMetadata.title.value": new RegExp(search, "i") },
|
|
{ "canonicalMetadata.series.value": new RegExp(search, "i") },
|
|
{ "rawFileDetails.name": new RegExp(search, "i") },
|
|
];
|
|
}
|
|
|
|
if (publisher) {
|
|
query["canonicalMetadata.publisher.value"] = new RegExp(
|
|
publisher,
|
|
"i"
|
|
);
|
|
}
|
|
|
|
if (series) {
|
|
query["canonicalMetadata.series.value"] = new RegExp(series, "i");
|
|
}
|
|
|
|
const options = {
|
|
page,
|
|
limit,
|
|
sort: { createdAt: -1 },
|
|
};
|
|
|
|
const result = await Comic.paginate(query, options);
|
|
|
|
return {
|
|
comics: result.docs,
|
|
totalCount: result.totalDocs,
|
|
pageInfo: {
|
|
hasNextPage: result.hasNextPage,
|
|
hasPreviousPage: result.hasPrevPage,
|
|
currentPage: result.page,
|
|
totalPages: result.totalPages,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching comics:", error);
|
|
throw new Error("Failed to fetch comics");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get user preferences for metadata resolution
|
|
* @async
|
|
* @function userPreferences
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {string} [args.userId='default'] - User ID
|
|
* @returns {Promise<UserPreferences>} User preferences document
|
|
* @throws {Error} If database query fails
|
|
* @description Retrieves user preferences for metadata resolution.
|
|
* Creates default preferences if none exist for the user.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* userPreferences(userId: "default") {
|
|
* conflictResolution
|
|
* minConfidenceThreshold
|
|
* sourcePriorities { source priority enabled }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
userPreferences: async (
|
|
_: any,
|
|
{ userId = "default" }: { userId?: string }
|
|
) => {
|
|
try {
|
|
let preferences = await UserPreferences.findOne({ userId });
|
|
|
|
// Create default preferences if none exist
|
|
if (!preferences) {
|
|
preferences = await UserPreferences.create({ userId });
|
|
}
|
|
|
|
return preferences;
|
|
} catch (error) {
|
|
console.error("Error fetching user preferences:", error);
|
|
throw new Error("Failed to fetch user preferences");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Analyze metadata conflicts for a comic
|
|
* @async
|
|
* @function analyzeMetadataConflicts
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {string} args.comicId - Comic ID to analyze
|
|
* @returns {Promise<Array>} Array of metadata conflicts with candidates and resolution
|
|
* @throws {Error} If comic or preferences not found, or analysis fails
|
|
* @description Analyzes metadata conflicts by comparing values from different sources
|
|
* for key fields (title, series, issueNumber, description, publisher).
|
|
* Returns conflicts with all candidates and the resolved value.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* analyzeMetadataConflicts(comicId: "507f1f77bcf86cd799439011") {
|
|
* field
|
|
* candidates { value provenance { source confidence } }
|
|
* resolved { value provenance { source } }
|
|
* resolutionReason
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
analyzeMetadataConflicts: async (
|
|
_: any,
|
|
{ comicId }: { comicId: string }
|
|
) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
if (!preferences) {
|
|
throw new Error("User preferences not found");
|
|
}
|
|
|
|
const conflicts: any[] = [];
|
|
|
|
// Analyze each field for conflicts
|
|
const fields = [
|
|
"title",
|
|
"series",
|
|
"issueNumber",
|
|
"description",
|
|
"publisher",
|
|
];
|
|
|
|
for (const field of fields) {
|
|
const candidates = extractCandidatesForField(
|
|
field,
|
|
comic.sourcedMetadata
|
|
);
|
|
|
|
if (candidates.length > 1) {
|
|
const resolved = resolveMetadataField(
|
|
field,
|
|
candidates,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
conflicts.push({
|
|
field,
|
|
candidates,
|
|
resolved,
|
|
resolutionReason: getResolutionReason(
|
|
resolved,
|
|
candidates,
|
|
preferences
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
} catch (error) {
|
|
console.error("Error analyzing metadata conflicts:", error);
|
|
throw new Error("Failed to analyze metadata conflicts");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Preview canonical metadata resolution without saving
|
|
* @async
|
|
* @function previewCanonicalMetadata
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {string} args.comicId - Comic ID to preview
|
|
* @param {Object} [args.preferences] - Optional preference overrides for preview
|
|
* @returns {Promise<CanonicalMetadata>} Preview of resolved canonical metadata
|
|
* @throws {Error} If comic or preferences not found
|
|
* @description Previews how canonical metadata would be resolved with current
|
|
* or provided preferences without saving to the database. Useful for testing
|
|
* different resolution strategies.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* previewCanonicalMetadata(
|
|
* comicId: "507f1f77bcf86cd799439011"
|
|
* preferences: { conflictResolution: CONFIDENCE }
|
|
* ) {
|
|
* title { value provenance { source confidence } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
previewCanonicalMetadata: async (
|
|
_: any,
|
|
{
|
|
comicId,
|
|
preferences: preferencesInput,
|
|
}: { comicId: string; preferences?: any }
|
|
) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
let preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
|
|
// Use provided preferences or default
|
|
if (preferencesInput) {
|
|
preferences = applyPreferencesInput(preferences, preferencesInput);
|
|
}
|
|
|
|
if (!preferences) {
|
|
throw new Error("User preferences not found");
|
|
}
|
|
|
|
const canonical = buildCanonicalMetadata(
|
|
comic.sourcedMetadata,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
return canonical;
|
|
} catch (error) {
|
|
console.error("Error previewing canonical metadata:", error);
|
|
throw new Error("Failed to preview canonical metadata");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get import statistics for a directory
|
|
* @async
|
|
* @function getImportStatistics
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments
|
|
* @param {string} [args.directoryPath] - Optional directory path to analyze
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Object>} Import statistics including total files, imported count, and new files
|
|
* @throws {Error} If statistics calculation fails
|
|
* @description Analyzes a directory (or default COMICS_DIRECTORY) and compares
|
|
* files against the database to determine import status. This performs a full
|
|
* filesystem scan and is slower than getCachedImportStatistics.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getImportStatistics(directoryPath: "/path/to/comics") {
|
|
* success
|
|
* directory
|
|
* stats {
|
|
* totalLocalFiles
|
|
* alreadyImported
|
|
* newFiles
|
|
* percentageImported
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getImportStatistics: async (
|
|
_: any,
|
|
{ directoryPath }: { directoryPath?: string },
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
const result = await broker.call("library.getImportStatistics", {
|
|
directoryPath,
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error fetching import statistics:", error);
|
|
throw new Error(`Failed to fetch import statistics: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Get job result statistics grouped by session
|
|
* @async
|
|
* @function getJobResultStatistics
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments (none)
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Array>} Array of job result statistics by session
|
|
* @throws {Error} If job queue service is unavailable
|
|
* @description Retrieves job result statistics grouped by session ID,
|
|
* including counts of completed and failed jobs and earliest timestamp.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getJobResultStatistics {
|
|
* sessionId
|
|
* completedJobs
|
|
* failedJobs
|
|
* earliestTimestamp
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getJobResultStatistics: async (
|
|
_: any,
|
|
args: {},
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
const result = await broker.call("jobqueue.getJobResultStatistics");
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error fetching job result statistics:", error);
|
|
throw new Error(`Failed to fetch job result statistics: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get active import session (if any)
|
|
* @async
|
|
* @function getActiveImportSession
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Query arguments (none)
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Object|null>} Active import session or null
|
|
* @throws {Error} If import state service is unavailable
|
|
* @description Retrieves the currently active import session (if any).
|
|
* Useful for checking if an import is in progress before starting a new one.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* query {
|
|
* getActiveImportSession {
|
|
* sessionId
|
|
* type
|
|
* status
|
|
* startedAt
|
|
* stats {
|
|
* totalFiles
|
|
* filesProcessed
|
|
* filesSucceeded
|
|
* filesFailed
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
getActiveImportSession: async (
|
|
_: any,
|
|
args: {},
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
const session = await broker.call("importstate.getActiveSession");
|
|
console.log("[GraphQL] getActiveImportSession result:", session ? `Session ${session.sessionId} (${session.type}, ${session.status})` : "No active session");
|
|
return session;
|
|
} catch (error) {
|
|
console.error("Error fetching active import session:", error);
|
|
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: {
|
|
/**
|
|
* Update user preferences for metadata resolution
|
|
* @async
|
|
* @function updateUserPreferences
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} [args.userId='default'] - User ID
|
|
* @param {Object} args.preferences - Preferences to update
|
|
* @returns {Promise<UserPreferences>} Updated preferences document
|
|
* @throws {Error} If update fails
|
|
* @description Updates user preferences for metadata resolution including
|
|
* source priorities, conflict resolution strategy, confidence thresholds,
|
|
* field preferences, and auto-merge settings.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* updateUserPreferences(
|
|
* userId: "default"
|
|
* preferences: {
|
|
* conflictResolution: CONFIDENCE
|
|
* minConfidenceThreshold: 0.8
|
|
* autoMerge: { enabled: true, onImport: true }
|
|
* }
|
|
* ) {
|
|
* id
|
|
* conflictResolution
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
updateUserPreferences: async (
|
|
_: any,
|
|
{
|
|
userId = "default",
|
|
preferences: preferencesInput,
|
|
}: { userId?: string; preferences: any }
|
|
) => {
|
|
try {
|
|
let preferences = await UserPreferences.findOne({ userId });
|
|
|
|
if (!preferences) {
|
|
preferences = new UserPreferences({ userId });
|
|
}
|
|
|
|
// Update preferences
|
|
if (preferencesInput.sourcePriorities) {
|
|
preferences.sourcePriorities = preferencesInput.sourcePriorities.map(
|
|
(sp: any) => ({
|
|
source: sp.source,
|
|
priority: sp.priority,
|
|
enabled: sp.enabled,
|
|
fieldOverrides: sp.fieldOverrides
|
|
? new Map(
|
|
sp.fieldOverrides.map((fo: any) => [fo.field, fo.priority])
|
|
)
|
|
: new Map(),
|
|
})
|
|
);
|
|
}
|
|
|
|
if (preferencesInput.conflictResolution) {
|
|
preferences.conflictResolution = preferencesInput.conflictResolution;
|
|
}
|
|
|
|
if (preferencesInput.minConfidenceThreshold !== undefined) {
|
|
preferences.minConfidenceThreshold =
|
|
preferencesInput.minConfidenceThreshold;
|
|
}
|
|
|
|
if (preferencesInput.preferRecent !== undefined) {
|
|
preferences.preferRecent = preferencesInput.preferRecent;
|
|
}
|
|
|
|
if (preferencesInput.fieldPreferences) {
|
|
preferences.fieldPreferences = new Map(
|
|
preferencesInput.fieldPreferences.map((fp: any) => [
|
|
fp.field,
|
|
fp.preferredSource,
|
|
])
|
|
);
|
|
}
|
|
|
|
if (preferencesInput.autoMerge) {
|
|
preferences.autoMerge = {
|
|
...preferences.autoMerge,
|
|
...preferencesInput.autoMerge,
|
|
};
|
|
}
|
|
|
|
await preferences.save();
|
|
return preferences;
|
|
} catch (error) {
|
|
console.error("Error updating user preferences:", error);
|
|
throw new Error("Failed to update user preferences");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Manually set a metadata field (creates user override)
|
|
* @async
|
|
* @function setMetadataField
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.comicId - Comic ID
|
|
* @param {string} args.field - Field name to set
|
|
* @param {any} args.value - New value for the field
|
|
* @returns {Promise<Comic>} Updated comic document
|
|
* @throws {Error} If comic not found or update fails
|
|
* @description Manually sets a metadata field value, creating a user override
|
|
* that takes precedence over all source data. Marks the field with userOverride flag.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* setMetadataField(
|
|
* comicId: "507f1f77bcf86cd799439011"
|
|
* field: "title"
|
|
* value: "Batman: The Dark Knight Returns"
|
|
* ) {
|
|
* id
|
|
* canonicalMetadata { title { value userOverride } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
setMetadataField: async (
|
|
_: any,
|
|
{ comicId, field, value }: { comicId: string; field: string; value: any }
|
|
) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
// Set the field with user override
|
|
const fieldPath = `canonicalMetadata.${field}`;
|
|
const update = {
|
|
[fieldPath]: {
|
|
value,
|
|
provenance: {
|
|
source: MetadataSource.MANUAL,
|
|
confidence: 1.0,
|
|
fetchedAt: new Date(),
|
|
},
|
|
userOverride: true,
|
|
},
|
|
};
|
|
|
|
const updatedComic = await Comic.findByIdAndUpdate(
|
|
comicId,
|
|
{ $set: update },
|
|
{ new: true }
|
|
);
|
|
|
|
return updatedComic;
|
|
} catch (error) {
|
|
console.error("Error setting metadata field:", error);
|
|
throw new Error("Failed to set metadata field");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Trigger metadata resolution for a comic
|
|
* @async
|
|
* @function resolveMetadata
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.comicId - Comic ID to resolve
|
|
* @returns {Promise<Comic>} Comic with resolved canonical metadata
|
|
* @throws {Error} If comic or preferences not found, or resolution fails
|
|
* @description Triggers metadata resolution for a comic, building canonical
|
|
* metadata from all available sources using current user preferences.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* resolveMetadata(comicId: "507f1f77bcf86cd799439011") {
|
|
* id
|
|
* canonicalMetadata { title { value provenance { source } } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
resolveMetadata: async (_: any, { comicId }: { comicId: string }) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
if (!preferences) {
|
|
throw new Error("User preferences not found");
|
|
}
|
|
|
|
// Build canonical metadata
|
|
const canonical = buildCanonicalMetadata(
|
|
comic.sourcedMetadata,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
// Update comic with canonical metadata
|
|
comic.canonicalMetadata = canonical;
|
|
await comic.save();
|
|
|
|
return comic;
|
|
} catch (error) {
|
|
console.error("Error resolving metadata:", error);
|
|
throw new Error("Failed to resolve metadata");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Bulk resolve metadata for multiple comics
|
|
* @async
|
|
* @function bulkResolveMetadata
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string[]} args.comicIds - Array of comic IDs to resolve
|
|
* @returns {Promise<Comic[]>} Array of comics with resolved metadata
|
|
* @throws {Error} If preferences not found or resolution fails
|
|
* @description Resolves metadata for multiple comics in bulk using current
|
|
* user preferences. Skips comics that don't exist.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* bulkResolveMetadata(comicIds: ["507f...", "507f..."]) {
|
|
* id
|
|
* canonicalMetadata { title { value } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
bulkResolveMetadata: async (
|
|
_: any,
|
|
{ comicIds }: { comicIds: string[] }
|
|
) => {
|
|
try {
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
if (!preferences) {
|
|
throw new Error("User preferences not found");
|
|
}
|
|
|
|
const resolvedComics = [];
|
|
|
|
for (const comicId of comicIds) {
|
|
const comic = await Comic.findById(comicId);
|
|
if (comic) {
|
|
const canonical = buildCanonicalMetadata(
|
|
comic.sourcedMetadata,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
comic.canonicalMetadata = canonical;
|
|
await comic.save();
|
|
resolvedComics.push(comic);
|
|
}
|
|
}
|
|
|
|
return resolvedComics;
|
|
} catch (error) {
|
|
console.error("Error bulk resolving metadata:", error);
|
|
throw new Error("Failed to bulk resolve metadata");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Remove user override for a field
|
|
* @async
|
|
* @function removeMetadataOverride
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.comicId - Comic ID
|
|
* @param {string} args.field - Field name to remove override from
|
|
* @returns {Promise<Comic>} Updated comic document
|
|
* @throws {Error} If comic or preferences not found, or update fails
|
|
* @description Removes a user override for a field and re-resolves it from
|
|
* source data using current preferences.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* removeMetadataOverride(
|
|
* comicId: "507f1f77bcf86cd799439011"
|
|
* field: "title"
|
|
* ) {
|
|
* id
|
|
* canonicalMetadata { title { value userOverride } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
removeMetadataOverride: async (
|
|
_: any,
|
|
{ comicId, field }: { comicId: string; field: string }
|
|
) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
if (!preferences) {
|
|
throw new Error("User preferences not found");
|
|
}
|
|
|
|
// Re-resolve the field without user override
|
|
const candidates = extractCandidatesForField(
|
|
field,
|
|
comic.sourcedMetadata
|
|
).filter((c) => !c.userOverride);
|
|
|
|
const resolved = resolveMetadataField(
|
|
field,
|
|
candidates,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
if (resolved) {
|
|
const fieldPath = `canonicalMetadata.${field}`;
|
|
await Comic.findByIdAndUpdate(comicId, {
|
|
$set: { [fieldPath]: resolved },
|
|
});
|
|
}
|
|
|
|
const updatedComic = await Comic.findById(comicId);
|
|
return updatedComic;
|
|
} catch (error) {
|
|
console.error("Error removing metadata override:", error);
|
|
throw new Error("Failed to remove metadata override");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh metadata from a specific source
|
|
* @async
|
|
* @function refreshMetadataFromSource
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.comicId - Comic ID
|
|
* @param {MetadataSource} args.source - Source to refresh from
|
|
* @returns {Promise<Comic>} Updated comic document
|
|
* @throws {Error} Not implemented - requires integration with metadata services
|
|
* @description Placeholder for refreshing metadata from a specific external source.
|
|
* Would trigger a re-fetch from the specified source and update sourced metadata.
|
|
*/
|
|
refreshMetadataFromSource: async (
|
|
_: any,
|
|
{ comicId, source }: { comicId: string; source: MetadataSource }
|
|
) => {
|
|
try {
|
|
// This would trigger a re-fetch from the external source
|
|
// Implementation depends on your existing metadata fetching services
|
|
throw new Error("Not implemented - requires integration with metadata services");
|
|
} catch (error) {
|
|
console.error("Error refreshing metadata from source:", error);
|
|
throw new Error("Failed to refresh metadata from source");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Import a new comic with automatic metadata resolution
|
|
* @async
|
|
* @function importComic
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {Object} args.input - Comic import data including file details and metadata
|
|
* @returns {Promise<Object>} Import result with success status and comic
|
|
* @throws {Error} If import fails
|
|
* @description Imports a new comic into the library with all metadata sources.
|
|
* Automatically resolves canonical metadata if auto-merge is enabled in preferences.
|
|
* Checks for duplicates before importing.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* importComic(input: {
|
|
* filePath: "/comics/batman-1.cbz"
|
|
* rawFileDetails: { name: "batman-1.cbz", fileSize: 12345 }
|
|
* sourcedMetadata: { comicInfo: "{...}" }
|
|
* }) {
|
|
* success
|
|
* comic { id }
|
|
* message
|
|
* canonicalMetadataResolved
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
importComic: async (_: any, { input }: { input: any }) => {
|
|
try {
|
|
console.log("Importing comic via GraphQL:", input.filePath);
|
|
|
|
// 1. Check if comic already exists
|
|
const existingComic = await Comic.findOne({
|
|
"rawFileDetails.name": input.rawFileDetails?.name,
|
|
});
|
|
|
|
if (existingComic) {
|
|
return {
|
|
success: false,
|
|
comic: existingComic,
|
|
message: "Comic already exists in the library",
|
|
canonicalMetadataResolved: false,
|
|
};
|
|
}
|
|
|
|
// 2. Prepare comic data
|
|
const comicData: any = {
|
|
importStatus: {
|
|
isImported: true,
|
|
tagged: false,
|
|
},
|
|
};
|
|
|
|
// Add raw file details
|
|
if (input.rawFileDetails) {
|
|
comicData.rawFileDetails = input.rawFileDetails;
|
|
}
|
|
|
|
// Add inferred metadata
|
|
if (input.inferredMetadata) {
|
|
comicData.inferredMetadata = input.inferredMetadata;
|
|
}
|
|
|
|
// Add sourced metadata
|
|
if (input.sourcedMetadata) {
|
|
comicData.sourcedMetadata = {};
|
|
|
|
if (input.sourcedMetadata.comicInfo) {
|
|
comicData.sourcedMetadata.comicInfo = JSON.parse(
|
|
input.sourcedMetadata.comicInfo
|
|
);
|
|
}
|
|
if (input.sourcedMetadata.comicvine) {
|
|
comicData.sourcedMetadata.comicvine = JSON.parse(
|
|
input.sourcedMetadata.comicvine
|
|
);
|
|
}
|
|
if (input.sourcedMetadata.metron) {
|
|
comicData.sourcedMetadata.metron = JSON.parse(
|
|
input.sourcedMetadata.metron
|
|
);
|
|
}
|
|
if (input.sourcedMetadata.gcd) {
|
|
comicData.sourcedMetadata.gcd = JSON.parse(
|
|
input.sourcedMetadata.gcd
|
|
);
|
|
}
|
|
if (input.sourcedMetadata.locg) {
|
|
comicData.sourcedMetadata.locg = input.sourcedMetadata.locg;
|
|
}
|
|
}
|
|
|
|
// Add wanted information
|
|
if (input.wanted) {
|
|
comicData.wanted = input.wanted;
|
|
}
|
|
|
|
// Add acquisition information
|
|
if (input.acquisition) {
|
|
comicData.acquisition = input.acquisition;
|
|
}
|
|
|
|
// 3. Create the comic document
|
|
const comic = await Comic.create(comicData);
|
|
console.log(`Comic created with ID: ${comic._id}`);
|
|
|
|
// 4. Check if auto-resolution is enabled
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
|
|
let canonicalMetadataResolved = false;
|
|
|
|
if (
|
|
preferences?.autoMerge?.enabled &&
|
|
preferences?.autoMerge?.onImport
|
|
) {
|
|
console.log("Auto-resolving canonical metadata...");
|
|
|
|
// Build canonical metadata
|
|
const canonical = buildCanonicalMetadata(
|
|
comic.sourcedMetadata,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
// Update comic with canonical metadata
|
|
comic.canonicalMetadata = canonical;
|
|
await comic.save();
|
|
|
|
canonicalMetadataResolved = true;
|
|
console.log("Canonical metadata resolved successfully");
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
comic,
|
|
message: "Comic imported successfully",
|
|
canonicalMetadataResolved,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error importing comic:", error);
|
|
throw new Error(`Failed to import comic: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update sourced metadata and trigger resolution
|
|
* @async
|
|
* @function updateSourcedMetadata
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.comicId - Comic ID
|
|
* @param {MetadataSource} args.source - Source being updated
|
|
* @param {string} args.metadata - JSON string of new metadata
|
|
* @returns {Promise<Comic>} Updated comic with re-resolved canonical metadata
|
|
* @throws {Error} If comic not found, JSON invalid, or update fails
|
|
* @description Updates sourced metadata from a specific source and automatically
|
|
* re-resolves canonical metadata if auto-merge on update is enabled.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* updateSourcedMetadata(
|
|
* comicId: "507f1f77bcf86cd799439011"
|
|
* source: COMICVINE
|
|
* metadata: "{\"name\": \"Batman #1\", ...}"
|
|
* ) {
|
|
* id
|
|
* canonicalMetadata { title { value } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
updateSourcedMetadata: async (
|
|
_: any,
|
|
{
|
|
comicId,
|
|
source,
|
|
metadata,
|
|
}: { comicId: string; source: MetadataSource; metadata: string }
|
|
) => {
|
|
try {
|
|
const comic = await Comic.findById(comicId);
|
|
if (!comic) {
|
|
throw new Error("Comic not found");
|
|
}
|
|
|
|
// Parse and update the sourced metadata
|
|
const parsedMetadata = JSON.parse(metadata);
|
|
const sourceKey = source.toLowerCase();
|
|
|
|
if (!comic.sourcedMetadata) {
|
|
comic.sourcedMetadata = {};
|
|
}
|
|
|
|
comic.sourcedMetadata[sourceKey] = parsedMetadata;
|
|
await comic.save();
|
|
|
|
console.log(
|
|
`Updated ${source} metadata for comic ${comicId}`
|
|
);
|
|
|
|
// Check if auto-resolution is enabled
|
|
const preferences = await UserPreferences.findOne({
|
|
userId: "default",
|
|
});
|
|
|
|
if (
|
|
preferences?.autoMerge?.enabled &&
|
|
preferences?.autoMerge?.onMetadataUpdate
|
|
) {
|
|
console.log("Auto-resolving canonical metadata after update...");
|
|
|
|
// Build canonical metadata
|
|
const canonical = buildCanonicalMetadata(
|
|
comic.sourcedMetadata,
|
|
convertPreferences(preferences)
|
|
);
|
|
|
|
// Update comic with canonical metadata
|
|
comic.canonicalMetadata = canonical;
|
|
await comic.save();
|
|
|
|
console.log("Canonical metadata resolved after update");
|
|
}
|
|
|
|
return comic;
|
|
} catch (error) {
|
|
console.error("Error updating sourced metadata:", error);
|
|
throw new Error(`Failed to update sourced metadata: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Start a new full import of the comics directory
|
|
* @async
|
|
* @function startNewImport
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.sessionId - Session ID for tracking this import batch
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Object>} Import job result with success status and jobs queued count
|
|
* @throws {Error} If import service is unavailable or import fails
|
|
* @description Starts a full import of all comics in the comics directory.
|
|
* Scans the entire directory and queues jobs for all comic files that haven't
|
|
* been imported yet. Checks for active import sessions to prevent race conditions.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* startNewImport(sessionId: "import-2024-01-01") {
|
|
* success
|
|
* message
|
|
* jobsQueued
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
startNewImport: async (
|
|
_: any,
|
|
{ sessionId }: { sessionId: string },
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
// Check for active import sessions (race condition prevention)
|
|
const activeSession = await broker.call("importstate.getActiveSession");
|
|
if (activeSession) {
|
|
throw new Error(
|
|
`Cannot start new import: Another import session "${activeSession.sessionId}" is already active (${activeSession.type}). Please wait for it to complete.`
|
|
);
|
|
}
|
|
|
|
// Call the library service to start new import
|
|
await broker.call("library.newImport", {
|
|
sessionId,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: "New import started successfully",
|
|
jobsQueued: 0, // The actual count is tracked asynchronously
|
|
};
|
|
} catch (error) {
|
|
console.error("Error starting new import:", error);
|
|
throw new Error(`Failed to start new import: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Start an incremental import (only new files)
|
|
* @async
|
|
* @function startIncrementalImport
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Mutation arguments
|
|
* @param {string} args.sessionId - Session ID for tracking this import batch
|
|
* @param {string} [args.directoryPath] - Optional directory path to scan (defaults to COMICS_DIRECTORY)
|
|
* @param {Object} context - GraphQL context with broker
|
|
* @returns {Promise<Object>} Incremental import result with statistics
|
|
* @throws {Error} If import service is unavailable or import fails
|
|
* @description Starts an incremental import that only processes new files
|
|
* not already in the database. More efficient than full import for large libraries.
|
|
* Checks for active import sessions to prevent race conditions.
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* startIncrementalImport(
|
|
* sessionId: "incremental-2024-01-01"
|
|
* directoryPath: "/path/to/comics"
|
|
* ) {
|
|
* success
|
|
* message
|
|
* stats {
|
|
* total
|
|
* alreadyImported
|
|
* newFiles
|
|
* queued
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
startIncrementalImport: async (
|
|
_: any,
|
|
{
|
|
sessionId,
|
|
directoryPath,
|
|
}: { sessionId: string; directoryPath?: string },
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
// Check for active import sessions (race condition prevention)
|
|
const activeSession = await broker.call("importstate.getActiveSession");
|
|
if (activeSession) {
|
|
throw new Error(
|
|
`Cannot start incremental import: Another import session "${activeSession.sessionId}" is already active (${activeSession.type}). Please wait for it to complete.`
|
|
);
|
|
}
|
|
|
|
// Call the library service to start incremental import
|
|
const result = await broker.call("library.incrementalImport", {
|
|
sessionId,
|
|
directoryPath,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error starting incremental import:", error);
|
|
throw new Error(`Failed to start incremental import: ${error.message}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Force complete a stuck import session
|
|
* @async
|
|
* @function forceCompleteSession
|
|
* @param {any} _ - Parent resolver (unused)
|
|
* @param {Object} args - Arguments
|
|
* @param {string} args.sessionId - Session ID to force complete
|
|
* @param {any} context - GraphQL context with broker
|
|
* @returns {Promise<Object>} Result with success status and message
|
|
* @throws {Error} If broker is unavailable or session completion fails
|
|
*
|
|
* @example
|
|
* ```graphql
|
|
* mutation {
|
|
* forceCompleteSession(sessionId: "d7c5043f-5438-4076-9480-2782267899b6") {
|
|
* success
|
|
* message
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
forceCompleteSession: async (
|
|
_: any,
|
|
{ sessionId }: { sessionId: string },
|
|
context: any
|
|
) => {
|
|
try {
|
|
const broker = context?.broker;
|
|
|
|
if (!broker) {
|
|
throw new Error("Broker not available in context");
|
|
}
|
|
|
|
// Force complete the session (mark as failed since it was stuck)
|
|
await broker.call("importstate.completeSession", {
|
|
sessionId,
|
|
success: false,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: `Session ${sessionId} has been force completed and removed from active sessions`,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error force completing session:", error);
|
|
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}`);
|
|
}
|
|
},
|
|
},
|
|
|
|
/**
|
|
* Field resolvers for Comic type
|
|
* @description Custom field resolvers for transforming Comic data
|
|
*/
|
|
Comic: {
|
|
/**
|
|
* Resolve Comic ID field
|
|
* @param {any} comic - Comic document
|
|
* @returns {string} String representation of MongoDB ObjectId
|
|
*/
|
|
id: (comic: any) => comic._id.toString(),
|
|
|
|
/**
|
|
* Resolve sourced metadata field
|
|
* @param {any} comic - Comic document
|
|
* @returns {Object} Sourced metadata with JSON-stringified sources
|
|
* @description Converts sourced metadata objects to JSON strings for GraphQL transport
|
|
*/
|
|
sourcedMetadata: (comic: any) => ({
|
|
comicInfo: JSON.stringify(comic.sourcedMetadata?.comicInfo || {}),
|
|
comicvine: JSON.stringify(comic.sourcedMetadata?.comicvine || {}),
|
|
metron: JSON.stringify(comic.sourcedMetadata?.metron || {}),
|
|
gcd: JSON.stringify(comic.sourcedMetadata?.gcd || {}),
|
|
locg: comic.sourcedMetadata?.locg || null,
|
|
}),
|
|
},
|
|
|
|
/**
|
|
* Field resolvers for FileTypeStats type
|
|
* @description Resolves ID field for file type statistics
|
|
*/
|
|
FileTypeStats: {
|
|
/**
|
|
* Resolve FileTypeStats ID
|
|
* @param {any} stats - Statistics document
|
|
* @returns {string} ID value
|
|
*/
|
|
id: (stats: any) => stats._id || stats.id,
|
|
},
|
|
|
|
/**
|
|
* Field resolvers for PublisherStats type
|
|
* @description Resolves ID field for publisher statistics
|
|
*/
|
|
PublisherStats: {
|
|
/**
|
|
* Resolve PublisherStats ID
|
|
* @param {any} stats - Statistics document
|
|
* @returns {string} ID value
|
|
*/
|
|
id: (stats: any) => stats._id || stats.id,
|
|
},
|
|
|
|
/**
|
|
* Field resolvers for IssueStats type
|
|
* @description Resolves ID field for issue statistics
|
|
*/
|
|
IssueStats: {
|
|
/**
|
|
* Resolve IssueStats ID
|
|
* @param {any} stats - Statistics document
|
|
* @returns {string} ID value
|
|
*/
|
|
id: (stats: any) => stats._id || stats.id,
|
|
},
|
|
|
|
/**
|
|
* Field resolvers for UserPreferences type
|
|
* @description Custom resolvers for transforming UserPreferences data
|
|
*/
|
|
UserPreferences: {
|
|
/**
|
|
* Resolve UserPreferences ID
|
|
* @param {any} prefs - Preferences document
|
|
* @returns {string} String representation of MongoDB ObjectId
|
|
*/
|
|
id: (prefs: any) => prefs._id.toString(),
|
|
|
|
/**
|
|
* Resolve field preferences
|
|
* @param {any} prefs - Preferences document
|
|
* @returns {Array} Array of field preference objects
|
|
* @description Converts Map to array of {field, preferredSource} objects
|
|
*/
|
|
fieldPreferences: (prefs: any) => {
|
|
if (!prefs.fieldPreferences) return [];
|
|
return Array.from(prefs.fieldPreferences.entries()).map(
|
|
([field, preferredSource]) => ({
|
|
field,
|
|
preferredSource,
|
|
})
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Resolve source priorities
|
|
* @param {any} prefs - Preferences document
|
|
* @returns {Array} Array of source priority objects with field overrides
|
|
* @description Converts fieldOverrides Map to array format for GraphQL
|
|
*/
|
|
sourcePriorities: (prefs: any) => {
|
|
return prefs.sourcePriorities.map((sp: any) => ({
|
|
...sp,
|
|
fieldOverrides: sp.fieldOverrides
|
|
? Array.from(sp.fieldOverrides.entries()).map(([field, priority]) => ({
|
|
field,
|
|
priority,
|
|
}))
|
|
: [],
|
|
}));
|
|
},
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Extract metadata field candidates from sourced metadata
|
|
* @private
|
|
* @function extractCandidatesForField
|
|
* @param {string} field - Field name to extract
|
|
* @param {any} sourcedMetadata - Sourced metadata object
|
|
* @returns {MetadataField[]} Array of metadata field candidates with provenance
|
|
* @description Extracts all available values for a field from different metadata sources.
|
|
* Maps field names to source-specific paths and extracts values with provenance information.
|
|
*/
|
|
function extractCandidatesForField(
|
|
field: string,
|
|
sourcedMetadata: any
|
|
): MetadataField[] {
|
|
const candidates: MetadataField[] = [];
|
|
|
|
// Map field names to source paths
|
|
const mappings: Record<string, any> = {
|
|
title: [
|
|
{ source: MetadataSource.COMICVINE, path: "name", data: sourcedMetadata.comicvine },
|
|
{ source: MetadataSource.COMICINFO_XML, path: "Title", data: sourcedMetadata.comicInfo },
|
|
{ source: MetadataSource.LOCG, path: "name", data: sourcedMetadata.locg },
|
|
],
|
|
series: [
|
|
{ source: MetadataSource.COMICVINE, path: "volumeInformation.name", data: sourcedMetadata.comicvine },
|
|
{ source: MetadataSource.COMICINFO_XML, path: "Series", data: sourcedMetadata.comicInfo },
|
|
],
|
|
issueNumber: [
|
|
{ source: MetadataSource.COMICVINE, path: "issue_number", data: sourcedMetadata.comicvine },
|
|
{ source: MetadataSource.COMICINFO_XML, path: "Number", data: sourcedMetadata.comicInfo },
|
|
],
|
|
description: [
|
|
{ source: MetadataSource.COMICVINE, path: "description", data: sourcedMetadata.comicvine },
|
|
{ source: MetadataSource.LOCG, path: "description", data: sourcedMetadata.locg },
|
|
{ source: MetadataSource.COMICINFO_XML, path: "Summary", data: sourcedMetadata.comicInfo },
|
|
],
|
|
publisher: [
|
|
{ source: MetadataSource.COMICVINE, path: "volumeInformation.publisher.name", data: sourcedMetadata.comicvine },
|
|
{ source: MetadataSource.LOCG, path: "publisher", data: sourcedMetadata.locg },
|
|
{ source: MetadataSource.COMICINFO_XML, path: "Publisher", data: sourcedMetadata.comicInfo },
|
|
],
|
|
};
|
|
|
|
const fieldMappings = mappings[field] || [];
|
|
|
|
for (const mapping of fieldMappings) {
|
|
if (!mapping.data) continue;
|
|
|
|
const value = getNestedValue(mapping.data, mapping.path);
|
|
if (value !== null && value !== undefined) {
|
|
candidates.push({
|
|
value,
|
|
provenance: {
|
|
source: mapping.source,
|
|
confidence: 0.9,
|
|
fetchedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
/**
|
|
* Get nested value from object using dot notation path
|
|
* @private
|
|
* @function getNestedValue
|
|
* @param {any} obj - Object to traverse
|
|
* @param {string} path - Dot-notation path (e.g., "volumeInformation.name")
|
|
* @returns {any} Value at path or undefined
|
|
* @description Safely traverses nested object properties using dot notation.
|
|
*/
|
|
function getNestedValue(obj: any, path: string): any {
|
|
return path.split(".").reduce((current, key) => current?.[key], obj);
|
|
}
|
|
|
|
/**
|
|
* Convert UserPreferences model to ResolutionPreferences format
|
|
* @private
|
|
* @function convertPreferences
|
|
* @param {any} prefs - UserPreferences document
|
|
* @returns {ResolutionPreferences} Preferences in resolution utility format
|
|
* @description Transforms UserPreferences model to the format expected by
|
|
* metadata resolution utilities.
|
|
*/
|
|
function convertPreferences(prefs: any): ResolutionPreferences {
|
|
return {
|
|
sourcePriorities: prefs.sourcePriorities.map((sp: any) => ({
|
|
source: sp.source,
|
|
priority: sp.priority,
|
|
enabled: sp.enabled,
|
|
fieldOverrides: sp.fieldOverrides,
|
|
})),
|
|
conflictResolution: prefs.conflictResolution,
|
|
minConfidenceThreshold: prefs.minConfidenceThreshold,
|
|
preferRecent: prefs.preferRecent,
|
|
fieldPreferences: prefs.fieldPreferences,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get human-readable resolution reason
|
|
* @private
|
|
* @function getResolutionReason
|
|
* @param {MetadataField|null} resolved - Resolved metadata field
|
|
* @param {MetadataField[]} candidates - All candidate fields
|
|
* @param {any} preferences - User preferences
|
|
* @returns {string} Human-readable explanation of resolution
|
|
* @description Generates explanation for why a particular field value was chosen.
|
|
*/
|
|
function getResolutionReason(
|
|
resolved: MetadataField | null,
|
|
candidates: MetadataField[],
|
|
preferences: any
|
|
): string {
|
|
if (!resolved) return "No valid candidates";
|
|
|
|
if (resolved.userOverride) {
|
|
return "User override";
|
|
}
|
|
|
|
const priority = preferences.getSourcePriority(resolved.provenance.source);
|
|
return `Resolved using ${resolved.provenance.source} (priority: ${priority}, confidence: ${resolved.provenance.confidence})`;
|
|
}
|
|
|
|
/**
|
|
* Apply preference input overrides to existing preferences
|
|
* @private
|
|
* @function applyPreferencesInput
|
|
* @param {any} prefs - Existing preferences document
|
|
* @param {any} input - Input preferences to apply
|
|
* @returns {any} Updated preferences object
|
|
* @description Merges input preferences with existing preferences for preview operations.
|
|
*/
|
|
function applyPreferencesInput(prefs: any, input: any): any {
|
|
const updated = { ...prefs.toObject() };
|
|
|
|
if (input.sourcePriorities) {
|
|
updated.sourcePriorities = input.sourcePriorities;
|
|
}
|
|
if (input.conflictResolution) {
|
|
updated.conflictResolution = input.conflictResolution;
|
|
}
|
|
if (input.minConfidenceThreshold !== undefined) {
|
|
updated.minConfidenceThreshold = input.minConfidenceThreshold;
|
|
}
|
|
if (input.preferRecent !== undefined) {
|
|
updated.preferRecent = input.preferRecent;
|
|
}
|
|
|
|
return updated;
|
|
}
|