🛠 graphql changes

This commit is contained in:
2026-02-24 16:29:48 -05:00
parent cd446a9ca3
commit f7804ee3f0
11 changed files with 3317 additions and 40 deletions

4
.gitignore vendored
View File

@@ -72,3 +72,7 @@ erl_crash.dump
temp
test
.nova
CANONICAL_METADATA.md
GRAPHQL_LEVERAGE_GUIDE.md
IMPORT_WITH_GRAPHQL.md
JOBQUEUE_GRAPHQL_INTEGRATION.md

View File

@@ -0,0 +1,420 @@
/**
* Example: Importing Comics with GraphQL and Canonical Metadata
*
* This example demonstrates how to import comics using the new GraphQL-based
* import system that automatically resolves canonical metadata from multiple sources.
*/
import { ServiceBroker } from "moleculer";
import {
importComicViaGraphQL,
updateSourcedMetadataViaGraphQL,
resolveMetadataViaGraphQL,
analyzeMetadataConflictsViaGraphQL,
getComicViaGraphQL,
} from "../utils/import.graphql.utils";
/**
* Example 1: Basic Comic Import
* Import a comic with ComicInfo.xml metadata
*/
async function example1_basicImport(broker: ServiceBroker) {
console.log("\n=== Example 1: Basic Comic Import ===\n");
const result = await importComicViaGraphQL(broker, {
filePath: "/comics/amazing-spider-man-001.cbz",
fileSize: 12345678,
rawFileDetails: {
name: "Amazing Spider-Man 001",
filePath: "/comics/amazing-spider-man-001.cbz",
fileSize: 12345678,
extension: ".cbz",
mimeType: "application/x-cbz",
pageCount: 24,
},
inferredMetadata: {
issue: {
name: "Amazing Spider-Man",
number: 1,
year: "2023",
},
},
sourcedMetadata: {
comicInfo: {
Title: "Amazing Spider-Man #1",
Series: "Amazing Spider-Man",
Number: "1",
Publisher: "Marvel Comics",
Summary: "Peter Parker's origin story begins...",
Year: "2023",
Month: "1",
},
},
});
console.log("Import Result:", {
success: result.success,
message: result.message,
canonicalMetadataResolved: result.canonicalMetadataResolved,
comicId: result.comic.id,
});
console.log("\nCanonical Metadata:");
console.log(" Title:", result.comic.canonicalMetadata?.title?.value);
console.log(" Source:", result.comic.canonicalMetadata?.title?.provenance?.source);
console.log(" Series:", result.comic.canonicalMetadata?.series?.value);
console.log(" Publisher:", result.comic.canonicalMetadata?.publisher?.value);
return result.comic.id;
}
/**
* Example 2: Import with Multiple Sources
* Import a comic with metadata from ComicInfo.xml, ComicVine, and LOCG
*/
async function example2_multiSourceImport(broker: ServiceBroker) {
console.log("\n=== Example 2: Multi-Source Import ===\n");
const result = await importComicViaGraphQL(broker, {
filePath: "/comics/batman-001.cbz",
rawFileDetails: {
name: "Batman 001",
filePath: "/comics/batman-001.cbz",
fileSize: 15000000,
extension: ".cbz",
pageCount: 32,
},
inferredMetadata: {
issue: {
name: "Batman",
number: 1,
year: "2023",
},
},
sourcedMetadata: {
// From ComicInfo.xml
comicInfo: {
Title: "Batman #1",
Series: "Batman",
Number: "1",
Publisher: "DC Comics",
Summary: "The Dark Knight returns...",
},
// From ComicVine API
comicvine: {
name: "Batman #1: The Court of Owls",
issue_number: "1",
description: "A new era begins for the Dark Knight...",
cover_date: "2023-01-01",
volumeInformation: {
name: "Batman",
publisher: {
name: "DC Comics",
},
},
},
// From League of Comic Geeks
locg: {
name: "Batman #1",
publisher: "DC Comics",
description: "Batman faces a new threat...",
rating: 4.8,
pulls: 15000,
cover: "https://example.com/batman-001-cover.jpg",
},
},
});
console.log("Import Result:", {
success: result.success,
canonicalMetadataResolved: result.canonicalMetadataResolved,
comicId: result.comic.id,
});
console.log("\nCanonical Metadata (resolved from 3 sources):");
console.log(" Title:", result.comic.canonicalMetadata?.title?.value);
console.log(" Source:", result.comic.canonicalMetadata?.title?.provenance?.source);
console.log(" Confidence:", result.comic.canonicalMetadata?.title?.provenance?.confidence);
console.log("\n Description:", result.comic.canonicalMetadata?.description?.value?.substring(0, 50) + "...");
console.log(" Source:", result.comic.canonicalMetadata?.description?.provenance?.source);
return result.comic.id;
}
/**
* Example 3: Update Metadata After Import
* Import a comic, then fetch and add ComicVine metadata
*/
async function example3_updateMetadataAfterImport(broker: ServiceBroker) {
console.log("\n=== Example 3: Update Metadata After Import ===\n");
// Step 1: Import with basic metadata
console.log("Step 1: Initial import with ComicInfo.xml only");
const importResult = await importComicViaGraphQL(broker, {
filePath: "/comics/x-men-001.cbz",
rawFileDetails: {
name: "X-Men 001",
filePath: "/comics/x-men-001.cbz",
fileSize: 10000000,
extension: ".cbz",
},
sourcedMetadata: {
comicInfo: {
Title: "X-Men #1",
Series: "X-Men",
Number: "1",
Publisher: "Marvel Comics",
},
},
});
const comicId = importResult.comic.id;
console.log(" Comic imported:", comicId);
console.log(" Initial title:", importResult.comic.canonicalMetadata?.title?.value);
console.log(" Initial source:", importResult.comic.canonicalMetadata?.title?.provenance?.source);
// Step 2: Fetch and add ComicVine metadata
console.log("\nStep 2: Adding ComicVine metadata");
const comicVineData = {
name: "X-Men #1: Mutant Genesis",
issue_number: "1",
description: "The X-Men are reborn in this landmark issue...",
cover_date: "2023-01-01",
volumeInformation: {
name: "X-Men",
publisher: {
name: "Marvel Comics",
},
},
};
const updatedComic = await updateSourcedMetadataViaGraphQL(
broker,
comicId,
"comicvine",
comicVineData
);
console.log(" Updated title:", updatedComic.canonicalMetadata?.title?.value);
console.log(" Updated source:", updatedComic.canonicalMetadata?.title?.provenance?.source);
console.log(" Description added:", updatedComic.canonicalMetadata?.description?.value?.substring(0, 50) + "...");
return comicId;
}
/**
* Example 4: Analyze Metadata Conflicts
* See how conflicts between sources are resolved
*/
async function example4_analyzeConflicts(broker: ServiceBroker) {
console.log("\n=== Example 4: Analyze Metadata Conflicts ===\n");
// Import with conflicting metadata
const result = await importComicViaGraphQL(broker, {
filePath: "/comics/superman-001.cbz",
rawFileDetails: {
name: "Superman 001",
filePath: "/comics/superman-001.cbz",
fileSize: 14000000,
extension: ".cbz",
},
sourcedMetadata: {
comicInfo: {
Title: "Superman #1",
Series: "Superman",
Publisher: "DC Comics",
},
comicvine: {
name: "Superman #1: Man of Steel",
volumeInformation: {
name: "Superman",
publisher: {
name: "DC Comics",
},
},
},
locg: {
name: "Superman #1 (2023)",
publisher: "DC",
},
},
});
const comicId = result.comic.id;
console.log("Comic imported:", comicId);
// Analyze conflicts
console.log("\nAnalyzing metadata conflicts...");
const conflicts = await analyzeMetadataConflictsViaGraphQL(broker, comicId);
console.log(`\nFound ${conflicts.length} field(s) with conflicts:\n`);
for (const conflict of conflicts) {
console.log(`Field: ${conflict.field}`);
console.log(` Candidates:`);
for (const candidate of conflict.candidates) {
console.log(` - "${candidate.value}" from ${candidate.provenance.source} (confidence: ${candidate.provenance.confidence})`);
}
console.log(` Resolved: "${conflict.resolved.value}" from ${conflict.resolved.provenance.source}`);
console.log(` Reason: ${conflict.resolutionReason}`);
console.log();
}
return comicId;
}
/**
* Example 5: Manual Metadata Resolution
* Manually trigger metadata resolution
*/
async function example5_manualResolution(broker: ServiceBroker) {
console.log("\n=== Example 5: Manual Metadata Resolution ===\n");
// Import without auto-resolution (if disabled)
const result = await importComicViaGraphQL(broker, {
filePath: "/comics/wonder-woman-001.cbz",
rawFileDetails: {
name: "Wonder Woman 001",
filePath: "/comics/wonder-woman-001.cbz",
fileSize: 13000000,
extension: ".cbz",
},
sourcedMetadata: {
comicInfo: {
Title: "Wonder Woman #1",
Series: "Wonder Woman",
},
},
});
const comicId = result.comic.id;
console.log("Comic imported:", comicId);
console.log("Auto-resolved:", result.canonicalMetadataResolved);
// Manually trigger resolution
console.log("\nManually resolving metadata...");
const resolvedComic = await resolveMetadataViaGraphQL(broker, comicId);
console.log("Resolved metadata:");
console.log(" Title:", resolvedComic.canonicalMetadata?.title?.value);
console.log(" Series:", resolvedComic.canonicalMetadata?.series?.value);
return comicId;
}
/**
* Example 6: Get Comic with Full Canonical Metadata
* Retrieve a comic with all its canonical metadata
*/
async function example6_getComicWithMetadata(broker: ServiceBroker, comicId: string) {
console.log("\n=== Example 6: Get Comic with Full Metadata ===\n");
const comic = await getComicViaGraphQL(broker, comicId);
console.log("Comic ID:", comic.id);
console.log("\nCanonical Metadata:");
console.log(" Title:", comic.canonicalMetadata?.title?.value);
console.log(" Source:", comic.canonicalMetadata?.title?.provenance?.source);
console.log(" Confidence:", comic.canonicalMetadata?.title?.provenance?.confidence);
console.log(" Fetched:", comic.canonicalMetadata?.title?.provenance?.fetchedAt);
console.log(" User Override:", comic.canonicalMetadata?.title?.userOverride || false);
console.log("\n Series:", comic.canonicalMetadata?.series?.value);
console.log(" Source:", comic.canonicalMetadata?.series?.provenance?.source);
console.log("\n Publisher:", comic.canonicalMetadata?.publisher?.value);
console.log(" Source:", comic.canonicalMetadata?.publisher?.provenance?.source);
if (comic.canonicalMetadata?.description) {
console.log("\n Description:", comic.canonicalMetadata.description.value?.substring(0, 100) + "...");
console.log(" Source:", comic.canonicalMetadata.description.provenance?.source);
}
if (comic.canonicalMetadata?.creators?.length > 0) {
console.log("\n Creators:");
for (const creator of comic.canonicalMetadata.creators) {
console.log(` - ${creator.name} (${creator.role}) from ${creator.provenance.source}`);
}
}
console.log("\nRaw File Details:");
console.log(" Name:", comic.rawFileDetails?.name);
console.log(" Path:", comic.rawFileDetails?.filePath);
console.log(" Size:", comic.rawFileDetails?.fileSize);
console.log(" Pages:", comic.rawFileDetails?.pageCount);
console.log("\nImport Status:");
console.log(" Imported:", comic.importStatus?.isImported);
console.log(" Tagged:", comic.importStatus?.tagged);
}
/**
* Run all examples
*/
async function runAllExamples(broker: ServiceBroker) {
console.log("╔════════════════════════════════════════════════════════════╗");
console.log("║ Comic Import with GraphQL & Canonical Metadata Examples ║");
console.log("╚════════════════════════════════════════════════════════════╝");
try {
// Example 1: Basic import
const comicId1 = await example1_basicImport(broker);
// Example 2: Multi-source import
const comicId2 = await example2_multiSourceImport(broker);
// Example 3: Update after import
const comicId3 = await example3_updateMetadataAfterImport(broker);
// Example 4: Analyze conflicts
const comicId4 = await example4_analyzeConflicts(broker);
// Example 5: Manual resolution
const comicId5 = await example5_manualResolution(broker);
// Example 6: Get full metadata
await example6_getComicWithMetadata(broker, comicId2);
console.log("\n╔════════════════════════════════════════════════════════════╗");
console.log("║ All examples completed successfully! ║");
console.log("╚════════════════════════════════════════════════════════════╝\n");
} catch (error) {
console.error("\n❌ Error running examples:", error);
throw error;
}
}
/**
* Usage in your service
*/
export {
example1_basicImport,
example2_multiSourceImport,
example3_updateMetadataAfterImport,
example4_analyzeConflicts,
example5_manualResolution,
example6_getComicWithMetadata,
runAllExamples,
};
// If running directly
if (require.main === module) {
console.log("Note: This is an example file. To run these examples:");
console.log("1. Ensure your Moleculer broker is running");
console.log("2. Import and call the example functions from your service");
console.log("3. Or integrate the patterns into your library.service.ts");
}

View File

@@ -18,6 +18,216 @@ export const eSClient = new Client({
},
});
// Metadata source enumeration
export enum MetadataSource {
COMICVINE = "comicvine",
METRON = "metron",
GRAND_COMICS_DATABASE = "gcd",
LOCG = "locg",
COMICINFO_XML = "comicinfo",
MANUAL = "manual",
}
// Provenance schema - tracks where each piece of metadata came from
const ProvenanceSchema = new mongoose.Schema(
{
_id: false,
source: {
type: String,
enum: Object.values(MetadataSource),
required: true,
},
sourceId: String, // External ID from the source (e.g., ComicVine ID)
confidence: { type: Number, min: 0, max: 1, default: 1 }, // 0-1 confidence score
fetchedAt: { type: Date, default: Date.now },
url: String, // Source URL if applicable
},
{ _id: false }
);
// Individual metadata field with provenance
const MetadataFieldSchema = new mongoose.Schema(
{
_id: false,
value: mongoose.Schema.Types.Mixed, // The actual value
provenance: ProvenanceSchema, // Where it came from
userOverride: { type: Boolean, default: false }, // User manually set this
},
{ _id: false }
);
// Creator with provenance
const CreatorSchema = new mongoose.Schema(
{
_id: false,
name: String,
role: String, // writer, artist, colorist, letterer, etc.
id: String, // External ID from source (e.g., Metron creator ID)
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Story Arc with provenance
const StoryArcSchema = new mongoose.Schema(
{
_id: false,
name: String,
number: Number, // Issue's position in the arc
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Universe schema for multiverse/alternate reality tracking
const UniverseSchema = new mongoose.Schema(
{
_id: false,
name: String,
designation: String, // e.g., "Earth-616", "Earth-25"
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Price information with country codes
const PriceSchema = new mongoose.Schema(
{
_id: false,
country: String, // ISO country code (e.g., "US", "GB")
amount: Number,
currency: String, // ISO currency code (e.g., "USD", "GBP")
provenance: ProvenanceSchema,
},
{ _id: false }
);
// External IDs from various sources
const ExternalIDSchema = new mongoose.Schema(
{
_id: false,
source: String, // e.g., "Metron", "Comic Vine", "Grand Comics Database", "MangaDex"
id: String,
primary: { type: Boolean, default: false },
provenance: ProvenanceSchema,
},
{ _id: false }
);
// GTIN (Global Trade Item Number) - includes ISBN, UPC, etc.
const GTINSchema = new mongoose.Schema(
{
_id: false,
isbn: String,
upc: String,
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Reprint information
const ReprintSchema = new mongoose.Schema(
{
_id: false,
description: String, // e.g., "Foo Bar #001 (2002)"
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// URL with primary flag
const URLSchema = new mongoose.Schema(
{
_id: false,
url: String,
primary: { type: Boolean, default: false },
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Canonical metadata - resolved from multiple sources
const CanonicalMetadataSchema = new mongoose.Schema(
{
_id: false,
// Core identifiers
title: MetadataFieldSchema,
series: MetadataFieldSchema,
volume: MetadataFieldSchema,
issueNumber: MetadataFieldSchema,
// External IDs from various sources (Metron, ComicVine, GCD, MangaDex, etc.)
externalIDs: [ExternalIDSchema],
// Publication info
publisher: MetadataFieldSchema,
imprint: MetadataFieldSchema, // Publisher imprint (e.g., Vertigo for DC Comics)
publicationDate: MetadataFieldSchema, // Store/release date
coverDate: MetadataFieldSchema, // Cover date (often different from store date)
// Series information
seriesInfo: {
type: {
_id: false,
id: String, // External series ID
language: String, // ISO language code (e.g., "en", "de")
sortName: String, // Alternative sort name
startYear: Number,
issueCount: Number, // Total issues in series
volumeCount: Number, // Total volumes/collections
alternativeNames: [MetadataFieldSchema], // Alternative series names
provenance: ProvenanceSchema,
},
default: null,
},
// Content
description: MetadataFieldSchema, // Summary/synopsis
notes: MetadataFieldSchema, // Additional notes about the issue
stories: [MetadataFieldSchema], // Story titles within the issue
storyArcs: [StoryArcSchema], // Story arcs with position tracking
characters: [MetadataFieldSchema],
teams: [MetadataFieldSchema],
locations: [MetadataFieldSchema],
universes: [UniverseSchema], // Multiverse/alternate reality information
// Creators
creators: [CreatorSchema],
// Classification
genres: [MetadataFieldSchema],
tags: [MetadataFieldSchema],
ageRating: MetadataFieldSchema,
// Physical/Digital properties
pageCount: MetadataFieldSchema,
format: MetadataFieldSchema, // Single Issue, TPB, HC, etc.
// Commercial information
prices: [PriceSchema], // Prices in different countries/currencies
gtin: GTINSchema, // ISBN, UPC, etc.
// Reprints
reprints: [ReprintSchema], // Information about reprinted content
// URLs
urls: [URLSchema], // External URLs (ComicVine, Metron, etc.)
// Ratings and popularity
communityRating: MetadataFieldSchema,
// Cover image
coverImage: MetadataFieldSchema,
// Metadata tracking
lastModified: MetadataFieldSchema, // Last modification timestamp from source
},
{ _id: false }
);
const RawFileDetailsSchema = mongoose.Schema({
_id: false,
name: String,
@@ -49,6 +259,7 @@ const LOCGSchema = mongoose.Schema({
pulls: Number,
potw: Number,
});
const DirectConnectBundleSchema = mongoose.Schema({
bundleId: Number,
name: String,
@@ -56,6 +267,7 @@ const DirectConnectBundleSchema = mongoose.Schema({
type: {},
_id: false,
});
const wantedSchema = mongoose.Schema(
{
source: { type: String, default: null },
@@ -63,7 +275,7 @@ const wantedSchema = mongoose.Schema(
issues: {
type: [
{
_id: false, // Disable automatic ObjectId creation for each issue
_id: false,
id: Number,
url: String,
image: { type: Array, default: [] },
@@ -75,7 +287,7 @@ const wantedSchema = mongoose.Schema(
},
volume: {
type: {
_id: false, // Disable automatic ObjectId creation for volume
_id: false,
id: Number,
url: String,
image: { type: Array, default: [] },
@@ -85,7 +297,7 @@ const wantedSchema = mongoose.Schema(
},
},
{ _id: false }
); // Disable automatic ObjectId creation for the wanted object itself
);
const ComicSchema = mongoose.Schema(
{
@@ -99,15 +311,27 @@ const ComicSchema = mongoose.Schema(
userAddedMetadata: {
tags: [String],
},
// NEW: Canonical metadata with provenance
canonicalMetadata: {
type: CanonicalMetadataSchema,
es_indexed: true,
default: {},
},
// LEGACY: Keep existing sourced metadata for backward compatibility
sourcedMetadata: {
comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} },
comicvine: { type: mongoose.Schema.Types.Mixed, default: {} }, // Set as a freeform object
comicvine: { type: mongoose.Schema.Types.Mixed, default: {} },
metron: { type: mongoose.Schema.Types.Mixed, default: {} },
gcd: { type: mongoose.Schema.Types.Mixed, default: {} }, // Grand Comics Database
locg: {
type: LOCGSchema,
es_indexed: true,
default: {},
},
},
rawFileDetails: {
type: RawFileDetailsSchema,
es_indexed: true,

813
models/graphql/resolvers.ts Normal file
View File

@@ -0,0 +1,813 @@
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
*/
export const resolvers = {
Query: {
/**
* Get a single comic by ID
*/
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");
}
},
/**
* List comics with pagination and filtering
*/
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
*/
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
*/
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
*/
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");
}
},
},
Mutation: {
/**
* Update user preferences
*/
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)
*/
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
*/
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
*/
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
*/
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
*/
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
*/
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
*/
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}`);
}
},
},
// Field resolvers
Comic: {
id: (comic: any) => comic._id.toString(),
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,
}),
},
UserPreferences: {
id: (prefs: any) => prefs._id.toString(),
fieldPreferences: (prefs: any) => {
if (!prefs.fieldPreferences) return [];
return Array.from(prefs.fieldPreferences.entries()).map(
([field, preferredSource]) => ({
field,
preferredSource,
})
);
},
sourcePriorities: (prefs: any) => {
return prefs.sourcePriorities.map((sp: any) => ({
...sp,
fieldOverrides: sp.fieldOverrides
? Array.from(sp.fieldOverrides.entries()).map(([field, priority]) => ({
field,
priority,
}))
: [],
}));
},
},
};
/**
* Helper: Extract candidates for a field from sourced metadata
*/
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;
}
/**
* Helper: Get nested value from object
*/
function getNestedValue(obj: any, path: string): any {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
/**
* Helper: Convert UserPreferences model to ResolutionPreferences
*/
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,
};
}
/**
* Helper: Get resolution reason for display
*/
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})`;
}
/**
* Helper: Apply preferences input to existing preferences
*/
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;
}

View File

@@ -1,24 +1,442 @@
import { gql } from "graphql-tag";
export const typeDefs = gql`
type Query {
comic(id: ID!): Comic
comics(limit: Int = 10): [Comic]
# Metadata source enumeration
enum MetadataSource {
COMICVINE
METRON
GRAND_COMICS_DATABASE
LOCG
COMICINFO_XML
MANUAL
}
# Conflict resolution strategy
enum ConflictResolutionStrategy {
PRIORITY
CONFIDENCE
RECENCY
MANUAL
HYBRID
}
# Provenance information for metadata
type Provenance {
source: MetadataSource!
sourceId: String
confidence: Float!
fetchedAt: String!
url: String
}
# Metadata field with provenance
type MetadataField {
value: String
provenance: Provenance!
userOverride: Boolean
}
# Array metadata field with provenance
type MetadataArrayField {
values: [String!]!
provenance: Provenance!
userOverride: Boolean
}
# Creator with role and provenance
type Creator {
name: String!
role: String!
provenance: Provenance!
}
# Canonical metadata - resolved from multiple sources
type CanonicalMetadata {
# Core identifiers
title: MetadataField
series: MetadataField
volume: MetadataField
issueNumber: MetadataField
# Publication info
publisher: MetadataField
publicationDate: MetadataField
coverDate: MetadataField
# Content
description: MetadataField
storyArcs: [MetadataField!]
characters: [MetadataField!]
teams: [MetadataField!]
locations: [MetadataField!]
# Creators
creators: [Creator!]
# Classification
genres: [MetadataField!]
tags: [MetadataField!]
ageRating: MetadataField
# Physical/Digital properties
pageCount: MetadataField
format: MetadataField
# Ratings
communityRating: MetadataField
# Cover
coverImage: MetadataField
}
# Raw file details
type RawFileDetails {
name: String
filePath: String
fileSize: Int
extension: String
mimeType: String
containedIn: String
pageCount: Int
archive: Archive
cover: Cover
}
type Archive {
uncompressed: Boolean
expandedPath: String
}
type Cover {
filePath: String
stats: String
}
# Import status
type ImportStatus {
isImported: Boolean
tagged: Boolean
matchedResult: MatchedResult
}
type MatchedResult {
score: String
}
# Main Comic type with canonical metadata
type Comic {
id: ID!
title: String
volume: Int
issueNumber: String
publicationDate: String
coverUrl: String
creators: [Creator]
source: String
# Canonical metadata (resolved from all sources)
canonicalMetadata: CanonicalMetadata
# Raw sourced metadata (for transparency)
sourcedMetadata: SourcedMetadata
# File information
rawFileDetails: RawFileDetails
# Import status
importStatus: ImportStatus
# Timestamps
createdAt: String
updatedAt: String
}
type Creator {
# Sourced metadata (raw data from each source)
type SourcedMetadata {
comicInfo: String # JSON string
comicvine: String # JSON string
metron: String # JSON string
gcd: String # JSON string
locg: LOCGMetadata
}
type LOCGMetadata {
name: String
role: String
publisher: String
url: String
cover: String
description: String
price: String
rating: Float
pulls: Int
potw: Int
}
# Source priority configuration
type SourcePriority {
source: MetadataSource!
priority: Int!
enabled: Boolean!
fieldOverrides: [FieldOverride!]
}
type FieldOverride {
field: String!
priority: Int!
}
# User preferences for metadata resolution
type UserPreferences {
id: ID!
userId: String!
sourcePriorities: [SourcePriority!]!
conflictResolution: ConflictResolutionStrategy!
minConfidenceThreshold: Float!
preferRecent: Boolean!
fieldPreferences: [FieldPreference!]
autoMerge: AutoMergeSettings!
createdAt: String
updatedAt: String
}
type FieldPreference {
field: String!
preferredSource: MetadataSource!
}
type AutoMergeSettings {
enabled: Boolean!
onImport: Boolean!
onMetadataUpdate: Boolean!
}
# Pagination
type ComicConnection {
comics: [Comic!]!
totalCount: Int!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
currentPage: Int!
totalPages: Int!
}
# Metadata conflict information
type MetadataConflict {
field: String!
candidates: [MetadataField!]!
resolved: MetadataField
resolutionReason: String!
}
# Queries
type Query {
# Get a single comic by ID
comic(id: ID!): Comic
# List comics with pagination and filtering
comics(
limit: Int = 10
page: Int = 1
search: String
publisher: String
series: String
): ComicConnection!
# Get user preferences
userPreferences(userId: String = "default"): UserPreferences
# Analyze metadata conflicts for a comic
analyzeMetadataConflicts(comicId: ID!): [MetadataConflict!]!
# Preview canonical metadata resolution without saving
previewCanonicalMetadata(
comicId: ID!
preferences: UserPreferencesInput
): CanonicalMetadata
}
# Mutations
type Mutation {
# Update user preferences
updateUserPreferences(
userId: String = "default"
preferences: UserPreferencesInput!
): UserPreferences!
# Manually set a metadata field (creates user override)
setMetadataField(
comicId: ID!
field: String!
value: String!
): Comic!
# Trigger metadata resolution for a comic
resolveMetadata(comicId: ID!): Comic!
# Bulk resolve metadata for multiple comics
bulkResolveMetadata(comicIds: [ID!]!): [Comic!]!
# Remove user override for a field
removeMetadataOverride(comicId: ID!, field: String!): Comic!
# Refresh metadata from a specific source
refreshMetadataFromSource(
comicId: ID!
source: MetadataSource!
): Comic!
# Import a new comic with automatic metadata resolution
importComic(input: ImportComicInput!): ImportComicResult!
# Update sourced metadata and trigger resolution
updateSourcedMetadata(
comicId: ID!
source: MetadataSource!
metadata: String!
): Comic!
}
# Input types
input UserPreferencesInput {
sourcePriorities: [SourcePriorityInput!]
conflictResolution: ConflictResolutionStrategy
minConfidenceThreshold: Float
preferRecent: Boolean
fieldPreferences: [FieldPreferenceInput!]
autoMerge: AutoMergeSettingsInput
}
input SourcePriorityInput {
source: MetadataSource!
priority: Int!
enabled: Boolean!
fieldOverrides: [FieldOverrideInput!]
}
input FieldOverrideInput {
field: String!
priority: Int!
}
input FieldPreferenceInput {
field: String!
preferredSource: MetadataSource!
}
input AutoMergeSettingsInput {
enabled: Boolean
onImport: Boolean
onMetadataUpdate: Boolean
}
# Import comic input
input ImportComicInput {
filePath: String!
fileSize: Int
sourcedMetadata: SourcedMetadataInput
inferredMetadata: InferredMetadataInput
rawFileDetails: RawFileDetailsInput
wanted: WantedInput
acquisition: AcquisitionInput
}
input SourcedMetadataInput {
comicInfo: String
comicvine: String
metron: String
gcd: String
locg: LOCGMetadataInput
}
input LOCGMetadataInput {
name: String
publisher: String
url: String
cover: String
description: String
price: String
rating: Float
pulls: Int
potw: Int
}
input InferredMetadataInput {
issue: IssueInput
}
input IssueInput {
name: String
number: Int
year: String
subtitle: String
}
input RawFileDetailsInput {
name: String!
filePath: String!
fileSize: Int
extension: String
mimeType: String
containedIn: String
pageCount: Int
archive: ArchiveInput
cover: CoverInput
}
input ArchiveInput {
uncompressed: Boolean
expandedPath: String
}
input CoverInput {
filePath: String
stats: String
}
input WantedInput {
source: String
markEntireVolumeWanted: Boolean
issues: [WantedIssueInput!]
volume: WantedVolumeInput
}
input WantedIssueInput {
id: Int
url: String
image: [String!]
coverDate: String
issueNumber: String
}
input WantedVolumeInput {
id: Int
url: String
image: [String!]
name: String
}
input AcquisitionInput {
source: AcquisitionSourceInput
directconnect: DirectConnectInput
}
input AcquisitionSourceInput {
wanted: Boolean
name: String
}
input DirectConnectInput {
downloads: [DirectConnectBundleInput!]
}
input DirectConnectBundleInput {
bundleId: Int
name: String
size: String
}
# Import result
type ImportComicResult {
success: Boolean!
comic: Comic
message: String
canonicalMetadataResolved: Boolean!
}
`;

View File

@@ -0,0 +1,164 @@
const mongoose = require("mongoose");
import { MetadataSource } from "./comic.model";
// Source priority configuration
const SourcePrioritySchema = new mongoose.Schema(
{
_id: false,
source: {
type: String,
enum: Object.values(MetadataSource),
required: true,
},
priority: {
type: Number,
required: true,
min: 1,
}, // Lower number = higher priority (1 is highest)
enabled: {
type: Boolean,
default: true,
},
// Field-specific overrides
fieldOverrides: {
type: Map,
of: Number, // field name -> priority for that specific field
default: new Map(),
},
},
{ _id: false }
);
// Conflict resolution strategy
export enum ConflictResolutionStrategy {
PRIORITY = "priority", // Use source priority
CONFIDENCE = "confidence", // Use confidence score
RECENCY = "recency", // Use most recently fetched
MANUAL = "manual", // Always prefer manual entries
HYBRID = "hybrid", // Combine priority and confidence
}
// User preferences for metadata resolution
const UserPreferencesSchema = new mongoose.Schema(
{
userId: {
type: String,
required: true,
unique: true,
default: "default",
}, // Support for multi-user in future
// Source priority configuration
sourcePriorities: {
type: [SourcePrioritySchema],
default: [
{
source: MetadataSource.MANUAL,
priority: 1,
enabled: true,
},
{
source: MetadataSource.COMICVINE,
priority: 2,
enabled: true,
},
{
source: MetadataSource.METRON,
priority: 3,
enabled: true,
},
{
source: MetadataSource.GRAND_COMICS_DATABASE,
priority: 4,
enabled: true,
},
{
source: MetadataSource.LOCG,
priority: 5,
enabled: true,
},
{
source: MetadataSource.COMICINFO_XML,
priority: 6,
enabled: true,
},
],
},
// Global conflict resolution strategy
conflictResolution: {
type: String,
enum: Object.values(ConflictResolutionStrategy),
default: ConflictResolutionStrategy.HYBRID,
},
// Minimum confidence threshold (0-1)
minConfidenceThreshold: {
type: Number,
min: 0,
max: 1,
default: 0.5,
},
// Prefer newer data when confidence/priority are equal
preferRecent: {
type: Boolean,
default: true,
},
// Field-specific preferences
fieldPreferences: {
// Always prefer certain sources for specific fields
// e.g., { "description": "comicvine", "coverImage": "locg" }
type: Map,
of: String,
default: new Map(),
},
// Auto-merge settings
autoMerge: {
enabled: { type: Boolean, default: true },
onImport: { type: Boolean, default: true },
onMetadataUpdate: { type: Boolean, default: true },
},
},
{ timestamps: true }
);
// Helper method to get priority for a source
UserPreferencesSchema.methods.getSourcePriority = function (
source: MetadataSource,
field?: string
): number {
const sourcePriority = this.sourcePriorities.find(
(sp: any) => sp.source === source && sp.enabled
);
if (!sourcePriority) {
return Infinity; // Disabled or not configured
}
// Check for field-specific override
if (field && sourcePriority.fieldOverrides.has(field)) {
return sourcePriority.fieldOverrides.get(field);
}
return sourcePriority.priority;
};
// Helper method to check if source is enabled
UserPreferencesSchema.methods.isSourceEnabled = function (
source: MetadataSource
): boolean {
const sourcePriority = this.sourcePriorities.find(
(sp: any) => sp.source === source
);
return sourcePriority ? sourcePriority.enabled : false;
};
const UserPreferences = mongoose.model(
"UserPreferences",
UserPreferencesSchema
);
export default UserPreferences;

View File

@@ -55,12 +55,120 @@ export default class ApiService extends Service {
mappingPolicy: "all",
logging: true,
},
{
path: "/graphql",
cors: {
origin: "*",
methods: ["GET", "OPTIONS", "POST"],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
aliases: {
"POST /": async (req: any, res: any) => {
try {
const { query, variables, operationName } = req.body;
const result = await req.$service.broker.call("graphql.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
errors: [{ message: error.message }],
})
);
}
},
"GET /": async (req: any, res: any) => {
// Support GraphQL Playground or introspection queries via GET
const query = req.$params.query;
const variables = req.$params.variables
? JSON.parse(req.$params.variables)
: undefined;
const operationName = req.$params.operationName;
if (query) {
try {
const result = await req.$service.broker.call("graphql.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
errors: [{ message: error.message }],
})
);
}
} else {
// Return GraphQL Playground HTML
res.setHeader("Content-Type", "text/html");
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>GraphQL Playground</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
<link rel="shortcut icon" href="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png" />
<script src="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
</head>
<body>
<div id="root"></div>
<script>
window.addEventListener('load', function (event) {
GraphQLPlayground.init(document.getElementById('root'), {
endpoint: '/graphql',
settings: {
'request.credentials': 'same-origin',
},
})
})
</script>
</body>
</html>
`);
}
},
},
mappingPolicy: "restrict",
bodyParsers: {
json: { strict: false, limit: "1MB" },
},
},
{
path: "/userdata",
cors: {
origin: "*",
methods: ["GET", "OPTIONS"],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
use: [ApiGateway.serveStatic(path.resolve("./userdata"))],
},
{
path: "/comics",
cors: {
origin: "*",
methods: ["GET", "OPTIONS"],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
use: [ApiGateway.serveStatic(path.resolve("./comics"))],
},
{

213
services/graphql.service.ts Normal file
View File

@@ -0,0 +1,213 @@
import { Service, ServiceBroker } from "moleculer";
import { ApolloServer } from "@apollo/server";
import { typeDefs } from "../models/graphql/typedef";
import { resolvers } from "../models/graphql/resolvers";
/**
* GraphQL Service
* Provides a GraphQL API for canonical metadata queries and mutations
* Integrates Apollo Server with Moleculer
*/
export default class GraphQLService extends Service {
private apolloServer?: ApolloServer;
public constructor(broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "graphql",
settings: {
// GraphQL endpoint path
path: "/graphql",
},
actions: {
/**
* Execute a GraphQL query
*/
query: {
params: {
query: "string",
variables: { type: "object", optional: true },
operationName: { type: "string", optional: true },
},
async handler(ctx: any) {
try {
if (!this.apolloServer) {
throw new Error("Apollo Server not initialized");
}
const { query, variables, operationName } = ctx.params;
const response = await this.apolloServer.executeOperation(
{
query,
variables,
operationName,
},
{
contextValue: {
broker: this.broker,
ctx,
},
}
);
if (response.body.kind === "single") {
return response.body.singleResult;
}
return response;
} catch (error) {
this.logger.error("GraphQL query error:", error);
throw error;
}
},
},
/**
* Get GraphQL schema
*/
getSchema: {
async handler() {
return {
typeDefs: typeDefs.loc?.source.body || "",
};
},
},
},
methods: {
/**
* Initialize Apollo Server
*/
async initApolloServer() {
this.logger.info("Initializing Apollo Server...");
this.apolloServer = new ApolloServer({
typeDefs,
resolvers,
introspection: true, // Enable GraphQL Playground in development
formatError: (error) => {
this.logger.error("GraphQL Error:", error);
return {
message: error.message,
locations: error.locations,
path: error.path,
extensions: {
code: error.extensions?.code,
},
};
},
});
await this.apolloServer.start();
this.logger.info("Apollo Server started successfully");
},
/**
* Stop Apollo Server
*/
async stopApolloServer() {
if (this.apolloServer) {
this.logger.info("Stopping Apollo Server...");
await this.apolloServer.stop();
this.apolloServer = undefined;
this.logger.info("Apollo Server stopped");
}
},
},
events: {
/**
* Trigger metadata resolution when new metadata is imported
*/
"metadata.imported": {
async handler(ctx: any) {
const { comicId, source } = ctx.params;
this.logger.info(
`Metadata imported for comic ${comicId} from ${source}`
);
// Optionally trigger auto-resolution if enabled
try {
const UserPreferences = require("../models/userpreferences.model").default;
const preferences = await UserPreferences.findOne({
userId: "default",
});
if (
preferences?.autoMerge?.enabled &&
preferences?.autoMerge?.onMetadataUpdate
) {
this.logger.info(
`Auto-resolving metadata for comic ${comicId}`
);
await this.broker.call("graphql.query", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId },
});
}
} catch (error) {
this.logger.error("Error in auto-resolution:", error);
}
},
},
/**
* Trigger metadata resolution when comic is imported
*/
"comic.imported": {
async handler(ctx: any) {
const { comicId } = ctx.params;
this.logger.info(`Comic imported: ${comicId}`);
// Optionally trigger auto-resolution if enabled
try {
const UserPreferences = require("../models/userpreferences.model").default;
const preferences = await UserPreferences.findOne({
userId: "default",
});
if (
preferences?.autoMerge?.enabled &&
preferences?.autoMerge?.onImport
) {
this.logger.info(
`Auto-resolving metadata for newly imported comic ${comicId}`
);
await this.broker.call("graphql.query", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId },
});
}
} catch (error) {
this.logger.error("Error in auto-resolution on import:", error);
}
},
},
},
started: async function (this: any) {
await this.initApolloServer();
},
stopped: async function (this: any) {
await this.stopApolloServer();
},
});
}
}

View File

@@ -58,6 +58,7 @@ import klaw from "klaw";
import path from "path";
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
import AirDCPPSocket from "../shared/airdcpp.socket";
import { importComicViaGraphQL } from "../utils/import.graphql.utils";
console.log(`MONGO -> ${process.env.MONGO_URI}`);
export default class ImportService extends Service {
@@ -256,61 +257,119 @@ export default class ImportService extends Service {
sourcedMetadata: {
comicvine?: any;
locg?: {};
comicInfo?: any;
metron?: any;
gcd?: any;
};
inferredMetadata: {
issue: Object;
};
rawFileDetails: {
name: string;
filePath: string;
fileSize?: number;
extension?: string;
mimeType?: string;
containedIn?: string;
cover?: any;
};
wanted: {
wanted?: {
issues: [];
volume: { id: number };
source: string;
markEntireVolumeWanted: Boolean;
};
acquisition: {
directconnect: {
acquisition?: {
source?: {
wanted?: boolean;
name?: string;
};
directconnect?: {
downloads: [];
};
};
importStatus?: {
isImported: boolean;
tagged: boolean;
matchedResult?: {
score: string;
};
};
};
}>
) {
try {
console.log(
"[GraphQL Import] Processing import via GraphQL..."
);
console.log(
JSON.stringify(ctx.params.payload, null, 4)
);
const { payload } = ctx.params;
const { wanted } = payload;
console.log("Saving to Mongo...");
// Use GraphQL import for new comics
if (
!wanted ||
!wanted.volume ||
!wanted.volume.id
) {
console.log(
"No valid identifier for upsert. Attempting to create a new document with minimal data..."
"[GraphQL Import] No valid identifier - creating new comic via GraphQL"
);
// Import via GraphQL
const result = await importComicViaGraphQL(
this.broker,
{
filePath: payload.rawFileDetails.filePath,
fileSize: payload.rawFileDetails.fileSize,
rawFileDetails: payload.rawFileDetails,
inferredMetadata: payload.inferredMetadata,
sourcedMetadata: payload.sourcedMetadata,
wanted: payload.wanted ? {
...payload.wanted,
markEntireVolumeWanted: Boolean(payload.wanted.markEntireVolumeWanted)
} : undefined,
acquisition: payload.acquisition,
}
);
if (result.success) {
console.log(
`[GraphQL Import] Comic imported successfully: ${result.comic.id}`
);
console.log(
`[GraphQL Import] Canonical metadata resolved: ${result.canonicalMetadataResolved}`
);
const newDocument = new Comic(payload); // Using the entire payload for the new document
await newDocument.save();
return {
success: true,
message:
"New document created due to lack of valid identifiers.",
data: newDocument,
message: result.message,
data: result.comic,
};
} else {
console.log(
`[GraphQL Import] Import returned success=false: ${result.message}`
);
return {
success: false,
message: result.message,
data: result.comic,
};
}
}
// For comics with wanted.volume.id, use upsert logic
console.log(
"[GraphQL Import] Comic has wanted.volume.id - using upsert logic"
);
let condition = {
"wanted.volume.id": wanted.volume.id,
};
let update: any = {
// Using 'any' to bypass strict type checks; alternatively, define a more accurate type
$set: {
rawFileDetails: payload.rawFileDetails,
inferredMetadata: payload.inferredMetadata,
@@ -340,18 +399,45 @@ export default class ImportService extends Service {
update,
options
);
console.log(
"Operation completed. Document updated or inserted:",
result
"[GraphQL Import] Document upserted:",
result._id
);
// Trigger canonical metadata resolution via GraphQL
try {
console.log(
"[GraphQL Import] Triggering metadata resolution..."
);
await this.broker.call("graphql.query", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId: result._id.toString() },
});
console.log(
"[GraphQL Import] Metadata resolution triggered"
);
} catch (resolveError) {
console.error(
"[GraphQL Import] Error resolving metadata:",
resolveError
);
// Don't fail the import if resolution fails
}
return {
success: true,
message: "Document successfully upserted.",
data: result,
};
} catch (error) {
console.log(error);
console.error("[GraphQL Import] Error:", error);
throw new Errors.MoleculerError(
"Operation failed.",
500

View File

@@ -0,0 +1,391 @@
/**
* GraphQL Import Utilities
* Helper functions for importing comics using GraphQL mutations
*/
import { ServiceBroker } from "moleculer";
/**
* Import a comic using GraphQL mutation
*/
export async function importComicViaGraphQL(
broker: ServiceBroker,
importData: {
filePath: string;
fileSize?: number;
sourcedMetadata?: {
comicInfo?: any;
comicvine?: any;
metron?: any;
gcd?: any;
locg?: any;
};
inferredMetadata?: {
issue: {
name?: string;
number?: number;
year?: string;
subtitle?: string;
};
};
rawFileDetails: {
name: string;
filePath: string;
fileSize?: number;
extension?: string;
mimeType?: string;
containedIn?: string;
pageCount?: number;
};
wanted?: {
source?: string;
markEntireVolumeWanted?: boolean;
issues?: any[];
volume?: any;
};
acquisition?: {
source?: {
wanted?: boolean;
name?: string;
};
directconnect?: {
downloads?: any[];
};
};
}
): Promise<{
success: boolean;
comic: any;
message: string;
canonicalMetadataResolved: boolean;
}> {
const mutation = `
mutation ImportComic($input: ImportComicInput!) {
importComic(input: $input) {
success
message
canonicalMetadataResolved
comic {
id
canonicalMetadata {
title { value, provenance { source, confidence } }
series { value, provenance { source } }
issueNumber { value, provenance { source } }
publisher { value, provenance { source } }
description { value, provenance { source } }
}
rawFileDetails {
name
filePath
fileSize
}
}
}
}
`;
// Prepare input
const input: any = {
filePath: importData.filePath,
rawFileDetails: importData.rawFileDetails,
};
if (importData.fileSize) {
input.fileSize = importData.fileSize;
}
if (importData.inferredMetadata) {
input.inferredMetadata = importData.inferredMetadata;
}
if (importData.sourcedMetadata) {
input.sourcedMetadata = {};
if (importData.sourcedMetadata.comicInfo) {
input.sourcedMetadata.comicInfo = JSON.stringify(
importData.sourcedMetadata.comicInfo
);
}
if (importData.sourcedMetadata.comicvine) {
input.sourcedMetadata.comicvine = JSON.stringify(
importData.sourcedMetadata.comicvine
);
}
if (importData.sourcedMetadata.metron) {
input.sourcedMetadata.metron = JSON.stringify(
importData.sourcedMetadata.metron
);
}
if (importData.sourcedMetadata.gcd) {
input.sourcedMetadata.gcd = JSON.stringify(
importData.sourcedMetadata.gcd
);
}
if (importData.sourcedMetadata.locg) {
input.sourcedMetadata.locg = importData.sourcedMetadata.locg;
}
}
if (importData.wanted) {
input.wanted = importData.wanted;
}
if (importData.acquisition) {
input.acquisition = importData.acquisition;
}
try {
const result: any = await broker.call("graphql.query", {
query: mutation,
variables: { input },
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.importComic;
} catch (error) {
console.error("Error importing comic via GraphQL:", error);
throw error;
}
}
/**
* Update sourced metadata for a comic using GraphQL
*/
export async function updateSourcedMetadataViaGraphQL(
broker: ServiceBroker,
comicId: string,
source: string,
metadata: any
): Promise<any> {
const mutation = `
mutation UpdateSourcedMetadata(
$comicId: ID!
$source: MetadataSource!
$metadata: String!
) {
updateSourcedMetadata(
comicId: $comicId
source: $source
metadata: $metadata
) {
id
canonicalMetadata {
title { value, provenance { source } }
series { value, provenance { source } }
publisher { value, provenance { source } }
}
}
}
`;
try {
const result: any = await broker.call("graphql.query", {
query: mutation,
variables: {
comicId,
source: source.toUpperCase(),
metadata: JSON.stringify(metadata),
},
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.updateSourcedMetadata;
} catch (error) {
console.error("Error updating sourced metadata via GraphQL:", error);
throw error;
}
}
/**
* Resolve canonical metadata for a comic using GraphQL
*/
export async function resolveMetadataViaGraphQL(
broker: ServiceBroker,
comicId: string
): Promise<any> {
const mutation = `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
canonicalMetadata {
title { value, provenance { source, confidence } }
series { value, provenance { source, confidence } }
issueNumber { value, provenance { source } }
publisher { value, provenance { source } }
description { value, provenance { source } }
coverDate { value, provenance { source } }
pageCount { value, provenance { source } }
}
}
}
`;
try {
const result: any = await broker.call("graphql.query", {
query: mutation,
variables: { comicId },
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.resolveMetadata;
} catch (error) {
console.error("Error resolving metadata via GraphQL:", error);
throw error;
}
}
/**
* Get comic with canonical metadata using GraphQL
*/
export async function getComicViaGraphQL(
broker: ServiceBroker,
comicId: string
): Promise<any> {
const query = `
query GetComic($id: ID!) {
comic(id: $id) {
id
canonicalMetadata {
title { value, provenance { source, confidence, fetchedAt } }
series { value, provenance { source, confidence } }
issueNumber { value, provenance { source } }
publisher { value, provenance { source } }
description { value, provenance { source } }
coverDate { value, provenance { source } }
pageCount { value, provenance { source } }
creators {
name
role
provenance { source, confidence }
}
}
rawFileDetails {
name
filePath
fileSize
extension
pageCount
}
importStatus {
isImported
tagged
}
}
}
`;
try {
const result: any = await broker.call("graphql.query", {
query,
variables: { id: comicId },
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.comic;
} catch (error) {
console.error("Error getting comic via GraphQL:", error);
throw error;
}
}
/**
* Analyze metadata conflicts for a comic
*/
export async function analyzeMetadataConflictsViaGraphQL(
broker: ServiceBroker,
comicId: string
): Promise<any[]> {
const query = `
query AnalyzeConflicts($comicId: ID!) {
analyzeMetadataConflicts(comicId: $comicId) {
field
candidates {
value
provenance {
source
confidence
fetchedAt
}
}
resolved {
value
provenance {
source
confidence
}
}
resolutionReason
}
}
`;
try {
const result: any = await broker.call("graphql.query", {
query,
variables: { comicId },
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.analyzeMetadataConflicts;
} catch (error) {
console.error("Error analyzing conflicts via GraphQL:", error);
throw error;
}
}
/**
* Bulk resolve metadata for multiple comics
*/
export async function bulkResolveMetadataViaGraphQL(
broker: ServiceBroker,
comicIds: string[]
): Promise<any[]> {
const mutation = `
mutation BulkResolve($comicIds: [ID!]!) {
bulkResolveMetadata(comicIds: $comicIds) {
id
canonicalMetadata {
title { value }
series { value }
}
}
}
`;
try {
const result: any = await broker.call("graphql.query", {
query: mutation,
variables: { comicIds },
});
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data.bulkResolveMetadata;
} catch (error) {
console.error("Error bulk resolving metadata via GraphQL:", error);
throw error;
}
}

View File

@@ -0,0 +1,436 @@
import { MetadataSource } from "../models/comic.model";
import { ConflictResolutionStrategy } from "../models/userpreferences.model";
/**
* Metadata field with provenance information
*/
export interface MetadataField {
value: any;
provenance: {
source: MetadataSource;
sourceId?: string;
confidence: number;
fetchedAt: Date;
url?: string;
};
userOverride?: boolean;
}
/**
* User preferences for metadata resolution
*/
export interface ResolutionPreferences {
sourcePriorities: Array<{
source: MetadataSource;
priority: number;
enabled: boolean;
fieldOverrides?: Map<string, number>;
}>;
conflictResolution: ConflictResolutionStrategy;
minConfidenceThreshold: number;
preferRecent: boolean;
fieldPreferences?: Map<string, MetadataSource>;
}
/**
* Resolve a single metadata field from multiple sources
*/
export function resolveMetadataField(
fieldName: string,
candidates: MetadataField[],
preferences: ResolutionPreferences
): MetadataField | null {
// Filter out invalid candidates
const validCandidates = candidates.filter(
(c) =>
c &&
c.value !== null &&
c.value !== undefined &&
c.provenance &&
c.provenance.confidence >= preferences.minConfidenceThreshold
);
if (validCandidates.length === 0) {
return null;
}
// Always prefer user overrides
const userOverride = validCandidates.find((c) => c.userOverride);
if (userOverride) {
return userOverride;
}
// Check for field-specific preference
if (preferences.fieldPreferences?.has(fieldName)) {
const preferredSource = preferences.fieldPreferences.get(fieldName);
const preferred = validCandidates.find(
(c) => c.provenance.source === preferredSource
);
if (preferred) {
return preferred;
}
}
// Apply resolution strategy
switch (preferences.conflictResolution) {
case ConflictResolutionStrategy.PRIORITY:
return resolveByPriority(fieldName, validCandidates, preferences);
case ConflictResolutionStrategy.CONFIDENCE:
return resolveByConfidence(validCandidates, preferences);
case ConflictResolutionStrategy.RECENCY:
return resolveByRecency(validCandidates);
case ConflictResolutionStrategy.MANUAL:
// Already handled user overrides above
return resolveByPriority(fieldName, validCandidates, preferences);
case ConflictResolutionStrategy.HYBRID:
default:
return resolveHybrid(fieldName, validCandidates, preferences);
}
}
/**
* Resolve by source priority
*/
function resolveByPriority(
fieldName: string,
candidates: MetadataField[],
preferences: ResolutionPreferences
): MetadataField {
const sorted = [...candidates].sort((a, b) => {
const priorityA = getSourcePriority(
a.provenance.source,
fieldName,
preferences
);
const priorityB = getSourcePriority(
b.provenance.source,
fieldName,
preferences
);
return priorityA - priorityB;
});
return sorted[0];
}
/**
* Resolve by confidence score
*/
function resolveByConfidence(
candidates: MetadataField[],
preferences: ResolutionPreferences
): MetadataField {
const sorted = [...candidates].sort((a, b) => {
const diff = b.provenance.confidence - a.provenance.confidence;
// If confidence is equal and preferRecent is true, use recency
if (diff === 0 && preferences.preferRecent) {
return (
b.provenance.fetchedAt.getTime() - a.provenance.fetchedAt.getTime()
);
}
return diff;
});
return sorted[0];
}
/**
* Resolve by recency (most recently fetched)
*/
function resolveByRecency(candidates: MetadataField[]): MetadataField {
const sorted = [...candidates].sort(
(a, b) =>
b.provenance.fetchedAt.getTime() - a.provenance.fetchedAt.getTime()
);
return sorted[0];
}
/**
* Hybrid resolution: combines priority and confidence
*/
function resolveHybrid(
fieldName: string,
candidates: MetadataField[],
preferences: ResolutionPreferences
): MetadataField {
// Calculate a weighted score for each candidate
const scored = candidates.map((candidate) => {
const priority = getSourcePriority(
candidate.provenance.source,
fieldName,
preferences
);
const confidence = candidate.provenance.confidence;
// Normalize priority (lower is better, so invert)
const maxPriority = Math.max(
...preferences.sourcePriorities.map((sp) => sp.priority)
);
const normalizedPriority = 1 - (priority - 1) / maxPriority;
// Weighted score: 60% priority, 40% confidence
const score = normalizedPriority * 0.6 + confidence * 0.4;
// Add recency bonus if enabled
let recencyBonus = 0;
if (preferences.preferRecent) {
const now = Date.now();
const age = now - candidate.provenance.fetchedAt.getTime();
const maxAge = 365 * 24 * 60 * 60 * 1000; // 1 year in ms
recencyBonus = Math.max(0, 1 - age / maxAge) * 0.1; // Up to 10% bonus
}
return {
candidate,
score: score + recencyBonus,
};
});
// Sort by score (highest first)
scored.sort((a, b) => b.score - a.score);
return scored[0].candidate;
}
/**
* Get priority for a source, considering field-specific overrides
*/
function getSourcePriority(
source: MetadataSource,
fieldName: string,
preferences: ResolutionPreferences
): number {
const sourcePriority = preferences.sourcePriorities.find(
(sp) => sp.source === source && sp.enabled
);
if (!sourcePriority) {
return Infinity; // Disabled or not configured
}
// Check for field-specific override
if (sourcePriority.fieldOverrides?.has(fieldName)) {
return sourcePriority.fieldOverrides.get(fieldName)!;
}
return sourcePriority.priority;
}
/**
* Merge array fields (e.g., creators, tags) from multiple sources
*/
export function mergeArrayField(
fieldName: string,
sources: Array<{ source: MetadataSource; values: any[]; confidence: number }>,
preferences: ResolutionPreferences
): any[] {
const allValues: any[] = [];
const seen = new Set<string>();
// Sort sources by priority
const sortedSources = [...sources].sort((a, b) => {
const priorityA = getSourcePriority(a.source, fieldName, preferences);
const priorityB = getSourcePriority(b.source, fieldName, preferences);
return priorityA - priorityB;
});
// Merge values, avoiding duplicates
for (const source of sortedSources) {
for (const value of source.values) {
const key =
typeof value === "string"
? value.toLowerCase()
: JSON.stringify(value);
if (!seen.has(key)) {
seen.add(key);
allValues.push(value);
}
}
}
return allValues;
}
/**
* Build canonical metadata from multiple sources
*/
export function buildCanonicalMetadata(
sourcedMetadata: {
comicInfo?: any;
comicvine?: any;
metron?: any;
gcd?: any;
locg?: any;
},
preferences: ResolutionPreferences
): any {
const canonical: any = {};
// Define field mappings from each source
const fieldMappings = {
title: [
{
source: MetadataSource.COMICVINE,
path: "name",
data: sourcedMetadata.comicvine,
},
{
source: MetadataSource.METRON,
path: "name",
data: sourcedMetadata.metron,
},
{
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,
},
],
coverDate: [
{
source: MetadataSource.COMICVINE,
path: "cover_date",
data: sourcedMetadata.comicvine,
},
{
source: MetadataSource.COMICINFO_XML,
path: "CoverDate",
data: sourcedMetadata.comicInfo,
},
],
pageCount: [
{
source: MetadataSource.COMICINFO_XML,
path: "PageCount",
data: sourcedMetadata.comicInfo,
},
],
};
// Resolve each field
for (const [fieldName, mappings] of Object.entries(fieldMappings)) {
const candidates: MetadataField[] = [];
for (const mapping of mappings) {
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, // Default confidence
fetchedAt: new Date(),
},
});
}
}
if (candidates.length > 0) {
const resolved = resolveMetadataField(fieldName, candidates, preferences);
if (resolved) {
canonical[fieldName] = resolved;
}
}
}
return canonical;
}
/**
* Get nested value from object using dot notation path
*/
function getNestedValue(obj: any, path: string): any {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
/**
* Compare two metadata values for equality
*/
export function metadataValuesEqual(a: any, b: any): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, idx) => metadataValuesEqual(val, b[idx]));
}
if (typeof a === "object" && a !== null && b !== null) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => metadataValuesEqual(a[key], b[key]));
}
return false;
}