Compare commits
41 Commits
ux-refinem
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664da47ea2 | ||
|
|
c4cf233053 | ||
|
|
83f905ebb6 | ||
|
|
735f0dbb38 | ||
| a70d469c36 | |||
| bf7e57a274 | |||
| bfaf7bb664 | |||
| e54063c1a4 | |||
| 7f86497cfc | |||
| c9f323e610 | |||
| e5e4e82f11 | |||
| 42c427c7ea | |||
| c7d3d46bcf | |||
| 8138e0fe4f | |||
| 71267ecc7e | |||
| cc30dcc14f | |||
| a2f9be71ed | |||
| 965565f7b8 | |||
| 17f80682e1 | |||
| 8a8acc656a | |||
| 22cbdcd468 | |||
| 8c224bad68 | |||
| a1fa12f181 | |||
| f7804ee3f0 | |||
| cd446a9ca3 | |||
| 136a7f494f | |||
| b332d9d75a | |||
| a0671ce6d1 | |||
| 999af29800 | |||
| 7313fc4df7 | |||
| 8b8f470f52 | |||
| a2eae27c31 | |||
| 58168b1a9c | |||
| bd62866340 | |||
| 77d21d3046 | |||
| 030f89b258 | |||
| a702f724f7 | |||
| d0b4219aef | |||
| 09d7fa2772 | |||
| b0c56f65c4 | |||
| 10ff192ce1 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
comics/*
|
comics/*
|
||||||
userdata/*
|
userdata/*
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
logs/*
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
.git
|
.git
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,3 +72,7 @@ erl_crash.dump
|
|||||||
temp
|
temp
|
||||||
test
|
test
|
||||||
.nova
|
.nova
|
||||||
|
CANONICAL_METADATA.md
|
||||||
|
GRAPHQL_LEVERAGE_GUIDE.md
|
||||||
|
IMPORT_WITH_GRAPHQL.md
|
||||||
|
JOBQUEUE_GRAPHQL_INTEGRATION.md
|
||||||
|
|||||||
97
Dockerfile
97
Dockerfile
@@ -1,39 +1,50 @@
|
|||||||
# Use a base image with Node.js 22.1.0
|
# Use a non-ARM image (x86_64) for Node.js
|
||||||
FROM node:22.1.0
|
FROM --platform=linux/amd64 node:21-alpine3.18 AS builder
|
||||||
|
|
||||||
# Set metadata for contact
|
# Set metadata for contact
|
||||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV NPM_CONFIG_LOGLEVEL warn
|
ENV NPM_CONFIG_LOGLEVEL=warn
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /core-services
|
WORKDIR /core-services
|
||||||
|
|
||||||
# Install required packages
|
# Install required dependencies using apk
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apk update && apk add --no-cache \
|
||||||
libvips-tools \
|
bash \
|
||||||
wget \
|
wget \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
python3 \
|
python3 \
|
||||||
xvfb \
|
xvfb \
|
||||||
xz-utils \
|
build-base \
|
||||||
curl \
|
g++ \
|
||||||
bash \
|
python3-dev \
|
||||||
software-properties-common
|
p7zip \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
glib \
|
||||||
|
cairo-dev \
|
||||||
|
pango-dev \
|
||||||
|
icu-dev \
|
||||||
|
pkgconfig
|
||||||
|
|
||||||
# Install p7zip
|
# Install libvips from source
|
||||||
RUN apt-get update && apt-get install -y p7zip
|
RUN wget https://github.com/libvips/libvips/releases/download/v8.13.0/vips-8.13.0.tar.gz \
|
||||||
|
&& tar -zxvf vips-8.13.0.tar.gz \
|
||||||
|
&& cd vips-8.13.0 \
|
||||||
|
&& ./configure --disable-python \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -rf vips-8.13.0.tar.gz vips-8.13.0
|
||||||
|
|
||||||
# Install unrar directly from RARLAB
|
# Install unrar directly from RARLAB
|
||||||
RUN wget https://www.rarlab.com/rar/rarlinux-x64-621.tar.gz \
|
RUN wget https://www.rarlab.com/rar/rarlinux-x64-621.tar.gz \
|
||||||
&& tar -zxvf rarlinux-x64-621.tar.gz \
|
&& tar -zxvf rarlinux-x64-621.tar.gz \
|
||||||
&& cp rar/unrar /usr/bin/ \
|
&& cp rar/unrar /usr/bin/ \
|
||||||
&& rm -rf rarlinux-x64-621.tar.gz rar
|
&& rm -rf rarlinux-x64-621.tar.gz rar
|
||||||
|
|
||||||
# Clean up package lists
|
|
||||||
RUN rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Verify Node.js installation
|
# Verify Node.js installation
|
||||||
RUN node -v && npm -v
|
RUN node -v && npm -v
|
||||||
@@ -45,17 +56,49 @@ COPY tsconfig.json ./
|
|||||||
|
|
||||||
# Install application dependencies
|
# Install application dependencies
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
# Install sharp with proper platform configuration
|
||||||
|
RUN npm install --force sharp --platform=linux/amd64
|
||||||
|
|
||||||
|
# Install global dependencies
|
||||||
RUN npm install -g typescript ts-node
|
RUN npm install -g typescript ts-node
|
||||||
|
|
||||||
# Copy the rest of the application files
|
# Copy the rest of the application files (e.g., source code)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build and clean up
|
# Build the app
|
||||||
RUN npm run build \
|
RUN npm run build
|
||||||
&& npm prune
|
|
||||||
|
# Final image
|
||||||
|
FROM --platform=linux/amd64 node:21-alpine3.18
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /core-services
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk update && apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
wget \
|
||||||
|
imagemagick \
|
||||||
|
python3 \
|
||||||
|
xvfb \
|
||||||
|
p7zip \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
glib \
|
||||||
|
cairo-dev \
|
||||||
|
pango-dev \
|
||||||
|
icu-dev \
|
||||||
|
pkgconfig
|
||||||
|
|
||||||
|
# Copy necessary files from the builder image
|
||||||
|
COPY --from=builder /core-services /core-services
|
||||||
|
|
||||||
# Expose the application's port
|
# Expose the application's port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Command to run the application
|
# Command to run the application (this will now work)
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
176
config/graphql.config.ts
Normal file
176
config/graphql.config.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL service configuration module
|
||||||
|
* @module config/graphql.config
|
||||||
|
* @description Provides configuration interfaces and defaults for the GraphQL service,
|
||||||
|
* including remote schema settings, execution parameters, validation rules, logging options,
|
||||||
|
* and health check configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL service configuration interface
|
||||||
|
* @interface GraphQLConfig
|
||||||
|
* @description Complete configuration object for the GraphQL service with all subsections
|
||||||
|
*/
|
||||||
|
export interface GraphQLConfig {
|
||||||
|
/**
|
||||||
|
* Remote schema configuration
|
||||||
|
* @property {boolean} enabled - Whether remote schema stitching is enabled
|
||||||
|
* @property {string} url - URL of the remote GraphQL endpoint
|
||||||
|
* @property {number} timeout - Request timeout in milliseconds
|
||||||
|
* @property {number} retries - Number of retry attempts for failed requests
|
||||||
|
* @property {number} retryDelay - Delay between retries in milliseconds
|
||||||
|
* @property {boolean} cacheEnabled - Whether to cache the remote schema
|
||||||
|
* @property {number} cacheTTL - Cache time-to-live in seconds
|
||||||
|
*/
|
||||||
|
remoteSchema: {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
timeout: number;
|
||||||
|
retries: number;
|
||||||
|
retryDelay: number;
|
||||||
|
cacheEnabled: boolean;
|
||||||
|
cacheTTL: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query execution configuration
|
||||||
|
* @property {number} timeout - Maximum query execution time in milliseconds
|
||||||
|
* @property {number} maxDepth - Maximum allowed query depth
|
||||||
|
* @property {number} maxComplexity - Maximum allowed query complexity score
|
||||||
|
*/
|
||||||
|
execution: {
|
||||||
|
timeout: number;
|
||||||
|
maxDepth: number;
|
||||||
|
maxComplexity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation configuration
|
||||||
|
* @property {number} maxQueryLength - Maximum allowed query string length
|
||||||
|
* @property {number} maxBatchSize - Maximum number of operations in a batch
|
||||||
|
* @property {boolean} enableIntrospection - Whether to allow schema introspection
|
||||||
|
*/
|
||||||
|
validation: {
|
||||||
|
maxQueryLength: number;
|
||||||
|
maxBatchSize: number;
|
||||||
|
enableIntrospection: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logging configuration
|
||||||
|
* @property {boolean} logQueries - Whether to log all GraphQL queries
|
||||||
|
* @property {boolean} logErrors - Whether to log errors
|
||||||
|
* @property {boolean} logPerformance - Whether to log performance metrics
|
||||||
|
* @property {number} slowQueryThreshold - Threshold in milliseconds for slow query warnings
|
||||||
|
*/
|
||||||
|
logging: {
|
||||||
|
logQueries: boolean;
|
||||||
|
logErrors: boolean;
|
||||||
|
logPerformance: boolean;
|
||||||
|
slowQueryThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check configuration
|
||||||
|
* @property {boolean} enabled - Whether periodic health checks are enabled
|
||||||
|
* @property {number} interval - Health check interval in milliseconds
|
||||||
|
*/
|
||||||
|
healthCheck: {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default GraphQL configuration with sensible defaults
|
||||||
|
* @constant {GraphQLConfig}
|
||||||
|
* @description Provides default configuration values, with environment variable overrides
|
||||||
|
* for remote schema URL and introspection settings
|
||||||
|
*/
|
||||||
|
export const defaultGraphQLConfig: GraphQLConfig = {
|
||||||
|
remoteSchema: {
|
||||||
|
enabled: true,
|
||||||
|
url: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql",
|
||||||
|
timeout: 10000,
|
||||||
|
retries: 3,
|
||||||
|
retryDelay: 2000,
|
||||||
|
cacheEnabled: true,
|
||||||
|
cacheTTL: 3600, // 1 hour
|
||||||
|
},
|
||||||
|
|
||||||
|
execution: {
|
||||||
|
timeout: 30000,
|
||||||
|
maxDepth: 10,
|
||||||
|
maxComplexity: 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
validation: {
|
||||||
|
maxQueryLength: 10000,
|
||||||
|
maxBatchSize: 100,
|
||||||
|
enableIntrospection: process.env.NODE_ENV !== "production",
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
logQueries: process.env.NODE_ENV === "development",
|
||||||
|
logErrors: true,
|
||||||
|
logPerformance: true,
|
||||||
|
slowQueryThreshold: 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
healthCheck: {
|
||||||
|
enabled: true,
|
||||||
|
interval: 60000, // 1 minute
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GraphQL configuration with environment variable overrides
|
||||||
|
* @function getGraphQLConfig
|
||||||
|
* @returns {GraphQLConfig} Complete GraphQL configuration object
|
||||||
|
* @description Merges default configuration with environment variable overrides.
|
||||||
|
* Supports the following environment variables:
|
||||||
|
* - `METADATA_GRAPHQL_URL`: Remote schema URL
|
||||||
|
* - `GRAPHQL_REMOTE_TIMEOUT`: Remote schema timeout (ms)
|
||||||
|
* - `GRAPHQL_REMOTE_RETRIES`: Number of retry attempts
|
||||||
|
* - `GRAPHQL_EXECUTION_TIMEOUT`: Query execution timeout (ms)
|
||||||
|
* - `GRAPHQL_MAX_QUERY_DEPTH`: Maximum query depth
|
||||||
|
* - `GRAPHQL_CACHE_ENABLED`: Enable/disable schema caching ("true"/"false")
|
||||||
|
* - `GRAPHQL_CACHE_TTL`: Cache TTL in seconds
|
||||||
|
* - `NODE_ENV`: Affects introspection and logging defaults
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const config = getGraphQLConfig();
|
||||||
|
* console.log(config.remoteSchema.url); // "http://localhost:3080/metadata-graphql"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getGraphQLConfig(): GraphQLConfig {
|
||||||
|
const config = { ...defaultGraphQLConfig };
|
||||||
|
|
||||||
|
// Override with environment variables if present
|
||||||
|
if (process.env.GRAPHQL_REMOTE_TIMEOUT) {
|
||||||
|
config.remoteSchema.timeout = parseInt(process.env.GRAPHQL_REMOTE_TIMEOUT, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GRAPHQL_REMOTE_RETRIES) {
|
||||||
|
config.remoteSchema.retries = parseInt(process.env.GRAPHQL_REMOTE_RETRIES, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GRAPHQL_EXECUTION_TIMEOUT) {
|
||||||
|
config.execution.timeout = parseInt(process.env.GRAPHQL_EXECUTION_TIMEOUT, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GRAPHQL_MAX_QUERY_DEPTH) {
|
||||||
|
config.execution.maxDepth = parseInt(process.env.GRAPHQL_MAX_QUERY_DEPTH, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GRAPHQL_CACHE_ENABLED) {
|
||||||
|
config.remoteSchema.cacheEnabled = process.env.GRAPHQL_CACHE_ENABLED === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GRAPHQL_CACHE_TTL) {
|
||||||
|
config.remoteSchema.cacheTTL = parseInt(process.env.GRAPHQL_CACHE_TTL, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export const COMICS_DIRECTORY = "./comics";
|
export const COMICS_DIRECTORY = process.env.COMICS_DIRECTORY || "./comics";
|
||||||
export const USERDATA_DIRECTORY = "./userdata";
|
export const USERDATA_DIRECTORY = process.env.USERDATA_DIRECTORY || "./userdata";
|
||||||
@@ -17,23 +17,17 @@ services:
|
|||||||
hostname: kafka1
|
hostname: kafka1
|
||||||
container_name: kafka1
|
container_name: kafka1
|
||||||
ports:
|
ports:
|
||||||
- "9092:9092"
|
- "127.0.0.1:9092:9092" # exposed ONLY to host localhost
|
||||||
- "29092:29092"
|
|
||||||
- "9999:9999"
|
|
||||||
environment:
|
environment:
|
||||||
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
|
||||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT
|
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_INTERNAL://0.0.0.0:29092
|
||||||
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka1:29092
|
||||||
|
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
|
||||||
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
|
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
|
||||||
KAFKA_BROKER_ID: 1
|
KAFKA_BROKER_ID: 1
|
||||||
KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO"
|
|
||||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||||
KAFKA_JMX_PORT: 9999
|
|
||||||
KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1}
|
|
||||||
KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer
|
|
||||||
KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- zoo1
|
- zoo1
|
||||||
networks:
|
networks:
|
||||||
@@ -63,7 +57,7 @@ services:
|
|||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- "mongodb_data:/bitnami/mongodb"
|
- "mongodb_data:/bitnami/mongodb"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: "bitnami/redis:latest"
|
image: "bitnami/redis:latest"
|
||||||
container_name: queue
|
container_name: queue
|
||||||
@@ -75,7 +69,7 @@ services:
|
|||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.16.2
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.2
|
||||||
container_name: elasticsearch
|
container_name: elasticsearch
|
||||||
environment:
|
environment:
|
||||||
- "discovery.type=single-node"
|
- "discovery.type=single-node"
|
||||||
|
|||||||
95
docs/METADATA_FIELD_MAPPINGS.md
Normal file
95
docs/METADATA_FIELD_MAPPINGS.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Metadata Field Mappings
|
||||||
|
|
||||||
|
Maps each canonical field to the dot-path where its value lives inside `sourcedMetadata.<source>`.
|
||||||
|
|
||||||
|
Used by `SOURCE_FIELD_PATHS` in `utils/metadata.resolution.utils.ts` and drives both the auto-resolution algorithm and the cherry-pick comparison view.
|
||||||
|
|
||||||
|
Dot-notation paths are relative to `sourcedMetadata.<source>`
|
||||||
|
(e.g. `volumeInformation.name` → `comic.sourcedMetadata.comicvine.volumeInformation.name`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source API Notes
|
||||||
|
|
||||||
|
| Source | API | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ComicVine | `https://comicvine.gamespot.com/api/` | Free API key | Covers Marvel, DC, and independents |
|
||||||
|
| Metron | `https://metron.cloud/api/` | Free account | Modern community DB, growing |
|
||||||
|
| GCD | `https://www.comics.org/api/` | None | Creators/characters live inside `story_set[]`, not top-level |
|
||||||
|
| LOCG | `https://leagueofcomicgeeks.com` | No public API | Scraped or partner access |
|
||||||
|
| ComicInfo.xml | Embedded in archive | N/A | ComicRack standard |
|
||||||
|
| Shortboxed | `https://api.shortboxed.com` | Partner key | Release-focused; limited metadata |
|
||||||
|
| Marvel | `https://gateway.marvel.com/v1/public/` | API key | Official Marvel API |
|
||||||
|
| DC | No official public API | — | Use ComicVine for DC issues |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scalar Fields
|
||||||
|
|
||||||
|
| Canonical Field | ComicVine | Metron | GCD | LOCG | ComicInfo.xml | Shortboxed | Marvel |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `title` | `name` | `name` | `title` | `name` | `Title` | `title` | `title` |
|
||||||
|
| `series` | `volumeInformation.name` | `series.name` | `series_name` | TBD | `Series` | TBD | `series.name` |
|
||||||
|
| `issueNumber` | `issue_number` | `number` | `number` | TBD | `Number` | TBD | `issueNumber` |
|
||||||
|
| `volume` | `volume.name` | `series.volume` | `volume` | TBD | `Volume` | TBD | TBD |
|
||||||
|
| `publisher` | `volumeInformation.publisher.name` | `publisher.name` | `indicia_publisher` | `publisher` | `Publisher` | `publisher` | `"Marvel"` (static) |
|
||||||
|
| `imprint` | TBD | `imprint.name` | `brand_emblem` | TBD | `Imprint` | TBD | TBD |
|
||||||
|
| `coverDate` | `cover_date` | `cover_date` | `key_date` | TBD | `CoverDate` | TBD | `dates[onsaleDate].date` |
|
||||||
|
| `publicationDate` | `store_date` | `store_date` | `on_sale_date` | TBD | TBD | `release_date` | `dates[focDate].date` |
|
||||||
|
| `description` | `description` | `desc` | `story_set[0].synopsis` | `description` | `Summary` | `description` | `description` |
|
||||||
|
| `notes` | TBD | TBD | `notes` | TBD | `Notes` | TBD | TBD |
|
||||||
|
| `pageCount` | TBD | `page_count` | `page_count` | TBD | `PageCount` | TBD | `pageCount` |
|
||||||
|
| `ageRating` | TBD | `rating.name` | `rating` | TBD | `AgeRating` | TBD | TBD |
|
||||||
|
| `format` | TBD | `series.series_type.name` | `story_set[0].type` | TBD | `Format` | TBD | `format` |
|
||||||
|
| `communityRating` | TBD | TBD | TBD | `rating` | TBD | TBD | TBD |
|
||||||
|
| `coverImage` | `image.original_url` | `image` | `cover` | `cover` | TBD | TBD | `thumbnail.path + "." + thumbnail.extension` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Array / Nested Fields
|
||||||
|
|
||||||
|
GCD creator credits live as free-text strings inside `story_set[0]` (e.g. `"script": "Grant Morrison, Peter Milligan"`), not as structured arrays. These need to be split on commas during mapping.
|
||||||
|
|
||||||
|
| Canonical Field | ComicVine | Metron | GCD | LOCG | ComicInfo.xml | Shortboxed | Marvel |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `creators` (writer) | `person_credits[role=writer]` | `credits[role=writer]` | `story_set[0].script` | TBD | `Writer` | `creators` | `creators.items[role=writer]` |
|
||||||
|
| `creators` (penciller) | `person_credits[role=penciller]` | `credits[role=penciller]` | `story_set[0].pencils` | TBD | `Penciller` | TBD | `creators.items[role=penciler]` |
|
||||||
|
| `creators` (inker) | `person_credits[role=inker]` | `credits[role=inker]` | `story_set[0].inks` | TBD | `Inker` | TBD | `creators.items[role=inker]` |
|
||||||
|
| `creators` (colorist) | `person_credits[role=colorist]` | `credits[role=colorist]` | `story_set[0].colors` | TBD | `Colorist` | TBD | `creators.items[role=colorist]` |
|
||||||
|
| `creators` (letterer) | `person_credits[role=letterer]` | `credits[role=letterer]` | `story_set[0].letters` | TBD | `Letterer` | TBD | `creators.items[role=letterer]` |
|
||||||
|
| `creators` (editor) | `person_credits[role=editor]` | `credits[role=editor]` | `story_set[0].editing` | TBD | `Editor` | TBD | `creators.items[role=editor]` |
|
||||||
|
| `characters` | `character_credits` | `characters` | `story_set[0].characters` | TBD | `Characters` | TBD | `characters.items` |
|
||||||
|
| `teams` | `team_credits` | `teams` | TBD | TBD | `Teams` | TBD | TBD |
|
||||||
|
| `locations` | `location_credits` | `locations` | TBD | TBD | `Locations` | TBD | TBD |
|
||||||
|
| `storyArcs` | `story_arc_credits` | `arcs` | TBD | TBD | `StoryArc` | TBD | `events.items` |
|
||||||
|
| `stories` | TBD | TBD | `story_set[].title` | TBD | TBD | TBD | `stories.items` |
|
||||||
|
| `genres` | TBD | `series.genres` | `story_set[0].genre` | TBD | `Genre` | TBD | TBD |
|
||||||
|
| `tags` | TBD | TBD | `story_set[0].keywords` | TBD | `Tags` | TBD | TBD |
|
||||||
|
| `universes` | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
|
||||||
|
| `reprints` | TBD | `reprints` | TBD | TBD | TBD | TBD | TBD |
|
||||||
|
| `urls` | `site_detail_url` | `resource_url` | `api_url` | `url` | TBD | TBD | `urls[type=detail].url` |
|
||||||
|
| `prices` | `price` | TBD | `price` | `price` | `Price` | `price` | `prices[type=printPrice].price` |
|
||||||
|
| `externalIDs` | `id` | `id` | `api_url` | TBD | TBD | `diamond_id` | `id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identifiers / GTINs
|
||||||
|
|
||||||
|
| Canonical Field | ComicVine | Metron | GCD | LOCG | ComicInfo.xml | Shortboxed | Marvel |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `gtin.isbn` | TBD | TBD | `isbn` | TBD | TBD | TBD | `isbn` |
|
||||||
|
| `gtin.upc` | TBD | TBD | `barcode` | TBD | TBD | TBD | `upc` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Mapping Notes
|
||||||
|
|
||||||
|
- **DC Comics**: No official public API. DC issue metadata is sourced via **ComicVine** (which has comprehensive DC coverage). There is no separate `dc` source key.
|
||||||
|
- **GCD creators**: All credit fields (`script`, `pencils`, `inks`, `colors`, `letters`, `editing`) are comma-separated strings inside `story_set[0]`. Mapping code must split these into individual creator objects with roles assigned.
|
||||||
|
- **GCD characters/genre/keywords**: Also inside `story_set[0]`, not top-level on the issue.
|
||||||
|
- **Marvel publisher**: Always "Marvel Comics" — can be set as a static value rather than extracted from the API response.
|
||||||
|
- **Marvel cover image**: Constructed by concatenating `thumbnail.path + "." + thumbnail.extension`.
|
||||||
|
- **Marvel dates**: Multiple date types in a `dates[]` array — filter by `type == "onsaleDate"` for cover date, `type == "focDate"` for FOC/publication date.
|
||||||
|
- **Marvel creators/characters**: Nested inside collection objects (`creators.items[]`, `characters.items[]`) with `name` and `role` sub-fields.
|
||||||
|
- **Shortboxed**: Release-focused service; limited metadata. Best used for `publicationDate`, `price`, and `publisher` only. No series/issue number fields.
|
||||||
|
- **LOCG**: No public API; fields marked TBD will need to be confirmed when integration is built.
|
||||||
330
docs/METADATA_RECONCILIATION_PLAN.md
Normal file
330
docs/METADATA_RECONCILIATION_PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Metadata Reconciliation System Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Comics in the library can have metadata from multiple sources: ComicVine, Metron, GCD, LOCG, ComicInfo.xml, Shortboxed, Marvel, DC, and Manual. The existing `canonicalMetadata` + `sourcedMetadata` architecture already stores raw per-source data and has a resolution algorithm, but there's no way for a user to interactively compare and cherry-pick values across sources field-by-field. This plan adds that manual reconciliation workflow (Phase 1) and lays the groundwork for ranked auto-resolution (Phase 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (what already exists)
|
||||||
|
|
||||||
|
- `sourcedMetadata.{comicvine,metron,gcd,locg,comicInfo}` — raw per-source data (Mongoose Mixed) — **Shortboxed, Marvel, DC not yet added**
|
||||||
|
- `canonicalMetadata` — resolved truth, each field is `{ value, provenance, userOverride }`
|
||||||
|
- `analyzeMetadataConflicts(comicId)` GraphQL query — conflict view for 5 fields only
|
||||||
|
- `setMetadataField(comicId, field, value)` — stores MANUAL override with raw string
|
||||||
|
- `resolveMetadata(comicId)` / `bulkResolveMetadata(comicIds)` — trigger auto-resolution
|
||||||
|
- `previewCanonicalMetadata(comicId, preferences)` — dry run
|
||||||
|
- `buildCanonicalMetadata()` in `utils/metadata.resolution.utils.ts` — covers only 7 fields
|
||||||
|
- `UserPreferences` model with `sourcePriorities`, `conflictResolution`, `autoMerge`
|
||||||
|
- `updateUserPreferences` resolver — fully implemented
|
||||||
|
- `autoResolveMetadata()` in `services/graphql.service.ts` — exists but only for scalar triggers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Manual Cherry-Pick Reconciliation
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
For any comic, a user can open a comparison table: each row is a canonical field, each column is a source. They click a cell to "pick" that source's value for that field. The result is stored as `canonicalMetadata.<field>` with the original source's provenance intact and `userOverride: true` to prevent future auto-resolution from overwriting it.
|
||||||
|
|
||||||
|
### Expand `MetadataSource` enum (`models/comic.model.ts` + `models/graphql/typedef.ts`)
|
||||||
|
|
||||||
|
Add new sources to the enum:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
enum MetadataSource {
|
||||||
|
COMICVINE = "comicvine",
|
||||||
|
METRON = "metron",
|
||||||
|
GRAND_COMICS_DATABASE = "gcd",
|
||||||
|
LOCG = "locg",
|
||||||
|
COMICINFO_XML = "comicinfo",
|
||||||
|
SHORTBOXED = "shortboxed",
|
||||||
|
MARVEL = "marvel",
|
||||||
|
DC = "dc",
|
||||||
|
MANUAL = "manual",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add to `sourcedMetadata` in `ComicSchema` (`models/comic.model.ts`):
|
||||||
|
```ts
|
||||||
|
shortboxed: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
marvel: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
dc: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
```
|
||||||
|
|
||||||
|
And in GraphQL schema enum:
|
||||||
|
```graphql
|
||||||
|
enum MetadataSource {
|
||||||
|
COMICVINE
|
||||||
|
METRON
|
||||||
|
GRAND_COMICS_DATABASE
|
||||||
|
LOCG
|
||||||
|
COMICINFO_XML
|
||||||
|
SHORTBOXED
|
||||||
|
MARVEL
|
||||||
|
DC
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Shortboxed, Marvel, and DC field paths in `SOURCE_FIELD_PATHS` will be stubs (`{}`) until those integrations are built. The comparison view will simply show no data for those sources until then — no breaking changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### New types (GraphQL — `models/graphql/typedef.ts`)
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# One source's value for a single field
|
||||||
|
type SourceFieldValue {
|
||||||
|
source: MetadataSource!
|
||||||
|
value: JSON # null if source has no value for this field
|
||||||
|
confidence: Float
|
||||||
|
fetchedAt: String
|
||||||
|
url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# All sources' values for a single canonical field
|
||||||
|
type MetadataFieldComparison {
|
||||||
|
field: String!
|
||||||
|
currentCanonical: MetadataField # what is currently resolved
|
||||||
|
sourcedValues: [SourceFieldValue!]! # one entry per source that has data
|
||||||
|
hasConflict: Boolean! # true if >1 source has a different value
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataComparisonView {
|
||||||
|
comicId: ID!
|
||||||
|
comparisons: [MetadataFieldComparison!]!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Query`:
|
||||||
|
```graphql
|
||||||
|
getMetadataComparisonView(comicId: ID!): MetadataComparisonView!
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Mutation`:
|
||||||
|
```graphql
|
||||||
|
# Cherry-pick a single field from a named source
|
||||||
|
pickFieldFromSource(comicId: ID!, field: String!, source: MetadataSource!): Comic!
|
||||||
|
|
||||||
|
# Batch cherry-pick multiple fields at once
|
||||||
|
batchPickFieldsFromSources(
|
||||||
|
comicId: ID!
|
||||||
|
picks: [FieldSourcePick!]!
|
||||||
|
): Comic!
|
||||||
|
|
||||||
|
input FieldSourcePick {
|
||||||
|
field: String!
|
||||||
|
source: MetadataSource!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes to `utils/metadata.resolution.utils.ts`
|
||||||
|
|
||||||
|
Add `SOURCE_FIELD_PATHS` — a complete mapping of every canonical field to its path in each sourced-metadata blob:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const SOURCE_FIELD_PATHS: Record<
|
||||||
|
string, // canonical field name
|
||||||
|
Partial<Record<MetadataSource, string>> // source → dot-path in sourcedMetadata[source]
|
||||||
|
> = {
|
||||||
|
title: { comicvine: "name", metron: "name", comicinfo: "Title", locg: "name" },
|
||||||
|
series: { comicvine: "volumeInformation.name", comicinfo: "Series" },
|
||||||
|
issueNumber: { comicvine: "issue_number", metron: "number", comicinfo: "Number" },
|
||||||
|
publisher: { comicvine: "volumeInformation.publisher.name", locg: "publisher", comicinfo: "Publisher" },
|
||||||
|
coverDate: { comicvine: "cover_date", metron: "cover_date", comicinfo: "CoverDate" },
|
||||||
|
description: { comicvine: "description", locg: "description", comicinfo: "Summary" },
|
||||||
|
pageCount: { comicinfo: "PageCount", metron: "page_count" },
|
||||||
|
ageRating: { comicinfo: "AgeRating", metron: "rating.name" },
|
||||||
|
format: { metron: "series.series_type.name", comicinfo: "Format" },
|
||||||
|
// creators → array field, handled separately
|
||||||
|
storyArcs: { comicvine: "story_arc_credits", metron: "arcs", comicinfo: "StoryArc" },
|
||||||
|
characters: { comicvine: "character_credits", metron: "characters", comicinfo: "Characters" },
|
||||||
|
teams: { comicvine: "team_credits", metron: "teams", comicinfo: "Teams" },
|
||||||
|
locations: { comicvine: "location_credits", metron: "locations", comicinfo: "Locations" },
|
||||||
|
genres: { metron: "series.genres", comicinfo: "Genre" },
|
||||||
|
tags: { comicinfo: "Tags" },
|
||||||
|
communityRating: { locg: "rating" },
|
||||||
|
coverImage: { comicvine: "image.original_url", locg: "cover", metron: "image" },
|
||||||
|
// Shortboxed, Marvel, DC — paths TBD when integrations are built
|
||||||
|
// shortboxed: {}, marvel: {}, dc: {}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `extractAllSourceValues(field, sourcedMetadata)` — returns `SourceFieldValue[]` for every source that has a non-null value for the given field.
|
||||||
|
|
||||||
|
Update `buildCanonicalMetadata()` to use `SOURCE_FIELD_PATHS` instead of the hard-coded 7-field mapping. This single source of truth drives both auto-resolve and the comparison view.
|
||||||
|
|
||||||
|
### Changes to `models/graphql/resolvers.ts`
|
||||||
|
|
||||||
|
**`getMetadataComparisonView` resolver:**
|
||||||
|
- Fetch comic by ID
|
||||||
|
- For each key in `SOURCE_FIELD_PATHS`, call `extractAllSourceValues()`
|
||||||
|
- Return the comparison array with `hasConflict` flag
|
||||||
|
- Include `currentCanonical` from `comic.canonicalMetadata[field]` if it exists
|
||||||
|
|
||||||
|
**`pickFieldFromSource` resolver:**
|
||||||
|
- Fetch comic, validate source has a value for the field
|
||||||
|
- Extract value + provenance from `sourcedMetadata[source]` via `SOURCE_FIELD_PATHS`
|
||||||
|
- Write to `canonicalMetadata[field]` with original source provenance + `userOverride: true`
|
||||||
|
- Save and return comic
|
||||||
|
|
||||||
|
**`batchPickFieldsFromSources` resolver:**
|
||||||
|
- Same as above but iterate over `picks[]`, do a single `comic.save()`
|
||||||
|
|
||||||
|
### Changes to `services/library.service.ts`
|
||||||
|
|
||||||
|
Add Moleculer actions that delegate to GraphQL:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getMetadataComparisonView: {
|
||||||
|
rest: "POST /getMetadataComparisonView",
|
||||||
|
async handler(ctx) { /* call GraphQL query */ }
|
||||||
|
},
|
||||||
|
pickFieldFromSource: {
|
||||||
|
rest: "POST /pickFieldFromSource",
|
||||||
|
async handler(ctx) { /* call GraphQL mutation */ }
|
||||||
|
},
|
||||||
|
batchPickFieldsFromSources: {
|
||||||
|
rest: "POST /batchPickFieldsFromSources",
|
||||||
|
async handler(ctx) { /* call GraphQL mutation */ }
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes to `utils/import.graphql.utils.ts`
|
||||||
|
|
||||||
|
Add three helper functions mirroring the pattern of existing utils:
|
||||||
|
- `getMetadataComparisonViewViaGraphQL(broker, comicId)`
|
||||||
|
- `pickFieldFromSourceViaGraphQL(broker, comicId, field, source)`
|
||||||
|
- `batchPickFieldsFromSourcesViaGraphQL(broker, comicId, picks)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Guidance: GraphQL vs REST
|
||||||
|
|
||||||
|
The project has two distinct patterns — use the right one:
|
||||||
|
|
||||||
|
| Type of operation | Pattern |
|
||||||
|
|---|---|
|
||||||
|
| Complex metadata logic (resolution, provenance, conflict analysis) | **GraphQL mutation/query** in `typedef.ts` + `resolvers.ts` |
|
||||||
|
| User-facing operation the UI calls | **REST action** in `library.service.ts` → delegates to GraphQL via `broker.call("graphql.graphql", {...})` |
|
||||||
|
| Pure acquisition tracking (no resolution) | Direct DB write in `library.service.ts`, no GraphQL needed |
|
||||||
|
|
||||||
|
**All three new reconciliation operations** (`getMetadataComparisonView`, `pickFieldFromSource`, `batchPickFieldsFromSources`) follow the first two rows: GraphQL for the logic + REST wrapper for UI consumption.
|
||||||
|
|
||||||
|
### Gap: `applyComicVineMetadata` bypasses canonicalMetadata
|
||||||
|
|
||||||
|
Currently `library.applyComicVineMetadata` writes directly to `sourcedMetadata.comicvine` in MongoDB without triggering `buildCanonicalMetadata`. This means `canonicalMetadata` goes stale when ComicVine data is applied.
|
||||||
|
|
||||||
|
The fix: change `applyComicVineMetadata` to call the existing `updateSourcedMetadata` GraphQL mutation instead of the direct DB write. `updateSourcedMetadata` already triggers re-resolution via `autoMerge.onMetadataUpdate`.
|
||||||
|
|
||||||
|
**File**: `services/library.service.ts` lines ~937–990 (applyComicVineMetadata handler)
|
||||||
|
**Change**: Replace direct `Comic.findByIdAndUpdate` with `broker.call("graphql.graphql", { query: updateSourcedMetadataMutation, ... })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Source Ranking + AutoResolve (design — not implementing yet)
|
||||||
|
|
||||||
|
The infrastructure already exists:
|
||||||
|
- `UserPreferences.sourcePriorities[]` with per-source `priority` (1=highest)
|
||||||
|
- `conflictResolution` strategy enum (PRIORITY, CONFIDENCE, RECENCY, HYBRID, MANUAL)
|
||||||
|
- `autoMerge.enabled / onImport / onMetadataUpdate`
|
||||||
|
- `updateUserPreferences` resolver
|
||||||
|
|
||||||
|
When this phase is implemented, the additions will be:
|
||||||
|
1. A "re-resolve all comics" action triggered when source priorities change (`POST /reResolveAllWithPreferences`)
|
||||||
|
2. `autoResolveMetadata` in graphql.service.ts wired to call `resolveMetadata` on save rather than only on import/update hooks
|
||||||
|
3. Field-specific source overrides UI (the `fieldOverrides` Map in `SourcePrioritySchema` is already modeled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TDD Approach
|
||||||
|
|
||||||
|
Each step follows Red → Green → Refactor:
|
||||||
|
1. Write failing spec(s) for the unit being built
|
||||||
|
2. Implement the minimum code to make them pass
|
||||||
|
3. Refactor if needed
|
||||||
|
|
||||||
|
**Test framework:** Jest + ts-jest (configured in `package.json`, zero existing tests — these will be the first)
|
||||||
|
**File convention:** `*.spec.ts` alongside the source file (e.g., `utils/metadata.resolution.utils.spec.ts`)
|
||||||
|
**No DB needed for unit tests** — mock `Comic.findById` etc. with `jest.spyOn` / `jest.mock`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Step 1 — Utility layer (prerequisite for everything)
|
||||||
|
**Write first:** `utils/metadata.resolution.utils.spec.ts`
|
||||||
|
- `SOURCE_FIELD_PATHS` has entries for all canonical fields
|
||||||
|
- `extractAllSourceValues("title", { comicvine: { name: "A" }, metron: { name: "B" } })` returns 2 entries with correct source + value
|
||||||
|
- `extractAllSourceValues` returns empty array when no source has the field
|
||||||
|
- `buildCanonicalMetadata()` covers all fields in `SOURCE_FIELD_PATHS` (not just 7)
|
||||||
|
- `buildCanonicalMetadata()` never overwrites fields with `userOverride: true`
|
||||||
|
|
||||||
|
**Then implement:**
|
||||||
|
- `models/comic.model.ts` — add `SHORTBOXED`, `MARVEL`, `DC` to `MetadataSource` enum; add 3 new `sourcedMetadata` fields
|
||||||
|
- `models/userpreferences.model.ts` — add SHORTBOXED (priority 7), MARVEL (8), DC (9) to default `sourcePriorities`
|
||||||
|
- `utils/metadata.resolution.utils.ts` — add `SOURCE_FIELD_PATHS`, `extractAllSourceValues()`, rewrite `buildCanonicalMetadata()`
|
||||||
|
|
||||||
|
### Step 2 — GraphQL schema (no tests — type definitions only)
|
||||||
|
**`models/graphql/typedef.ts`**
|
||||||
|
- Expand `MetadataSource` enum (add SHORTBOXED, MARVEL, DC)
|
||||||
|
- Add `SourceFieldValue`, `MetadataFieldComparison`, `MetadataComparisonView`, `FieldSourcePick` types
|
||||||
|
- Add `getMetadataComparisonView` to `Query`
|
||||||
|
- Add `pickFieldFromSource`, `batchPickFieldsFromSources` to `Mutation`
|
||||||
|
|
||||||
|
### Step 3 — GraphQL resolvers
|
||||||
|
**Write first:** `models/graphql/resolvers.spec.ts`
|
||||||
|
- `getMetadataComparisonView`: returns one entry per field in `SOURCE_FIELD_PATHS`; `hasConflict` true when sources disagree; `currentCanonical` reflects DB state
|
||||||
|
- `pickFieldFromSource`: sets field with source provenance + `userOverride: true`; throws when source has no value
|
||||||
|
- `batchPickFieldsFromSources`: applies all picks in a single save
|
||||||
|
- `applyComicVineMetadata` fix: calls `updateSourcedMetadata` mutation (not direct DB write)
|
||||||
|
|
||||||
|
**Then implement:** `models/graphql/resolvers.ts`
|
||||||
|
|
||||||
|
### Step 4 — GraphQL util helpers
|
||||||
|
**Write first:** `utils/import.graphql.utils.spec.ts`
|
||||||
|
- Each helper calls `broker.call("graphql.graphql", ...)` with correct query/variables
|
||||||
|
- GraphQL errors are propagated
|
||||||
|
|
||||||
|
**Then implement:** `utils/import.graphql.utils.ts`
|
||||||
|
|
||||||
|
### Step 5 — REST surface
|
||||||
|
**Write first:** `services/library.service.spec.ts`
|
||||||
|
- Each action delegates to the correct GraphQL util helper
|
||||||
|
- Context params pass through correctly
|
||||||
|
|
||||||
|
**Then implement:** `services/library.service.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Files
|
||||||
|
|
||||||
|
| File | Step | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| `models/comic.model.ts` | 1 | Add `SHORTBOXED`, `MARVEL`, `DC` to `MetadataSource` enum; add 3 new `sourcedMetadata` fields |
|
||||||
|
| `models/userpreferences.model.ts` | 1 | Add SHORTBOXED (priority 7), MARVEL (8), DC (9) to default `sourcePriorities` |
|
||||||
|
| `utils/metadata.resolution.utils.ts` | 1 | Add `SOURCE_FIELD_PATHS`, `extractAllSourceValues()`; rewrite `buildCanonicalMetadata()` |
|
||||||
|
| `models/graphql/typedef.ts` | 2 | Expand `MetadataSource` enum; add 4 new types + query + 2 mutations |
|
||||||
|
| `models/graphql/resolvers.ts` | 3 | Implement 3 resolvers + fix `applyComicVineMetadata` |
|
||||||
|
| `utils/import.graphql.utils.ts` | 4 | Add 3 GraphQL util functions |
|
||||||
|
| `services/library.service.ts` | 5 | Add 3 Moleculer REST actions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reusable Existing Code
|
||||||
|
|
||||||
|
- `resolveMetadataField()` in `utils/metadata.resolution.utils.ts` — reused inside `buildCanonicalMetadata()`
|
||||||
|
- `getNestedValue()` in same file — reused in `extractAllSourceValues()`
|
||||||
|
- `convertPreferences()` in `models/graphql/resolvers.ts` — reused in `getMetadataComparisonView`
|
||||||
|
- `autoResolveMetadata()` in `services/graphql.service.ts` — called after `pickFieldFromSource` if `autoMerge.onMetadataUpdate` is true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Unit**: `extractAllSourceValues("title", { comicvine: { name: "A" }, metron: { name: "B" } })` → 2 entries with correct provenance
|
||||||
|
2. **GraphQL**: `getMetadataComparisonView(comicId)` on a comic with comicvine + comicInfo data → all fields populated
|
||||||
|
3. **Cherry-pick**: `pickFieldFromSource(comicId, "title", COMICVINE)` → `canonicalMetadata.title.provenance.source == "comicvine"` and `userOverride == true`
|
||||||
|
4. **Batch**: `batchPickFieldsFromSources` with 3 fields → single DB write, all 3 updated
|
||||||
|
5. **Lock**: After cherry-picking, `resolveMetadata(comicId)` must NOT overwrite picked fields (`userOverride: true` takes priority)
|
||||||
|
6. **REST**: `POST /api/library/getMetadataComparisonView` returns expected JSON
|
||||||
137
migrations/add-import-indexes.ts
Normal file
137
migrations/add-import-indexes.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Migration script to add indexes for import performance optimization
|
||||||
|
*
|
||||||
|
* This migration adds indexes to the Comic collection to dramatically improve
|
||||||
|
* the performance of import statistics queries, especially for large libraries.
|
||||||
|
*
|
||||||
|
* Run this script once to add indexes to an existing database:
|
||||||
|
* npx ts-node migrations/add-import-indexes.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import Comic from "../models/comic.model";
|
||||||
|
|
||||||
|
// Suppress Mongoose 7 deprecation warning
|
||||||
|
mongoose.set('strictQuery', false);
|
||||||
|
|
||||||
|
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017/threetwo";
|
||||||
|
|
||||||
|
async function addIndexes() {
|
||||||
|
try {
|
||||||
|
console.log("Connecting to MongoDB...");
|
||||||
|
await mongoose.connect(MONGO_URI);
|
||||||
|
console.log("Connected successfully");
|
||||||
|
|
||||||
|
console.log("\nAdding indexes to Comic collection...");
|
||||||
|
|
||||||
|
// Get the collection
|
||||||
|
const collection = Comic.collection;
|
||||||
|
|
||||||
|
// Check existing indexes
|
||||||
|
console.log("\nExisting indexes:");
|
||||||
|
const existingIndexes = await collection.indexes();
|
||||||
|
const existingIndexMap = new Map();
|
||||||
|
|
||||||
|
existingIndexes.forEach((index) => {
|
||||||
|
const keyStr = JSON.stringify(index.key);
|
||||||
|
console.log(` - ${keyStr} (name: ${index.name})`);
|
||||||
|
existingIndexMap.set(keyStr, index.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create index if it doesn't exist
|
||||||
|
async function createIndexIfNeeded(
|
||||||
|
key: any,
|
||||||
|
options: any,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
const keyStr = JSON.stringify(key);
|
||||||
|
|
||||||
|
if (existingIndexMap.has(keyStr)) {
|
||||||
|
console.log(` ⏭️ Index on ${description} already exists (${existingIndexMap.get(keyStr)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Creating index on ${description}...`);
|
||||||
|
try {
|
||||||
|
await collection.createIndex(key, options);
|
||||||
|
console.log(" ✓ Created");
|
||||||
|
} catch (error: any) {
|
||||||
|
// If index already exists with different name, that's okay
|
||||||
|
if (error.code === 85 || error.codeName === 'IndexOptionsConflict') {
|
||||||
|
console.log(` ⏭️ Index already exists (skipping)`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new indexes
|
||||||
|
console.log("\nCreating new indexes...");
|
||||||
|
|
||||||
|
// Index for import statistics queries (most important)
|
||||||
|
await createIndexIfNeeded(
|
||||||
|
{ "rawFileDetails.filePath": 1 },
|
||||||
|
{
|
||||||
|
name: "rawFileDetails_filePath_1",
|
||||||
|
background: true // Create in background to avoid blocking
|
||||||
|
},
|
||||||
|
"rawFileDetails.filePath"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for duplicate detection
|
||||||
|
await createIndexIfNeeded(
|
||||||
|
{ "rawFileDetails.name": 1 },
|
||||||
|
{
|
||||||
|
name: "rawFileDetails_name_1",
|
||||||
|
background: true
|
||||||
|
},
|
||||||
|
"rawFileDetails.name"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for wanted comics queries
|
||||||
|
await createIndexIfNeeded(
|
||||||
|
{ "wanted.volume.id": 1 },
|
||||||
|
{
|
||||||
|
name: "wanted_volume_id_1",
|
||||||
|
background: true,
|
||||||
|
sparse: true // Only index documents that have this field
|
||||||
|
},
|
||||||
|
"wanted.volume.id"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify indexes were created
|
||||||
|
console.log("\nFinal indexes:");
|
||||||
|
const finalIndexes = await collection.indexes();
|
||||||
|
finalIndexes.forEach((index) => {
|
||||||
|
console.log(` - ${JSON.stringify(index.key)} (name: ${index.name})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n✅ Migration completed successfully!");
|
||||||
|
console.log("\nPerformance improvements:");
|
||||||
|
console.log(" - Import statistics queries should be 10-100x faster");
|
||||||
|
console.log(" - Large libraries (10,000+ comics) will see the most benefit");
|
||||||
|
console.log(" - Timeout errors should be eliminated");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("\n❌ Migration failed:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
console.log("\nDisconnected from MongoDB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the migration
|
||||||
|
if (require.main === module) {
|
||||||
|
addIndexes()
|
||||||
|
.then(() => {
|
||||||
|
console.log("\nMigration script completed");
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("\nMigration script failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default addIndexes;
|
||||||
@@ -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({
|
const RawFileDetailsSchema = mongoose.Schema({
|
||||||
_id: false,
|
_id: false,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -49,6 +259,7 @@ const LOCGSchema = mongoose.Schema({
|
|||||||
pulls: Number,
|
pulls: Number,
|
||||||
potw: Number,
|
potw: Number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const DirectConnectBundleSchema = mongoose.Schema({
|
const DirectConnectBundleSchema = mongoose.Schema({
|
||||||
bundleId: Number,
|
bundleId: Number,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -56,6 +267,7 @@ const DirectConnectBundleSchema = mongoose.Schema({
|
|||||||
type: {},
|
type: {},
|
||||||
_id: false,
|
_id: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wantedSchema = mongoose.Schema(
|
const wantedSchema = mongoose.Schema(
|
||||||
{
|
{
|
||||||
source: { type: String, default: null },
|
source: { type: String, default: null },
|
||||||
@@ -63,7 +275,7 @@ const wantedSchema = mongoose.Schema(
|
|||||||
issues: {
|
issues: {
|
||||||
type: [
|
type: [
|
||||||
{
|
{
|
||||||
_id: false, // Disable automatic ObjectId creation for each issue
|
_id: false,
|
||||||
id: Number,
|
id: Number,
|
||||||
url: String,
|
url: String,
|
||||||
image: { type: Array, default: [] },
|
image: { type: Array, default: [] },
|
||||||
@@ -75,7 +287,7 @@ const wantedSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
volume: {
|
volume: {
|
||||||
type: {
|
type: {
|
||||||
_id: false, // Disable automatic ObjectId creation for volume
|
_id: false,
|
||||||
id: Number,
|
id: Number,
|
||||||
url: String,
|
url: String,
|
||||||
image: { type: Array, default: [] },
|
image: { type: Array, default: [] },
|
||||||
@@ -85,13 +297,14 @@ const wantedSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
); // Disable automatic ObjectId creation for the wanted object itself
|
);
|
||||||
|
|
||||||
const ComicSchema = mongoose.Schema(
|
const ComicSchema = mongoose.Schema(
|
||||||
{
|
{
|
||||||
importStatus: {
|
importStatus: {
|
||||||
isImported: Boolean,
|
isImported: Boolean,
|
||||||
tagged: Boolean,
|
tagged: Boolean,
|
||||||
|
isRawFileMissing: { type: Boolean, default: false },
|
||||||
matchedResult: {
|
matchedResult: {
|
||||||
score: String,
|
score: String,
|
||||||
},
|
},
|
||||||
@@ -99,15 +312,27 @@ const ComicSchema = mongoose.Schema(
|
|||||||
userAddedMetadata: {
|
userAddedMetadata: {
|
||||||
tags: [String],
|
tags: [String],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// NEW: Canonical metadata with provenance
|
||||||
|
canonicalMetadata: {
|
||||||
|
type: CanonicalMetadataSchema,
|
||||||
|
es_indexed: true,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// LEGACY: Keep existing sourced metadata for backward compatibility
|
||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} },
|
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: {
|
locg: {
|
||||||
type: LOCGSchema,
|
type: LOCGSchema,
|
||||||
es_indexed: true,
|
es_indexed: true,
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
rawFileDetails: {
|
rawFileDetails: {
|
||||||
type: RawFileDetailsSchema,
|
type: RawFileDetailsSchema,
|
||||||
es_indexed: true,
|
es_indexed: true,
|
||||||
@@ -129,6 +354,10 @@ const ComicSchema = mongoose.Schema(
|
|||||||
wanted: wantedSchema,
|
wanted: wantedSchema,
|
||||||
|
|
||||||
acquisition: {
|
acquisition: {
|
||||||
|
source: {
|
||||||
|
wanted: { type: Boolean, default: false },
|
||||||
|
name: { type: String, default: null },
|
||||||
|
},
|
||||||
release: {},
|
release: {},
|
||||||
directconnect: {
|
directconnect: {
|
||||||
downloads: {
|
downloads: {
|
||||||
@@ -159,5 +388,10 @@ ComicSchema.plugin(mongoosastic, {
|
|||||||
} as MongoosasticPluginOpts);
|
} as MongoosasticPluginOpts);
|
||||||
ComicSchema.plugin(paginate);
|
ComicSchema.plugin(paginate);
|
||||||
|
|
||||||
|
// Add indexes for performance
|
||||||
|
ComicSchema.index({ "rawFileDetails.filePath": 1 }); // For import statistics queries
|
||||||
|
ComicSchema.index({ "rawFileDetails.name": 1 }); // For duplicate detection
|
||||||
|
ComicSchema.index({ "wanted.volume.id": 1 }); // For wanted comics queries
|
||||||
|
|
||||||
const Comic = mongoose.model("Comic", ComicSchema);
|
const Comic = mongoose.model("Comic", ComicSchema);
|
||||||
export default Comic;
|
export default Comic;
|
||||||
|
|||||||
2016
models/graphql/resolvers.ts
Normal file
2016
models/graphql/resolvers.ts
Normal file
File diff suppressed because it is too large
Load Diff
1042
models/graphql/typedef.ts
Normal file
1042
models/graphql/typedef.ts
Normal file
File diff suppressed because it is too large
Load Diff
164
models/userpreferences.model.ts
Normal file
164
models/userpreferences.model.ts
Normal 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;
|
||||||
@@ -102,7 +102,7 @@ const brokerConfig: BrokerOptions = {
|
|||||||
serializer: "JSON",
|
serializer: "JSON",
|
||||||
|
|
||||||
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
|
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
|
||||||
requestTimeout: 10 * 1000,
|
requestTimeout: 60 * 1000,
|
||||||
|
|
||||||
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
|
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
|
||||||
retryPolicy: {
|
retryPolicy: {
|
||||||
|
|||||||
4429
package-lock.json
generated
4429
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -12,7 +12,8 @@
|
|||||||
"lint": "eslint --ext .js,.ts .",
|
"lint": "eslint --ext .js,.ts .",
|
||||||
"dc:up": "docker-compose up --build -d",
|
"dc:up": "docker-compose up --build -d",
|
||||||
"dc:logs": "docker-compose logs -f",
|
"dc:logs": "docker-compose logs -f",
|
||||||
"dc:down": "docker-compose down"
|
"dc:down": "docker-compose down",
|
||||||
|
"migrate:indexes": "ts-node migrations/add-import-indexes.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"microservices",
|
"microservices",
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
||||||
"@elastic/elasticsearch": "^8.13.1",
|
"@elastic/elasticsearch": "^8.13.1",
|
||||||
|
"@graphql-tools/delegate": "^12.0.8",
|
||||||
|
"@graphql-tools/schema": "^10.0.31",
|
||||||
|
"@graphql-tools/stitch": "^10.1.12",
|
||||||
|
"@graphql-tools/utils": "^11.0.0",
|
||||||
|
"@graphql-tools/wrap": "^11.1.8",
|
||||||
"@jorgeferrero/stream-to-buffer": "^2.0.6",
|
"@jorgeferrero/stream-to-buffer": "^2.0.6",
|
||||||
"@npcz/magic": "^1.3.14",
|
"@npcz/magic": "^1.3.14",
|
||||||
"@root/walk": "^1.1.0",
|
"@root/walk": "^1.1.0",
|
||||||
@@ -48,16 +54,18 @@
|
|||||||
"@types/mkdirp": "^1.0.0",
|
"@types/mkdirp": "^1.0.0",
|
||||||
"@types/node": "^13.9.8",
|
"@types/node": "^13.9.8",
|
||||||
"@types/string-similarity": "^4.0.0",
|
"@types/string-similarity": "^4.0.0",
|
||||||
"airdcpp-apisocket": "^2.4.4",
|
"airdcpp-apisocket": "^3.0.0-beta.8",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"axios-retry": "^3.2.4",
|
"axios-retry": "^3.2.4",
|
||||||
"bree": "^7.1.5",
|
"bree": "^7.1.5",
|
||||||
"calibre-opds": "^1.0.7",
|
"calibre-opds": "^1.0.7",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^4.0.3",
|
||||||
"delay": "^5.0.0",
|
"delay": "^5.0.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
"filename-parser": "^1.0.4",
|
"filename-parser": "^1.0.4",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
|
"graphql": "^16.11.0",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
"http-response-stream": "^1.0.9",
|
"http-response-stream": "^1.0.9",
|
||||||
"image-js": "^0.34.0",
|
"image-js": "^0.34.0",
|
||||||
"imghash": "^0.0.9",
|
"imghash": "^0.0.9",
|
||||||
@@ -66,6 +74,7 @@
|
|||||||
"leven": "^3.1.0",
|
"leven": "^3.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mkdirp": "^0.5.5",
|
"mkdirp": "^0.5.5",
|
||||||
|
"moleculer-apollo-server": "^0.4.0",
|
||||||
"moleculer-bullmq": "^3.0.0",
|
"moleculer-bullmq": "^3.0.0",
|
||||||
"moleculer-db": "^0.8.23",
|
"moleculer-db": "^0.8.23",
|
||||||
"moleculer-db-adapter-mongoose": "^0.9.2",
|
"moleculer-db-adapter-mongoose": "^0.9.2",
|
||||||
@@ -82,6 +91,7 @@
|
|||||||
"sharp": "^0.33.3",
|
"sharp": "^0.33.3",
|
||||||
"threetwo-ui-typings": "^1.0.14",
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
"through2": "^4.0.2",
|
"through2": "^4.0.2",
|
||||||
|
"undici": "^7.22.0",
|
||||||
"unrar": "^0.2.0",
|
"unrar": "^0.2.0",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
@@ -91,6 +101,7 @@
|
|||||||
"jest": {
|
"jest": {
|
||||||
"coverageDirectory": "<rootDir>/coverage",
|
"coverageDirectory": "<rootDir>/coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
"testTimeout": 30000,
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
"ts",
|
"ts",
|
||||||
"tsx",
|
"tsx",
|
||||||
@@ -102,9 +113,16 @@
|
|||||||
"testMatch": [
|
"testMatch": [
|
||||||
"**/*.spec.(ts|js)"
|
"**/*.spec.(ts|js)"
|
||||||
],
|
],
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"/node_modules/",
|
||||||
|
"/dist/"
|
||||||
|
],
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/tests/setup.ts"
|
||||||
|
],
|
||||||
"globals": {
|
"globals": {
|
||||||
"ts-jest": {
|
"ts-jest": {
|
||||||
"tsConfig": "tsconfig.json"
|
"tsconfig": "tsconfig.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default class AirDCPPService extends Service {
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
return await airDCPPSocket.connect();
|
return await airDCPPSocket.connect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,190 +1,333 @@
|
|||||||
import chokidar from "chokidar";
|
import chokidar, { FSWatcher } from "chokidar";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { Service, ServiceBroker } from "moleculer";
|
|
||||||
import ApiGateway from "moleculer-web";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { IFolderData } from "threetwo-ui-typings";
|
import { Service, ServiceBroker, ServiceSchema, Context } from "moleculer";
|
||||||
|
import ApiGateway from "moleculer-web";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiService exposes REST endpoints and watches the comics directory for changes.
|
||||||
|
* It uses chokidar to monitor filesystem events and broadcasts them via the Moleculer broker.
|
||||||
|
* @extends Service
|
||||||
|
*/
|
||||||
export default class ApiService extends Service {
|
export default class ApiService extends Service {
|
||||||
public constructor(broker: ServiceBroker) {
|
/**
|
||||||
super(broker);
|
* The chokidar file system watcher instance.
|
||||||
this.parseServiceSchema({
|
* @private
|
||||||
name: "api",
|
*/
|
||||||
mixins: [ApiGateway],
|
private fileWatcher?: any;
|
||||||
// More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html
|
|
||||||
settings: {
|
|
||||||
port: process.env.PORT || 3000,
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: "/api",
|
|
||||||
whitelist: ["**"],
|
|
||||||
cors: {
|
|
||||||
origin: "*",
|
|
||||||
methods: [
|
|
||||||
"GET",
|
|
||||||
"OPTIONS",
|
|
||||||
"POST",
|
|
||||||
"PUT",
|
|
||||||
"DELETE",
|
|
||||||
],
|
|
||||||
allowedHeaders: ["*"],
|
|
||||||
exposedHeaders: [],
|
|
||||||
credentials: false,
|
|
||||||
maxAge: 3600,
|
|
||||||
},
|
|
||||||
use: [],
|
|
||||||
mergeParams: true,
|
|
||||||
authentication: false,
|
|
||||||
authorization: false,
|
|
||||||
autoAliases: true,
|
|
||||||
aliases: {},
|
|
||||||
callingOptions: {},
|
|
||||||
|
|
||||||
bodyParsers: {
|
/**
|
||||||
json: {
|
* Per-path debounced handlers for add/change events, keyed by file path.
|
||||||
strict: false,
|
* @private
|
||||||
limit: "1MB",
|
*/
|
||||||
},
|
private debouncedHandlers: Map<string, ReturnType<typeof debounce>> = new Map();
|
||||||
urlencoded: {
|
|
||||||
extended: true,
|
|
||||||
limit: "1MB",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mappingPolicy: "all", // Available values: "all", "restrict"
|
|
||||||
logging: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/userdata",
|
|
||||||
use: [
|
|
||||||
ApiGateway.serveStatic(path.resolve("./userdata")),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/comics",
|
|
||||||
use: [ApiGateway.serveStatic(path.resolve("./comics"))],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/logs",
|
|
||||||
use: [ApiGateway.serveStatic("logs")],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
log4XXResponses: false,
|
|
||||||
logRequestParams: true,
|
|
||||||
logResponseData: true,
|
|
||||||
assets: {
|
|
||||||
folder: "public",
|
|
||||||
// Options to `server-static` module
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
|
|
||||||
},
|
/**
|
||||||
|
* Creates an instance of ApiService.
|
||||||
|
* @param {ServiceBroker} broker - The Moleculer service broker instance.
|
||||||
|
*/
|
||||||
|
public constructor(broker: ServiceBroker) {
|
||||||
|
super(broker);
|
||||||
|
this.parseServiceSchema({
|
||||||
|
name: "api",
|
||||||
|
mixins: [ApiGateway],
|
||||||
|
settings: {
|
||||||
|
port: process.env.PORT || 3000,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/api",
|
||||||
|
whitelist: ["**"],
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "OPTIONS", "POST", "PUT", "DELETE"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
use: [],
|
||||||
|
mergeParams: true,
|
||||||
|
authentication: false,
|
||||||
|
authorization: false,
|
||||||
|
autoAliases: true,
|
||||||
|
aliases: {
|
||||||
|
"GET /settings/getDirectoryStatus": "settings.getDirectoryStatus",
|
||||||
|
},
|
||||||
|
callingOptions: {},
|
||||||
|
bodyParsers: {
|
||||||
|
json: { strict: false, limit: "1MB" },
|
||||||
|
urlencoded: { extended: true, limit: "1MB" },
|
||||||
|
},
|
||||||
|
mappingPolicy: "all",
|
||||||
|
logging: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/graphql",
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "OPTIONS", "POST"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
aliases: {
|
||||||
|
"POST /": "graphql.graphql",
|
||||||
|
"GET /": "graphql.graphql",
|
||||||
|
"GET /health": "graphql.checkRemoteSchema",
|
||||||
|
},
|
||||||
|
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"))],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/logs",
|
||||||
|
use: [ApiGateway.serveStatic("logs")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
log4XXResponses: false,
|
||||||
|
logRequestParams: true,
|
||||||
|
logResponseData: true,
|
||||||
|
assets: { folder: "public", options: {} },
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
/**
|
||||||
|
* Listen for watcher disable events
|
||||||
|
*/
|
||||||
|
"IMPORT_WATCHER_DISABLED": {
|
||||||
|
async handler(ctx: Context<{ reason: string; sessionId: string }>) {
|
||||||
|
const { reason, sessionId } = ctx.params;
|
||||||
|
this.logger.info(`[Watcher] Disabled: ${reason} (session: ${sessionId})`);
|
||||||
|
|
||||||
|
// Broadcast to frontend
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "IMPORT_WATCHER_STATUS",
|
||||||
|
args: [{
|
||||||
|
enabled: false,
|
||||||
|
reason,
|
||||||
|
sessionId,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
methods: {},
|
/**
|
||||||
started(): any {
|
* Listen for watcher enable events
|
||||||
// Filewatcher
|
*/
|
||||||
const fileWatcher = chokidar.watch(
|
"IMPORT_WATCHER_ENABLED": {
|
||||||
path.resolve("/comics"),
|
async handler(ctx: Context<{ sessionId: string }>) {
|
||||||
{
|
const { sessionId } = ctx.params;
|
||||||
ignored: (filePath) =>
|
this.logger.info(`[Watcher] Re-enabled after session: ${sessionId}`);
|
||||||
path.extname(filePath) === ".dctmp",
|
|
||||||
persistent: true,
|
// Broadcast to frontend
|
||||||
usePolling: true,
|
await this.broker.call("socket.broadcast", {
|
||||||
interval: 5000,
|
namespace: "/",
|
||||||
ignoreInitial: true,
|
event: "IMPORT_WATCHER_STATUS",
|
||||||
followSymlinks: true,
|
args: [{
|
||||||
atomic: true,
|
enabled: true,
|
||||||
awaitWriteFinish: {
|
sessionId,
|
||||||
stabilityThreshold: 2000,
|
}],
|
||||||
pollInterval: 100,
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
},
|
||||||
const fileCopyDelaySeconds = 3;
|
actions: {},
|
||||||
const checkEnd = (path, prev) => {
|
methods: {},
|
||||||
fs.stat(path, async (err, stat) => {
|
started: this.startWatcher,
|
||||||
// Replace error checking with something appropriate for your app.
|
stopped: this.stopWatcher,
|
||||||
if (err) throw err;
|
});
|
||||||
if (stat.mtime.getTime() === prev.mtime.getTime()) {
|
}
|
||||||
console.log("finished");
|
|
||||||
// Move on: call whatever needs to be called to process the file.
|
|
||||||
console.log(
|
|
||||||
"File detected, starting import..."
|
|
||||||
);
|
|
||||||
const walkedFolder: IFolderData =
|
|
||||||
await broker.call("library.walkFolders", {
|
|
||||||
basePathToWalk: path,
|
|
||||||
});
|
|
||||||
await this.broker.call(
|
|
||||||
"importqueue.processImport",
|
|
||||||
{
|
|
||||||
fileObject: {
|
|
||||||
filePath: path,
|
|
||||||
fileSize: walkedFolder[0].fileSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else
|
|
||||||
setTimeout(
|
|
||||||
checkEnd,
|
|
||||||
fileCopyDelaySeconds,
|
|
||||||
path,
|
|
||||||
stat
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
fileWatcher
|
/**
|
||||||
.on("add", (path, stats) => {
|
* Initializes and starts the chokidar watcher on the COMICS_DIRECTORY.
|
||||||
console.log("Watcher detected new files.");
|
* Debounces rapid events and logs initial scan completion.
|
||||||
console.log(
|
* @private
|
||||||
`File ${path} has been added with stats: ${JSON.stringify(
|
*/
|
||||||
stats,
|
private async startWatcher(): Promise<void> {
|
||||||
null,
|
const rawDir = process.env.COMICS_DIRECTORY;
|
||||||
2
|
if (!rawDir) {
|
||||||
)}`
|
this.logger.error("COMICS_DIRECTORY not set; cannot start watcher");
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
const watchDir = path.resolve(rawDir);
|
||||||
|
this.logger.info(`Watching comics folder at: ${watchDir}`);
|
||||||
|
if (!fs.existsSync(watchDir)) {
|
||||||
|
this.logger.error(`✖ Comics folder does not exist: ${watchDir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("File", path, "has been added");
|
// Chokidar uses the best native watcher per platform:
|
||||||
|
// - macOS: FSEvents
|
||||||
|
// - Linux: inotify
|
||||||
|
// - Windows: ReadDirectoryChangesW
|
||||||
|
// Only use polling when explicitly requested (Docker, network mounts, etc.)
|
||||||
|
const forcePolling = process.env.USE_POLLING === "true";
|
||||||
|
const platform = process.platform;
|
||||||
|
const watchMode = forcePolling ? "polling" : `native (${platform})`;
|
||||||
|
|
||||||
|
this.fileWatcher = chokidar.watch(watchDir, {
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
followSymlinks: true,
|
||||||
|
depth: 10,
|
||||||
|
// Use native file watchers by default (FSEvents/inotify/ReadDirectoryChangesW)
|
||||||
|
// Fall back to polling only when explicitly requested via USE_POLLING=true
|
||||||
|
usePolling: forcePolling,
|
||||||
|
interval: forcePolling ? 1000 : undefined,
|
||||||
|
binaryInterval: forcePolling ? 1000 : undefined,
|
||||||
|
atomic: true,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
|
||||||
|
ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info(`[Watcher] Platform: ${platform}, Mode: ${watchMode}`);
|
||||||
|
|
||||||
fs.stat(path, function(err, stat) {
|
/**
|
||||||
// Replace error checking with something appropriate for your app.
|
* Returns a debounced handler for a specific path, creating one if needed.
|
||||||
if (err) throw err;
|
* Debouncing per-path prevents duplicate events for the same file while
|
||||||
setTimeout(
|
* ensuring each distinct path is always processed.
|
||||||
checkEnd,
|
*/
|
||||||
fileCopyDelaySeconds,
|
const getDebouncedForPath = (p: string) => {
|
||||||
path,
|
if (!this.debouncedHandlers.has(p)) {
|
||||||
stat
|
const fn = debounce(
|
||||||
);
|
(event: string, filePath: string, stats?: fs.Stats) => {
|
||||||
});
|
this.debouncedHandlers.delete(filePath);
|
||||||
})
|
try {
|
||||||
// .once(
|
this.handleFileEvent(event, filePath, stats);
|
||||||
// "change",
|
} catch (err) {
|
||||||
|
this.logger.error(`Error handling file event [${event}] for ${filePath}:`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
{ leading: true, trailing: true }
|
||||||
|
);
|
||||||
|
this.debouncedHandlers.set(p, fn);
|
||||||
|
}
|
||||||
|
return this.debouncedHandlers.get(p)!;
|
||||||
|
};
|
||||||
|
|
||||||
// (path, stats) =>
|
this.fileWatcher
|
||||||
// console.log(
|
.on("ready", () => this.logger.info("Initial scan complete."))
|
||||||
// `File ${path} has been changed. Stats: ${JSON.stringify(
|
.on("error", (err) => this.logger.error("Watcher error:", err))
|
||||||
// stats,
|
.on("add", (p, stats) => getDebouncedForPath(p)("add", p, stats))
|
||||||
// null,
|
.on("change", (p, stats) => getDebouncedForPath(p)("change", p, stats))
|
||||||
// 2
|
// unlink/unlinkDir fire once per path — handle immediately, no debounce needed
|
||||||
// )}`
|
.on("unlink", (p) => this.handleFileEvent("unlink", p))
|
||||||
// )
|
.on("addDir", (p) => getDebouncedForPath(p)("addDir", p))
|
||||||
// )
|
.on("unlinkDir", (p) => this.handleFileEvent("unlinkDir", p));
|
||||||
.on(
|
}
|
||||||
"unlink",
|
|
||||||
|
|
||||||
(path) =>
|
/**
|
||||||
console.log(`File ${path} has been removed`)
|
* Stops and closes the chokidar watcher, freeing resources.
|
||||||
)
|
* @private
|
||||||
.on(
|
*/
|
||||||
"addDir",
|
private async stopWatcher(): Promise<void> {
|
||||||
|
if (this.fileWatcher) {
|
||||||
|
this.logger.info("Stopping file watcher...");
|
||||||
|
await this.fileWatcher.close();
|
||||||
|
this.fileWatcher = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(path) =>
|
/**
|
||||||
console.log(`Directory ${path} has been added`)
|
* Handles a filesystem event by logging and optionally importing new files.
|
||||||
);
|
* @param event - The type of chokidar event ('add', 'change', 'unlink', etc.).
|
||||||
|
* @param filePath - The full path of the file or directory that triggered the event.
|
||||||
|
* @param stats - Optional fs.Stats data for 'add' or 'change' events.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async handleFileEvent(
|
||||||
|
event: string,
|
||||||
|
filePath: string,
|
||||||
|
stats?: fs.Stats
|
||||||
|
): Promise<void> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const isComicFile = [".cbz", ".cbr", ".cb7"].includes(ext);
|
||||||
|
|
||||||
|
this.logger.info(`[Watcher] File event [${event}]: ${filePath} (ext: ${ext}, isComic: ${isComicFile})`);
|
||||||
|
|
||||||
|
// Handle file/directory removal — mark affected comics as missing and notify frontend
|
||||||
|
if (event === "unlink" || event === "unlinkDir") {
|
||||||
|
// For unlink events, process if it's a comic file OR a directory (unlinkDir)
|
||||||
|
if (event === "unlinkDir" || isComicFile) {
|
||||||
|
this.logger.info(`[Watcher] Processing deletion for: ${filePath}`);
|
||||||
|
try {
|
||||||
|
const result: any = await this.broker.call("library.markFileAsMissing", { filePath });
|
||||||
|
this.logger.info(`[Watcher] markFileAsMissing result: marked=${result.marked}, path=${filePath}`);
|
||||||
|
if (result.marked > 0) {
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILES_MISSING",
|
||||||
|
args: [{
|
||||||
|
missingComics: result.missingComics,
|
||||||
|
triggerPath: filePath,
|
||||||
|
count: result.marked,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
this.logger.info(`[Watcher] Marked ${result.marked} comic(s) as missing for path: ${filePath}`);
|
||||||
|
} else {
|
||||||
|
this.logger.info(`[Watcher] No matching comics found in DB for deleted path: ${filePath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[Watcher] Failed to mark comics missing for ${filePath}:`, err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.info(`[Watcher] Ignoring non-comic file deletion: ${filePath}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
},
|
if (event === "add" && stats) {
|
||||||
});
|
setTimeout(async () => {
|
||||||
}
|
try {
|
||||||
|
const newStats = await fs.promises.stat(filePath);
|
||||||
|
if (newStats.mtime.getTime() === stats.mtime.getTime()) {
|
||||||
|
this.logger.info(`[Watcher] Stable file detected: ${filePath}`);
|
||||||
|
|
||||||
|
// Clear missing flag if this file was previously marked absent
|
||||||
|
await this.broker.call("library.clearFileMissingFlag", { filePath });
|
||||||
|
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILE_DETECTED",
|
||||||
|
args: [{
|
||||||
|
filePath,
|
||||||
|
fileSize: newStats.size,
|
||||||
|
extension: path.extname(filePath),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[Watcher] Error handling detected file ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
285
services/graphql.service.ts
Normal file
285
services/graphql.service.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL service for schema stitching and query execution
|
||||||
|
* @module services/graphql.service
|
||||||
|
* @description Provides unified GraphQL API by stitching local canonical metadata schema
|
||||||
|
* with remote metadata-graphql schema. Falls back to local-only if remote unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Context } from "moleculer";
|
||||||
|
import { graphql, GraphQLSchema, buildClientSchema, getIntrospectionQuery, IntrospectionQuery, print } from "graphql";
|
||||||
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||||
|
import { stitchSchemas } from "@graphql-tools/stitch";
|
||||||
|
import { fetch } from "undici";
|
||||||
|
import { typeDefs } from "../models/graphql/typedef";
|
||||||
|
import { resolvers } from "../models/graphql/resolvers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch remote GraphQL schema via introspection with timeout handling
|
||||||
|
* @param url - Remote GraphQL endpoint URL
|
||||||
|
* @param timeout - Request timeout in milliseconds (default: 10000)
|
||||||
|
* @returns Introspected GraphQL schema
|
||||||
|
*/
|
||||||
|
async function fetchRemoteSchema(url: string, timeout = 10000): Promise<GraphQLSchema> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query: getIntrospectionQuery() }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to introspect remote schema: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { data?: IntrospectionQuery; errors?: any[] };
|
||||||
|
|
||||||
|
if (result.errors?.length) throw new Error(`Introspection errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
if (!result.data) throw new Error("No data returned from introspection query");
|
||||||
|
|
||||||
|
return buildClientSchema(result.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create executor function for remote GraphQL endpoint
|
||||||
|
* @param url - Remote GraphQL endpoint URL
|
||||||
|
* @returns Executor function compatible with schema stitching
|
||||||
|
*/
|
||||||
|
function createRemoteExecutor(url: string) {
|
||||||
|
return async ({ document, variables }: any) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query: print(document), variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Remote GraphQL request failed: ${response.statusText}`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-resolve metadata if user preferences allow
|
||||||
|
* @param broker - Moleculer broker instance
|
||||||
|
* @param logger - Logger instance
|
||||||
|
* @param comicId - Comic ID to resolve metadata for
|
||||||
|
* @param condition - Preference condition to check (onImport or onMetadataUpdate)
|
||||||
|
*/
|
||||||
|
async function autoResolveMetadata(broker: any, logger: any, comicId: string, condition: string) {
|
||||||
|
try {
|
||||||
|
const UserPreferences = require("../models/userpreferences.model").default;
|
||||||
|
const preferences = await UserPreferences.findOne({ userId: "default" });
|
||||||
|
|
||||||
|
if (preferences?.autoMerge?.enabled && preferences?.autoMerge?.[condition]) {
|
||||||
|
logger.info(`Auto-resolving metadata for comic ${comicId}`);
|
||||||
|
await broker.call("graphql.graphql", {
|
||||||
|
query: `mutation ResolveMetadata($comicId: ID!) { resolveMetadata(comicId: $comicId) { id } }`,
|
||||||
|
variables: { comicId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in auto-resolution:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL Service
|
||||||
|
* @description Moleculer service providing unified GraphQL API via schema stitching.
|
||||||
|
* Stitches local canonical metadata schema with remote metadata-graphql schema.
|
||||||
|
*
|
||||||
|
* Actions:
|
||||||
|
* - graphql.graphql - Execute GraphQL queries/mutations
|
||||||
|
* - graphql.getSchema - Get schema type definitions
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* - metadata.imported - Triggers auto-resolution if enabled
|
||||||
|
* - comic.imported - Triggers auto-resolution on import if enabled
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
name: "graphql",
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
/** Remote metadata GraphQL endpoint URL */
|
||||||
|
metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql",
|
||||||
|
/** Remote acquisition GraphQL endpoint URL */
|
||||||
|
acquisitionGraphqlUrl: process.env.ACQUISITION_GRAPHQL_URL || "http://localhost:3060/acquisition-graphql",
|
||||||
|
/** Retry interval in ms for re-stitching remote schemas (0 = disabled) */
|
||||||
|
schemaRetryInterval: 5000,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Check remote schema health and availability
|
||||||
|
* @returns Status of remote schema connection with appropriate HTTP status
|
||||||
|
*/
|
||||||
|
checkRemoteSchema: {
|
||||||
|
async handler(ctx: Context<any>) {
|
||||||
|
const status: any = {
|
||||||
|
remoteSchemaAvailable: this.remoteSchemaAvailable || false,
|
||||||
|
remoteUrl: this.settings.metadataGraphqlUrl,
|
||||||
|
localSchemaOnly: !this.remoteSchemaAvailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.remoteSchemaAvailable && this.schema) {
|
||||||
|
const queryType = this.schema.getQueryType();
|
||||||
|
if (queryType) {
|
||||||
|
const fields = Object.keys(queryType.getFields());
|
||||||
|
status.availableQueryFields = fields;
|
||||||
|
status.hasWeeklyPullList = fields.includes('getWeeklyPullList');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTTP status code based on schema stitching status
|
||||||
|
// 200 = Schema stitching complete (remote available)
|
||||||
|
// 503 = Service degraded (local only, remote unavailable)
|
||||||
|
(ctx.meta as any).$statusCode = this.remoteSchemaAvailable ? 200 : 503;
|
||||||
|
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute GraphQL queries and mutations
|
||||||
|
* @param query - GraphQL query or mutation string
|
||||||
|
* @param variables - Variables for the GraphQL operation
|
||||||
|
* @param operationName - Name of the operation to execute
|
||||||
|
* @returns GraphQL execution result with data or errors
|
||||||
|
*/
|
||||||
|
graphql: {
|
||||||
|
params: {
|
||||||
|
query: { type: "string" },
|
||||||
|
variables: { type: "object", optional: true },
|
||||||
|
operationName: { type: "string", optional: true },
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{ query: string; variables?: any; operationName?: string }>) {
|
||||||
|
try {
|
||||||
|
return await graphql({
|
||||||
|
schema: this.schema,
|
||||||
|
source: ctx.params.query,
|
||||||
|
variableValues: ctx.params.variables,
|
||||||
|
operationName: ctx.params.operationName,
|
||||||
|
contextValue: { broker: this.broker, ctx },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error("GraphQL execution error:", error);
|
||||||
|
return {
|
||||||
|
errors: [{
|
||||||
|
message: error.message,
|
||||||
|
extensions: { code: "INTERNAL_SERVER_ERROR" },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GraphQL schema type definitions
|
||||||
|
* @returns Object containing schema type definitions as string
|
||||||
|
*/
|
||||||
|
getSchema: {
|
||||||
|
async handler() {
|
||||||
|
return { typeDefs: typeDefs.loc?.source.body || "" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
/**
|
||||||
|
* Handle metadata imported event - triggers auto-resolution if enabled
|
||||||
|
*/
|
||||||
|
"metadata.imported": {
|
||||||
|
async handler(ctx: any) {
|
||||||
|
const { comicId, source } = ctx.params;
|
||||||
|
this.logger.info(`Metadata imported for comic ${comicId} from ${source}`);
|
||||||
|
await autoResolveMetadata(this.broker, this.logger, comicId, "onMetadataUpdate");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle comic imported event - triggers auto-resolution if enabled
|
||||||
|
*/
|
||||||
|
"comic.imported": {
|
||||||
|
async handler(ctx: any) {
|
||||||
|
this.logger.info(`Comic imported: ${ctx.params.comicId}`);
|
||||||
|
await autoResolveMetadata(this.broker, this.logger, ctx.params.comicId, "onImport");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Attempt to build/rebuild the stitched schema.
|
||||||
|
* Returns true if at least one remote schema was stitched.
|
||||||
|
*/
|
||||||
|
async _buildSchema(localSchema: any): Promise<boolean> {
|
||||||
|
const subschemas: any[] = [{ schema: localSchema }];
|
||||||
|
|
||||||
|
// Stitch metadata schema
|
||||||
|
try {
|
||||||
|
this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`);
|
||||||
|
const metadataSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl);
|
||||||
|
subschemas.push({ schema: metadataSchema, executor: createRemoteExecutor(this.settings.metadataGraphqlUrl) });
|
||||||
|
this.logger.info("✓ Successfully introspected remote metadata schema");
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`⚠ Metadata schema unavailable: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stitch acquisition schema
|
||||||
|
try {
|
||||||
|
this.logger.info(`Attempting to introspect remote schema at ${this.settings.acquisitionGraphqlUrl}`);
|
||||||
|
const acquisitionSchema = await fetchRemoteSchema(this.settings.acquisitionGraphqlUrl);
|
||||||
|
subschemas.push({ schema: acquisitionSchema, executor: createRemoteExecutor(this.settings.acquisitionGraphqlUrl) });
|
||||||
|
this.logger.info("✓ Successfully introspected remote acquisition schema");
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`⚠ Acquisition schema unavailable: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subschemas.length > 1) {
|
||||||
|
this.schema = stitchSchemas({ subschemas, mergeTypes: true });
|
||||||
|
this.logger.info(`✓ Stitched ${subschemas.length} schemas`);
|
||||||
|
this.remoteSchemaAvailable = true;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
this.schema = localSchema;
|
||||||
|
this.remoteSchemaAvailable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service started lifecycle hook
|
||||||
|
* Blocks until remote schemas are stitched, retrying every schemaRetryInterval ms.
|
||||||
|
*/
|
||||||
|
async started() {
|
||||||
|
this.logger.info("GraphQL service starting...");
|
||||||
|
|
||||||
|
this._localSchema = makeExecutableSchema({ typeDefs, resolvers });
|
||||||
|
this.schema = this._localSchema;
|
||||||
|
this.remoteSchemaAvailable = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const stitched = await this._buildSchema(this._localSchema);
|
||||||
|
if (stitched) break;
|
||||||
|
this.logger.warn(`⚠ Remote schemas unavailable — retrying in ${this.settings.schemaRetryInterval}ms`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this.settings.schemaRetryInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("GraphQL service started successfully");
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Service stopped lifecycle hook */
|
||||||
|
stopped() {
|
||||||
|
this.logger.info("GraphQL service stopped");
|
||||||
|
},
|
||||||
|
};
|
||||||
390
services/importstate.service.ts
Normal file
390
services/importstate.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Import State Management Service
|
||||||
|
*
|
||||||
|
* Centralized service for tracking import sessions, preventing race conditions,
|
||||||
|
* and coordinating between file watcher, manual imports, and statistics updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Service, ServiceBroker, Context } from "moleculer";
|
||||||
|
import { pubClient } from "../config/redis.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import session state
|
||||||
|
*/
|
||||||
|
interface ImportSession {
|
||||||
|
sessionId: string;
|
||||||
|
type: "full" | "incremental" | "watcher";
|
||||||
|
status: "starting" | "scanning" | "queueing" | "active" | "completed" | "failed";
|
||||||
|
startedAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
stats: {
|
||||||
|
totalFiles: number;
|
||||||
|
filesQueued: number;
|
||||||
|
filesProcessed: number;
|
||||||
|
filesSucceeded: number;
|
||||||
|
filesFailed: number;
|
||||||
|
};
|
||||||
|
directoryPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ImportStateService extends Service {
|
||||||
|
private activeSessions: Map<string, ImportSession> = new Map();
|
||||||
|
private watcherEnabled: boolean = true;
|
||||||
|
|
||||||
|
public constructor(broker: ServiceBroker) {
|
||||||
|
super(broker);
|
||||||
|
this.parseServiceSchema({
|
||||||
|
name: "importstate",
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Start a new import session
|
||||||
|
*/
|
||||||
|
startSession: {
|
||||||
|
params: {
|
||||||
|
sessionId: "string",
|
||||||
|
type: { type: "enum", values: ["full", "incremental", "watcher"] },
|
||||||
|
directoryPath: { type: "string", optional: true },
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{
|
||||||
|
sessionId: string;
|
||||||
|
type: "full" | "incremental" | "watcher";
|
||||||
|
directoryPath?: string;
|
||||||
|
}>) {
|
||||||
|
const { sessionId, type, directoryPath } = ctx.params;
|
||||||
|
|
||||||
|
// Check for active sessions (prevent race conditions)
|
||||||
|
const activeSession = this.getActiveSession();
|
||||||
|
if (activeSession && type !== "watcher") {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot start ${type} import: Another import session "${activeSession.sessionId}" is already active (${activeSession.type})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If starting manual import, temporarily disable watcher
|
||||||
|
if (type !== "watcher") {
|
||||||
|
this.logger.info(`[Import State] Disabling watcher for ${type} import`);
|
||||||
|
this.watcherEnabled = false;
|
||||||
|
await this.broker.broadcast("IMPORT_WATCHER_DISABLED", {
|
||||||
|
reason: `${type} import started`,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: ImportSession = {
|
||||||
|
sessionId,
|
||||||
|
type,
|
||||||
|
status: "starting",
|
||||||
|
startedAt: new Date(),
|
||||||
|
lastActivityAt: new Date(),
|
||||||
|
stats: {
|
||||||
|
totalFiles: 0,
|
||||||
|
filesQueued: 0,
|
||||||
|
filesProcessed: 0,
|
||||||
|
filesSucceeded: 0,
|
||||||
|
filesFailed: 0,
|
||||||
|
},
|
||||||
|
directoryPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activeSessions.set(sessionId, session);
|
||||||
|
this.logger.info(`[Import State] Started session: ${sessionId} (${type})`);
|
||||||
|
|
||||||
|
// Broadcast session started
|
||||||
|
await this.broker.broadcast("IMPORT_SESSION_STARTED", {
|
||||||
|
sessionId,
|
||||||
|
type,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store in Redis for persistence
|
||||||
|
await pubClient.set(
|
||||||
|
`import:session:${sessionId}`,
|
||||||
|
JSON.stringify(session),
|
||||||
|
{ EX: 86400 } // 24 hour expiry
|
||||||
|
);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session status
|
||||||
|
*/
|
||||||
|
updateSession: {
|
||||||
|
params: {
|
||||||
|
sessionId: "string",
|
||||||
|
status: {
|
||||||
|
type: "enum",
|
||||||
|
values: ["starting", "scanning", "queueing", "active", "completed", "failed"],
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
stats: { type: "object", optional: true },
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{
|
||||||
|
sessionId: string;
|
||||||
|
status?: ImportSession["status"];
|
||||||
|
stats?: Partial<ImportSession["stats"]>;
|
||||||
|
}>) {
|
||||||
|
const { sessionId, status, stats } = ctx.params;
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
session.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats) {
|
||||||
|
session.stats = { ...session.stats, ...stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Redis
|
||||||
|
await pubClient.set(
|
||||||
|
`import:session:${sessionId}`,
|
||||||
|
JSON.stringify(session),
|
||||||
|
{ EX: 86400 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
await this.broker.broadcast("IMPORT_SESSION_UPDATED", {
|
||||||
|
sessionId,
|
||||||
|
status: session.status,
|
||||||
|
stats: session.stats,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a session
|
||||||
|
*/
|
||||||
|
completeSession: {
|
||||||
|
params: {
|
||||||
|
sessionId: "string",
|
||||||
|
success: "boolean",
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{
|
||||||
|
sessionId: string;
|
||||||
|
success: boolean;
|
||||||
|
}>) {
|
||||||
|
const { sessionId, success } = ctx.params;
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
this.logger.warn(`[Import State] Session not found: ${sessionId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = success ? "completed" : "failed";
|
||||||
|
session.completedAt = new Date();
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`[Import State] Completed session: ${sessionId} (${session.status})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-enable watcher if this was a manual import
|
||||||
|
if (session.type !== "watcher") {
|
||||||
|
this.watcherEnabled = true;
|
||||||
|
this.logger.info("[Import State] Re-enabling watcher");
|
||||||
|
await this.broker.broadcast("IMPORT_WATCHER_ENABLED", {
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast completion
|
||||||
|
await this.broker.broadcast("IMPORT_SESSION_COMPLETED", {
|
||||||
|
sessionId,
|
||||||
|
type: session.type,
|
||||||
|
success,
|
||||||
|
stats: session.stats,
|
||||||
|
duration: session.completedAt.getTime() - session.startedAt.getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Redis with final state
|
||||||
|
await pubClient.set(
|
||||||
|
`import:session:${sessionId}:final`,
|
||||||
|
JSON.stringify(session),
|
||||||
|
{ EX: 604800 } // 7 day expiry for completed sessions
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from active sessions
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current session
|
||||||
|
*/
|
||||||
|
getSession: {
|
||||||
|
params: {
|
||||||
|
sessionId: "string",
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{ sessionId: string }>) {
|
||||||
|
const { sessionId } = ctx.params;
|
||||||
|
return this.activeSessions.get(sessionId) || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active session (if any)
|
||||||
|
*/
|
||||||
|
getActiveSession: {
|
||||||
|
async handler() {
|
||||||
|
const session = this.getActiveSession();
|
||||||
|
if (session) {
|
||||||
|
// Format session for GraphQL response
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
type: session.type,
|
||||||
|
status: session.status,
|
||||||
|
startedAt: session.startedAt.toISOString(),
|
||||||
|
completedAt: session.completedAt?.toISOString() || null,
|
||||||
|
stats: {
|
||||||
|
totalFiles: session.stats.totalFiles,
|
||||||
|
filesQueued: session.stats.filesQueued,
|
||||||
|
filesProcessed: session.stats.filesProcessed,
|
||||||
|
filesSucceeded: session.stats.filesSucceeded,
|
||||||
|
filesFailed: session.stats.filesFailed,
|
||||||
|
},
|
||||||
|
directoryPath: session.directoryPath || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if watcher should process files
|
||||||
|
*/
|
||||||
|
isWatcherEnabled: {
|
||||||
|
async handler() {
|
||||||
|
return {
|
||||||
|
enabled: this.watcherEnabled,
|
||||||
|
activeSession: this.getActiveSession(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment file processed counter
|
||||||
|
*/
|
||||||
|
incrementProcessed: {
|
||||||
|
params: {
|
||||||
|
sessionId: "string",
|
||||||
|
success: "boolean",
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{
|
||||||
|
sessionId: string;
|
||||||
|
success: boolean;
|
||||||
|
}>) {
|
||||||
|
const { sessionId, success } = ctx.params;
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.stats.filesProcessed++;
|
||||||
|
session.lastActivityAt = new Date();
|
||||||
|
if (success) {
|
||||||
|
session.stats.filesSucceeded++;
|
||||||
|
} else {
|
||||||
|
session.stats.filesFailed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Redis
|
||||||
|
await pubClient.set(
|
||||||
|
`import:session:${sessionId}`,
|
||||||
|
JSON.stringify(session),
|
||||||
|
{ EX: 86400 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast progress update
|
||||||
|
await this.broker.broadcast("IMPORT_PROGRESS", {
|
||||||
|
sessionId,
|
||||||
|
stats: session.stats,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session.stats;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions
|
||||||
|
*/
|
||||||
|
getAllActiveSessions: {
|
||||||
|
async handler() {
|
||||||
|
return Array.from(this.activeSessions.values());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-clear all active sessions (e.g. after flushDB)
|
||||||
|
*/
|
||||||
|
clearActiveSessions: {
|
||||||
|
async handler() {
|
||||||
|
const cleared = Array.from(this.activeSessions.keys());
|
||||||
|
this.activeSessions.clear();
|
||||||
|
this.watcherEnabled = true;
|
||||||
|
this.logger.warn(`[Import State] Force-cleared ${cleared.length} session(s): ${cleared.join(", ")}`);
|
||||||
|
await this.broker.broadcast("IMPORT_WATCHER_ENABLED", { reason: "sessions cleared" });
|
||||||
|
return { cleared };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Get the currently active session (non-watcher)
|
||||||
|
*/
|
||||||
|
getActiveSession(): ImportSession | null {
|
||||||
|
for (const session of this.activeSessions.values()) {
|
||||||
|
if (
|
||||||
|
session.type !== "watcher" &&
|
||||||
|
["starting", "scanning", "queueing", "active"].includes(session.status)
|
||||||
|
) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
/**
|
||||||
|
* Listen for job completion events from jobqueue
|
||||||
|
*/
|
||||||
|
"JOB_COMPLETED": {
|
||||||
|
async handler(ctx: Context<{ sessionId?: string; success: boolean }>) {
|
||||||
|
const { sessionId, success } = ctx.params;
|
||||||
|
if (sessionId) {
|
||||||
|
await this.actions.incrementProcessed({ sessionId, success });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
started: async () => {
|
||||||
|
this.logger.info("[Import State] Service started");
|
||||||
|
// Auto-complete stuck sessions every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes without activity
|
||||||
|
for (const [id, session] of this.activeSessions.entries()) {
|
||||||
|
const idleMs = Date.now() - session.lastActivityAt.getTime();
|
||||||
|
if (idleMs > IDLE_TIMEOUT) {
|
||||||
|
this.logger.warn(`[Import State] Auto-expiring stuck session ${id} (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
|
this.actions.completeSession({ sessionId: id, success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,7 +191,9 @@ export default class JobQueueService extends Service {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`An error occurred processing Job ID ${ctx.locals.job.id}`
|
`An error occurred processing Job ID ${ctx.locals.job.id}:`,
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
error instanceof Error ? error.stack : ""
|
||||||
);
|
);
|
||||||
throw new MoleculerError(
|
throw new MoleculerError(
|
||||||
error,
|
error,
|
||||||
@@ -377,16 +379,37 @@ export default class JobQueueService extends Service {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Complete the active import session now that the queue is empty
|
||||||
|
try {
|
||||||
|
const activeSession = await this.broker.call("importstate.getActiveSession");
|
||||||
|
if (activeSession) {
|
||||||
|
await this.broker.call("importstate.completeSession", {
|
||||||
|
sessionId: activeSession.sessionId,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to complete import session after queue drained:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit final library statistics when queue is drained
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after queue drained:", err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async "enqueue.async.completed"(ctx: Context<{ id: Number }>) {
|
async "enqueue.async.completed"(ctx: Context<{ id: Number }>) {
|
||||||
// 1. Fetch the job result using the job Id
|
// 1. Fetch the job result using the job Id
|
||||||
const job = await this.job(ctx.params.id);
|
const job = await this.job(ctx.params.id);
|
||||||
// 2. Increment the completed job counter
|
// 2. Increment the completed job counter
|
||||||
await pubClient.incr("completedJobCount");
|
await pubClient.incr("completedJobCount");
|
||||||
// 3. Fetch the completed job count for the final payload to be sent to the client
|
// 3. Fetch the completed and total job counts for the progress payload
|
||||||
const completedJobCount = await pubClient.get(
|
const [completedJobCount, totalJobCount] = await Promise.all([
|
||||||
"completedJobCount"
|
pubClient.get("completedJobCount"),
|
||||||
);
|
pubClient.get("totalJobCount"),
|
||||||
|
]);
|
||||||
// 4. Emit the LS_COVER_EXTRACTED event with the necessary details
|
// 4. Emit the LS_COVER_EXTRACTED event with the necessary details
|
||||||
await this.broker.call("socket.broadcast", {
|
await this.broker.call("socket.broadcast", {
|
||||||
namespace: "/",
|
namespace: "/",
|
||||||
@@ -394,6 +417,7 @@ export default class JobQueueService extends Service {
|
|||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
completedJobCount,
|
completedJobCount,
|
||||||
|
totalJobCount,
|
||||||
importResult: job.returnvalue.data.importResult,
|
importResult: job.returnvalue.data.importResult,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -408,6 +432,13 @@ export default class JobQueueService extends Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Job ID ${ctx.params.id} completed.`);
|
console.log(`Job ID ${ctx.params.id} completed.`);
|
||||||
|
|
||||||
|
// 6. Emit updated library statistics after each import
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after import:", err);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async "enqueue.async.failed"(ctx) {
|
async "enqueue.async.failed"(ctx) {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import klaw from "klaw";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
|
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
|
||||||
import AirDCPPSocket from "../shared/airdcpp.socket";
|
import AirDCPPSocket from "../shared/airdcpp.socket";
|
||||||
|
import { importComicViaGraphQL } from "../utils/import.graphql.utils";
|
||||||
|
import { getImportStatistics as getImportStats } from "../utils/import.utils";
|
||||||
|
|
||||||
console.log(`MONGO -> ${process.env.MONGO_URI}`);
|
console.log(`MONGO -> ${process.env.MONGO_URI}`);
|
||||||
export default class ImportService extends Service {
|
export default class ImportService extends Service {
|
||||||
@@ -85,7 +87,7 @@ export default class ImportService extends Service {
|
|||||||
async handler(
|
async handler(
|
||||||
ctx: Context<{
|
ctx: Context<{
|
||||||
basePathToWalk: string;
|
basePathToWalk: string;
|
||||||
extensions: string[];
|
extensions?: string[];
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
console.log(ctx.params);
|
console.log(ctx.params);
|
||||||
@@ -93,7 +95,7 @@ export default class ImportService extends Service {
|
|||||||
".cbz",
|
".cbz",
|
||||||
".cbr",
|
".cbr",
|
||||||
".cb7",
|
".cb7",
|
||||||
...ctx.params.extensions,
|
...(ctx.params.extensions || []),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -174,20 +176,42 @@ export default class ImportService extends Service {
|
|||||||
try {
|
try {
|
||||||
// Get params to be passed to the import jobs
|
// Get params to be passed to the import jobs
|
||||||
const { sessionId } = ctx.params;
|
const { sessionId } = ctx.params;
|
||||||
|
const resolvedPath = path.resolve(COMICS_DIRECTORY);
|
||||||
|
|
||||||
|
// Start import session
|
||||||
|
await this.broker.call("importstate.startSession", {
|
||||||
|
sessionId,
|
||||||
|
type: "full",
|
||||||
|
directoryPath: resolvedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Walking comics directory: ${resolvedPath}`);
|
||||||
|
|
||||||
|
// Update session status
|
||||||
|
await this.broker.call("importstate.updateSession", {
|
||||||
|
sessionId,
|
||||||
|
status: "scanning",
|
||||||
|
});
|
||||||
// 1. Walk the Source folder
|
// 1. Walk the Source folder
|
||||||
klaw(path.resolve(COMICS_DIRECTORY))
|
klaw(resolvedPath)
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`Error walking directory ${resolvedPath}:`, err);
|
||||||
|
})
|
||||||
// 1.1 Filter on .cb* extensions
|
// 1.1 Filter on .cb* extensions
|
||||||
.pipe(
|
.pipe(
|
||||||
through2.obj(function (item, enc, next) {
|
through2.obj(function (item, enc, next) {
|
||||||
let fileExtension = path.extname(
|
// Only process files, not directories
|
||||||
item.path
|
if (item.stats.isFile()) {
|
||||||
);
|
let fileExtension = path.extname(
|
||||||
if (
|
item.path
|
||||||
[".cbz", ".cbr", ".cb7"].includes(
|
);
|
||||||
fileExtension
|
if (
|
||||||
)
|
[".cbz", ".cbr", ".cb7"].includes(
|
||||||
) {
|
fileExtension
|
||||||
this.push(item);
|
)
|
||||||
|
) {
|
||||||
|
this.push(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
@@ -206,16 +230,7 @@ export default class ImportService extends Service {
|
|||||||
)}`,
|
)}`,
|
||||||
});
|
});
|
||||||
if (!comicExists) {
|
if (!comicExists) {
|
||||||
// 2.1 Reset the job counters in Redis
|
// Send the extraction job to the queue
|
||||||
await pubClient.set(
|
|
||||||
"completedJobCount",
|
|
||||||
0
|
|
||||||
);
|
|
||||||
await pubClient.set(
|
|
||||||
"failedJobCount",
|
|
||||||
0
|
|
||||||
);
|
|
||||||
// 2.2 Send the extraction job to the queue
|
|
||||||
this.broker.call("jobqueue.enqueue", {
|
this.broker.call("jobqueue.enqueue", {
|
||||||
fileObject: {
|
fileObject: {
|
||||||
filePath: item.path,
|
filePath: item.path,
|
||||||
@@ -231,11 +246,322 @@ export default class ImportService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("end", () => {
|
.on("end", async () => {
|
||||||
console.log("All files traversed.");
|
console.log("All files traversed.");
|
||||||
|
// Update session to active (jobs are now being processed)
|
||||||
|
await this.broker.call("importstate.updateSession", {
|
||||||
|
sessionId,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit library statistics after scanning
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {
|
||||||
|
directoryPath: resolvedPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics:", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
// Mark session as failed
|
||||||
|
const { sessionId } = ctx.params;
|
||||||
|
if (sessionId) {
|
||||||
|
await this.broker.call("importstate.completeSession", {
|
||||||
|
sessionId,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getImportStatistics: {
|
||||||
|
rest: "POST /getImportStatistics",
|
||||||
|
timeout: 300000, // 5 minute timeout for large libraries
|
||||||
|
async handler(
|
||||||
|
ctx: Context<{
|
||||||
|
directoryPath?: string;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { directoryPath } = ctx.params;
|
||||||
|
const resolvedPath = path.resolve(directoryPath || COMICS_DIRECTORY);
|
||||||
|
console.log(`[Import Statistics] Analyzing directory: ${resolvedPath}`);
|
||||||
|
|
||||||
|
// Collect all comic files from the directory
|
||||||
|
const localFiles: string[] = [];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
klaw(resolvedPath)
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`Error walking directory ${resolvedPath}:`, err);
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
through2.obj(function (item, enc, next) {
|
||||||
|
// Only process files, not directories
|
||||||
|
if (item.stats.isFile()) {
|
||||||
|
const fileExtension = path.extname(item.path);
|
||||||
|
if ([".cbz", ".cbr", ".cb7"].includes(fileExtension)) {
|
||||||
|
localFiles.push(item.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.on("data", () => {}) // Required for stream to work
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(`[Import Statistics] Found ${localFiles.length} comic files`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get statistics by comparing with database
|
||||||
|
const stats = await getImportStats(localFiles);
|
||||||
|
const percentageImported = stats.total > 0
|
||||||
|
? ((stats.alreadyImported / stats.total) * 100).toFixed(2)
|
||||||
|
: "0.00";
|
||||||
|
|
||||||
|
// Count all comics in DB (true imported count, regardless of file presence on disk)
|
||||||
|
const alreadyImported = await Comic.countDocuments({});
|
||||||
|
|
||||||
|
// Count comics marked as missing (in DB but no longer on disk)
|
||||||
|
const missingFiles = await Comic.countDocuments({
|
||||||
|
"importStatus.isRawFileMissing": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
directory: resolvedPath,
|
||||||
|
stats: {
|
||||||
|
totalLocalFiles: stats.total,
|
||||||
|
alreadyImported,
|
||||||
|
newFiles: stats.newFiles,
|
||||||
|
missingFiles,
|
||||||
|
percentageImported: `${percentageImported}%`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Import Statistics] Error:", error);
|
||||||
|
throw new Errors.MoleculerError(
|
||||||
|
"Failed to calculate import statistics",
|
||||||
|
500,
|
||||||
|
"IMPORT_STATS_ERROR",
|
||||||
|
{ error: error.message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
incrementalImport: {
|
||||||
|
rest: "POST /incrementalImport",
|
||||||
|
timeout: 60000, // 60 second timeout
|
||||||
|
async handler(
|
||||||
|
ctx: Context<{
|
||||||
|
sessionId: string;
|
||||||
|
directoryPath?: string;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { sessionId, directoryPath } = ctx.params;
|
||||||
|
const resolvedPath = path.resolve(directoryPath || COMICS_DIRECTORY);
|
||||||
|
console.log(`[Incremental Import] Starting for directory: ${resolvedPath}`);
|
||||||
|
|
||||||
|
// Start import session
|
||||||
|
await this.broker.call("importstate.startSession", {
|
||||||
|
sessionId,
|
||||||
|
type: "incremental",
|
||||||
|
directoryPath: resolvedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit start event
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_STARTED", {
|
||||||
|
message: "Starting incremental import analysis...",
|
||||||
|
directory: resolvedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Fetch imported files from database
|
||||||
|
await this.broker.call("importstate.updateSession", {
|
||||||
|
sessionId,
|
||||||
|
status: "scanning",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
|
||||||
|
message: "Fetching imported files from database...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const importedFileNames = new Set<string>();
|
||||||
|
const comics = await Comic.find(
|
||||||
|
{ "rawFileDetails.name": { $exists: true, $ne: null } },
|
||||||
|
{ "rawFileDetails.name": 1, _id: 0 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
for (const comic of comics) {
|
||||||
|
if (comic.rawFileDetails?.name) {
|
||||||
|
importedFileNames.add(comic.rawFileDetails.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Incremental Import] Found ${importedFileNames.size} imported files in database`);
|
||||||
|
|
||||||
|
// Step 2: Scan directory for comic files
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
|
||||||
|
message: "Scanning directory for comic files...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const localFiles: Array<{ path: string; name: string; size: number }> = [];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
klaw(resolvedPath)
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`Error walking directory ${resolvedPath}:`, err);
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
through2.obj(function (item, enc, next) {
|
||||||
|
// Only process files, not directories
|
||||||
|
if (item.stats.isFile()) {
|
||||||
|
const fileExtension = path.extname(item.path);
|
||||||
|
if ([".cbz", ".cbr", ".cb7"].includes(fileExtension)) {
|
||||||
|
const fileName = path.basename(item.path, fileExtension);
|
||||||
|
localFiles.push({
|
||||||
|
path: item.path,
|
||||||
|
name: fileName,
|
||||||
|
size: item.stats.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.on("data", () => {}) // Required for stream to work
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(`[Incremental Import] Found ${localFiles.length} comic files in directory`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Filter to only new files
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
|
||||||
|
message: `Found ${localFiles.length} comic files, filtering...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newFiles = localFiles.filter(file => !importedFileNames.has(file.name));
|
||||||
|
|
||||||
|
console.log(`[Incremental Import] ${newFiles.length} new files to import`);
|
||||||
|
|
||||||
|
// Step 4: Queue new files
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
await this.broker.call("importstate.updateSession", {
|
||||||
|
sessionId,
|
||||||
|
status: "queueing",
|
||||||
|
stats: {
|
||||||
|
totalFiles: localFiles.length,
|
||||||
|
filesQueued: newFiles.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
|
||||||
|
message: `Queueing ${newFiles.length} new files for import...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset counters and set total so the UI can show a progress bar
|
||||||
|
await pubClient.set("completedJobCount", 0);
|
||||||
|
await pubClient.set("failedJobCount", 0);
|
||||||
|
await pubClient.set("totalJobCount", newFiles.length);
|
||||||
|
|
||||||
|
// Queue all new files
|
||||||
|
for (const file of newFiles) {
|
||||||
|
await this.broker.call("jobqueue.enqueue", {
|
||||||
|
fileObject: {
|
||||||
|
filePath: file.path,
|
||||||
|
fileSize: file.size,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
importType: "new",
|
||||||
|
sourcedFrom: "library",
|
||||||
|
action: "enqueue.async",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session to active
|
||||||
|
await this.broker.call("importstate.updateSession", {
|
||||||
|
sessionId,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit library statistics after queueing
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {
|
||||||
|
directoryPath: resolvedPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics:", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No files to import, complete immediately
|
||||||
|
await this.broker.call("importstate.completeSession", {
|
||||||
|
sessionId,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit library statistics even when no new files
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {
|
||||||
|
directoryPath: resolvedPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit completion event (queueing complete, not import complete)
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_COMPLETE", {
|
||||||
|
message: `Successfully queued ${newFiles.length} files for import`,
|
||||||
|
stats: {
|
||||||
|
total: localFiles.length,
|
||||||
|
alreadyImported: localFiles.length - newFiles.length,
|
||||||
|
newFiles: newFiles.length,
|
||||||
|
queued: newFiles.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: newFiles.length > 0
|
||||||
|
? `Incremental import started: ${newFiles.length} new files queued`
|
||||||
|
: "No new files to import",
|
||||||
|
stats: {
|
||||||
|
total: localFiles.length,
|
||||||
|
alreadyImported: localFiles.length - newFiles.length,
|
||||||
|
newFiles: newFiles.length,
|
||||||
|
queued: newFiles.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Incremental Import] Error:", error);
|
||||||
|
|
||||||
|
// Mark session as failed
|
||||||
|
const { sessionId } = ctx.params;
|
||||||
|
if (sessionId) {
|
||||||
|
await this.broker.call("importstate.completeSession", {
|
||||||
|
sessionId,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit error event
|
||||||
|
this.broker.broadcast("LS_INCREMENTAL_IMPORT_ERROR", {
|
||||||
|
message: error.message || "Unknown error during incremental import",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Errors.MoleculerError(
|
||||||
|
"Failed to perform incremental import",
|
||||||
|
500,
|
||||||
|
"INCREMENTAL_IMPORT_ERROR",
|
||||||
|
{ error: error.message }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -251,61 +577,119 @@ export default class ImportService extends Service {
|
|||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicvine?: any;
|
comicvine?: any;
|
||||||
locg?: {};
|
locg?: {};
|
||||||
|
comicInfo?: any;
|
||||||
|
metron?: any;
|
||||||
|
gcd?: any;
|
||||||
};
|
};
|
||||||
inferredMetadata: {
|
inferredMetadata: {
|
||||||
issue: Object;
|
issue: Object;
|
||||||
};
|
};
|
||||||
rawFileDetails: {
|
rawFileDetails: {
|
||||||
name: string;
|
name: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize?: number;
|
||||||
|
extension?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
containedIn?: string;
|
||||||
|
cover?: any;
|
||||||
};
|
};
|
||||||
wanted: {
|
wanted?: {
|
||||||
issues: [];
|
issues: [];
|
||||||
volume: { id: number };
|
volume: { id: number };
|
||||||
source: string;
|
source: string;
|
||||||
markEntireVolumeWanted: Boolean;
|
markEntireVolumeWanted: Boolean;
|
||||||
};
|
};
|
||||||
acquisition: {
|
acquisition?: {
|
||||||
directconnect: {
|
source?: {
|
||||||
|
wanted?: boolean;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
directconnect?: {
|
||||||
downloads: [];
|
downloads: [];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
importStatus?: {
|
||||||
|
isImported: boolean;
|
||||||
|
tagged: boolean;
|
||||||
|
matchedResult?: {
|
||||||
|
score: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
console.log(
|
||||||
|
"[GraphQL Import] Processing import via GraphQL..."
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(ctx.params.payload, null, 4)
|
JSON.stringify(ctx.params.payload, null, 4)
|
||||||
);
|
);
|
||||||
const { payload } = ctx.params;
|
const { payload } = ctx.params;
|
||||||
const { wanted } = payload;
|
const { wanted } = payload;
|
||||||
|
|
||||||
console.log("Saving to Mongo...");
|
// Use GraphQL import for new comics
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!wanted ||
|
!wanted ||
|
||||||
!wanted.volume ||
|
!wanted.volume ||
|
||||||
!wanted.volume.id
|
!wanted.volume.id
|
||||||
) {
|
) {
|
||||||
console.log(
|
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"
|
||||||
);
|
);
|
||||||
const newDocument = new Comic(payload); // Using the entire payload for the new document
|
|
||||||
|
|
||||||
await newDocument.save();
|
// Import via GraphQL
|
||||||
return {
|
const result = await importComicViaGraphQL(
|
||||||
success: true,
|
this.broker,
|
||||||
message:
|
{
|
||||||
"New document created due to lack of valid identifiers.",
|
filePath: payload.rawFileDetails.filePath,
|
||||||
data: newDocument,
|
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}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
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 = {
|
let condition = {
|
||||||
"wanted.volume.id": wanted.volume.id,
|
"wanted.volume.id": wanted.volume.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let update: any = {
|
let update: any = {
|
||||||
// Using 'any' to bypass strict type checks; alternatively, define a more accurate type
|
|
||||||
$set: {
|
$set: {
|
||||||
rawFileDetails: payload.rawFileDetails,
|
rawFileDetails: payload.rawFileDetails,
|
||||||
inferredMetadata: payload.inferredMetadata,
|
inferredMetadata: payload.inferredMetadata,
|
||||||
@@ -335,18 +719,45 @@ export default class ImportService extends Service {
|
|||||||
update,
|
update,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"Operation completed. Document updated or inserted:",
|
"[GraphQL Import] Document upserted:",
|
||||||
result
|
result._id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trigger canonical metadata resolution via GraphQL
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
"[GraphQL Import] Triggering metadata resolution..."
|
||||||
|
);
|
||||||
|
await this.broker.call("graphql.graphql", {
|
||||||
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Document successfully upserted.",
|
message: "Document successfully upserted.",
|
||||||
data: result,
|
data: result,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error("[GraphQL Import] Error:", error);
|
||||||
throw new Errors.MoleculerError(
|
throw new Errors.MoleculerError(
|
||||||
"Operation failed.",
|
"Operation failed.",
|
||||||
500
|
500
|
||||||
@@ -354,7 +765,154 @@ export default class ImportService extends Service {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markFileAsMissing: {
|
||||||
|
rest: "POST /markFileAsMissing",
|
||||||
|
params: {
|
||||||
|
filePath: "string",
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{ filePath: string }>) {
|
||||||
|
const { filePath } = ctx.params;
|
||||||
|
|
||||||
|
// Prefix-regex match: covers both single file and entire directory subtree
|
||||||
|
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const pathRegex = new RegExp(`^${escapedPath}`);
|
||||||
|
|
||||||
|
const affectedComics = await Comic.find(
|
||||||
|
{ "rawFileDetails.filePath": pathRegex },
|
||||||
|
{
|
||||||
|
_id: 1,
|
||||||
|
"rawFileDetails.name": 1,
|
||||||
|
"rawFileDetails.filePath": 1,
|
||||||
|
"rawFileDetails.cover": 1,
|
||||||
|
"inferredMetadata.issue.name": 1,
|
||||||
|
"inferredMetadata.issue.number": 1,
|
||||||
|
}
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
if (affectedComics.length === 0) {
|
||||||
|
return { marked: 0, missingComics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedIds = affectedComics.map((c: any) => c._id);
|
||||||
|
await Comic.updateMany(
|
||||||
|
{ _id: { $in: affectedIds } },
|
||||||
|
{ $set: { "importStatus.isRawFileMissing": true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marked: affectedComics.length,
|
||||||
|
missingComics: affectedComics,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFileMissingFlag: {
|
||||||
|
rest: "POST /clearFileMissingFlag",
|
||||||
|
params: {
|
||||||
|
filePath: "string",
|
||||||
|
},
|
||||||
|
async handler(ctx: Context<{ filePath: string }>) {
|
||||||
|
const { filePath } = ctx.params;
|
||||||
|
|
||||||
|
// First try exact path match
|
||||||
|
const byPath = await Comic.findOneAndUpdate(
|
||||||
|
{ "rawFileDetails.filePath": filePath },
|
||||||
|
{ $set: { "importStatus.isRawFileMissing": false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!byPath) {
|
||||||
|
// File was moved — match by filename and update the stored path too
|
||||||
|
const fileName = path.basename(filePath, path.extname(filePath));
|
||||||
|
await Comic.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
"rawFileDetails.name": fileName,
|
||||||
|
"importStatus.isRawFileMissing": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"importStatus.isRawFileMissing": false,
|
||||||
|
"rawFileDetails.filePath": filePath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
reconcileLibrary: {
|
||||||
|
rest: "POST /reconcileLibrary",
|
||||||
|
timeout: 120000,
|
||||||
|
async handler(ctx: Context<{ directoryPath?: string }>) {
|
||||||
|
const resolvedPath = path.resolve(
|
||||||
|
ctx.params.directoryPath || COMICS_DIRECTORY
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Collect all comic file paths currently on disk
|
||||||
|
const localPaths = new Set<string>();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
klaw(resolvedPath)
|
||||||
|
.on("error", reject)
|
||||||
|
.pipe(
|
||||||
|
through2.obj(function (item, enc, next) {
|
||||||
|
if (
|
||||||
|
item.stats.isFile() &&
|
||||||
|
[".cbz", ".cbr", ".cb7"].includes(
|
||||||
|
path.extname(item.path)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.push(item.path);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.on("data", (p: string) => localPaths.add(p))
|
||||||
|
.on("end", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get every DB record that has a stored filePath
|
||||||
|
const comics = await Comic.find(
|
||||||
|
{ "rawFileDetails.filePath": { $exists: true, $ne: null } },
|
||||||
|
{ _id: 1, "rawFileDetails.filePath": 1 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const nowMissing: any[] = [];
|
||||||
|
const nowPresent: any[] = [];
|
||||||
|
|
||||||
|
for (const comic of comics) {
|
||||||
|
const stored = comic.rawFileDetails?.filePath;
|
||||||
|
if (!stored) continue;
|
||||||
|
if (localPaths.has(stored)) {
|
||||||
|
nowPresent.push(comic._id);
|
||||||
|
} else {
|
||||||
|
nowMissing.push(comic._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply updates in bulk
|
||||||
|
if (nowMissing.length > 0) {
|
||||||
|
await Comic.updateMany(
|
||||||
|
{ _id: { $in: nowMissing } },
|
||||||
|
{ $set: { "importStatus.isRawFileMissing": true } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nowPresent.length > 0) {
|
||||||
|
await Comic.updateMany(
|
||||||
|
{ _id: { $in: nowPresent } },
|
||||||
|
{ $set: { "importStatus.isRawFileMissing": false } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scanned: localPaths.size,
|
||||||
|
markedMissing: nowMissing.length,
|
||||||
|
cleared: nowPresent.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
getComicsMarkedAsWanted: {
|
getComicsMarkedAsWanted: {
|
||||||
|
|
||||||
rest: "GET /getComicsMarkedAsWanted",
|
rest: "GET /getComicsMarkedAsWanted",
|
||||||
handler: async (ctx: Context<{}>) => {
|
handler: async (ctx: Context<{}>) => {
|
||||||
try {
|
try {
|
||||||
@@ -819,6 +1377,8 @@ export default class ImportService extends Service {
|
|||||||
rest: "POST /flushDB",
|
rest: "POST /flushDB",
|
||||||
params: {},
|
params: {},
|
||||||
handler: async (ctx: Context<{}>) => {
|
handler: async (ctx: Context<{}>) => {
|
||||||
|
// Clear any stale import sessions so subsequent imports are not blocked
|
||||||
|
await ctx.broker.call("importstate.clearActiveSessions", {});
|
||||||
return await Comic.collection
|
return await Comic.collection
|
||||||
.drop()
|
.drop()
|
||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
@@ -840,6 +1400,8 @@ export default class ImportService extends Service {
|
|||||||
"search.deleteElasticSearchIndices",
|
"search.deleteElasticSearchIndices",
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
coversFolderDeleteResult,
|
coversFolderDeleteResult,
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export default class SettingsService extends Service {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "missingFiles":
|
||||||
|
Object.assign(eSQuery, {
|
||||||
|
term: query,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
"Searching ElasticSearch index with this query -> "
|
"Searching ElasticSearch index with this query -> "
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
import { DbMixin } from "../mixins/db.mixin";
|
import { DbMixin } from "../mixins/db.mixin";
|
||||||
import Settings from "../models/settings.model";
|
import Settings from "../models/settings.model";
|
||||||
import { isEmpty, pickBy, identity, map, isNil } from "lodash";
|
import { isEmpty, pickBy, identity, map, isNil } from "lodash";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
|
||||||
const ObjectId = require("mongoose").Types.ObjectId;
|
const ObjectId = require("mongoose").Types.ObjectId;
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@@ -24,6 +27,83 @@ export default class SettingsService extends Service {
|
|||||||
settings: {},
|
settings: {},
|
||||||
hooks: {},
|
hooks: {},
|
||||||
actions: {
|
actions: {
|
||||||
|
getEnvironmentVariables: {
|
||||||
|
rest: "GET /getEnvironmentVariables",
|
||||||
|
params: {},
|
||||||
|
handler: async (ctx: Context<{}>) => {
|
||||||
|
return {
|
||||||
|
comicsDirectory: process.env.COMICS_DIRECTORY,
|
||||||
|
userdataDirectory: process.env.USERDATA_DIRECTORY,
|
||||||
|
redisURI: process.env.REDIS_URI,
|
||||||
|
elasticsearchURI: process.env.ELASTICSEARCH_URI,
|
||||||
|
mongoURI: process.env.MONGO_URI,
|
||||||
|
kafkaBroker: process.env.KAFKA_BROKER,
|
||||||
|
unrarBinPath: process.env.UNRAR_BIN_PATH,
|
||||||
|
sevenzBinPath: process.env.SEVENZ_BINARY_PATH,
|
||||||
|
comicvineAPIKey: process.env.COMICVINE_API_KEY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getDirectoryStatus: {
|
||||||
|
rest: "GET /getDirectoryStatus",
|
||||||
|
params: {},
|
||||||
|
handler: async (ctx: Context<{}>) => {
|
||||||
|
const comicsDirectoryEnvSet = !!process.env.COMICS_DIRECTORY;
|
||||||
|
const userdataDirectoryEnvSet = !!process.env.USERDATA_DIRECTORY;
|
||||||
|
|
||||||
|
const resolvedComicsDirectory = path.resolve(COMICS_DIRECTORY);
|
||||||
|
const resolvedUserdataDirectory = path.resolve(USERDATA_DIRECTORY);
|
||||||
|
|
||||||
|
let comicsDirectoryExists = false;
|
||||||
|
let userdataDirectoryExists = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.access(resolvedComicsDirectory, fs.constants.F_OK);
|
||||||
|
comicsDirectoryExists = true;
|
||||||
|
} catch {
|
||||||
|
comicsDirectoryExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.access(resolvedUserdataDirectory, fs.constants.F_OK);
|
||||||
|
userdataDirectoryExists = true;
|
||||||
|
} catch {
|
||||||
|
userdataDirectoryExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
if (!comicsDirectoryEnvSet) {
|
||||||
|
issues.push("COMICS_DIRECTORY environment variable is not set");
|
||||||
|
}
|
||||||
|
if (!userdataDirectoryEnvSet) {
|
||||||
|
issues.push("USERDATA_DIRECTORY environment variable is not set");
|
||||||
|
}
|
||||||
|
if (!comicsDirectoryExists) {
|
||||||
|
issues.push(`Comics directory does not exist: ${resolvedComicsDirectory}`);
|
||||||
|
}
|
||||||
|
if (!userdataDirectoryExists) {
|
||||||
|
issues.push(`Userdata directory does not exist: ${resolvedUserdataDirectory}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comicsDirectory: {
|
||||||
|
path: resolvedComicsDirectory,
|
||||||
|
envSet: comicsDirectoryEnvSet,
|
||||||
|
exists: comicsDirectoryExists,
|
||||||
|
isValid: comicsDirectoryEnvSet && comicsDirectoryExists,
|
||||||
|
},
|
||||||
|
userdataDirectory: {
|
||||||
|
path: resolvedUserdataDirectory,
|
||||||
|
envSet: userdataDirectoryEnvSet,
|
||||||
|
exists: userdataDirectoryExists,
|
||||||
|
isValid: userdataDirectoryEnvSet && userdataDirectoryExists,
|
||||||
|
},
|
||||||
|
isValid: comicsDirectoryEnvSet && userdataDirectoryEnvSet && comicsDirectoryExists && userdataDirectoryExists,
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
getSettings: {
|
getSettings: {
|
||||||
rest: "GET /getAllSettings",
|
rest: "GET /getAllSettings",
|
||||||
params: {},
|
params: {},
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const { MoleculerError } = require("moleculer").Errors;
|
|||||||
const SocketIOService = require("moleculer-io");
|
const SocketIOService = require("moleculer-io");
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const { v4: uuidv4 } = require("uuid");
|
||||||
import AirDCPPSocket from "../shared/airdcpp.socket";
|
import AirDCPPSocket from "../shared/airdcpp.socket";
|
||||||
|
import type { Socket as IOSocket } from "socket.io";
|
||||||
|
import { namespace } from "../moleculer.config";
|
||||||
|
|
||||||
|
// Context type carrying the Socket.IO socket in meta
|
||||||
|
type SocketCtx<P> = Context<P, { socket: IOSocket }>;
|
||||||
|
|
||||||
export default class SocketService extends Service {
|
export default class SocketService extends Service {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -33,6 +38,11 @@ export default class SocketService extends Service {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/manual": {
|
||||||
|
events: {
|
||||||
|
call: { whitelist: ["socket.*"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
adapter: createAdapter(pubClient, subClient),
|
adapter: createAdapter(pubClient, subClient),
|
||||||
@@ -62,8 +72,12 @@ export default class SocketService extends Service {
|
|||||||
|
|
||||||
if (active > 0 || paused > 0 || waiting > 0) {
|
if (active > 0 || paused > 0 || waiting > 0) {
|
||||||
// 3. Get job counts
|
// 3. Get job counts
|
||||||
const completedJobCount = await pubClient.get("completedJobCount");
|
const completedJobCount = await pubClient.get(
|
||||||
const failedJobCount = await pubClient.get("failedJobCount");
|
"completedJobCount"
|
||||||
|
);
|
||||||
|
const failedJobCount = await pubClient.get(
|
||||||
|
"failedJobCount"
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Send the counts to the active socket.io session
|
// 4. Send the counts to the active socket.io session
|
||||||
await this.broker.call("socket.broadcast", {
|
await this.broker.call("socket.broadcast", {
|
||||||
@@ -80,9 +94,14 @@ export default class SocketService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new MoleculerError(err, 500, "SESSION_ID_NOT_FOUND", {
|
throw new MoleculerError(
|
||||||
data: sessionId,
|
err,
|
||||||
});
|
500,
|
||||||
|
"SESSION_ID_NOT_FOUND",
|
||||||
|
{
|
||||||
|
data: sessionId,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -93,7 +112,11 @@ export default class SocketService extends Service {
|
|||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { queueAction } = ctx.params;
|
const { queueAction } = ctx.params;
|
||||||
await this.broker.call("jobqueue.toggle", { action: queueAction }, {});
|
await this.broker.call(
|
||||||
|
"jobqueue.toggle",
|
||||||
|
{ action: queueAction },
|
||||||
|
{}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
importSingleIssue: async (ctx: Context<{}>) => {
|
importSingleIssue: async (ctx: Context<{}>) => {
|
||||||
console.info("AirDC++ finished a download -> ");
|
console.info("AirDC++ finished a download -> ");
|
||||||
@@ -116,7 +139,10 @@ export default class SocketService extends Service {
|
|||||||
const ADCPPSocket = new AirDCPPSocket(config);
|
const ADCPPSocket = new AirDCPPSocket(config);
|
||||||
try {
|
try {
|
||||||
await ADCPPSocket.connect();
|
await ADCPPSocket.connect();
|
||||||
const instance = await ADCPPSocket.post("search", query);
|
const instance = await ADCPPSocket.post(
|
||||||
|
"search",
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
// Send the instance to the client
|
// Send the instance to the client
|
||||||
await namespacedInstance.emit("searchInitiated", {
|
await namespacedInstance.emit("searchInitiated", {
|
||||||
@@ -128,8 +154,13 @@ export default class SocketService extends Service {
|
|||||||
`search`,
|
`search`,
|
||||||
`search_result_added`,
|
`search_result_added`,
|
||||||
(groupedResult) => {
|
(groupedResult) => {
|
||||||
console.log(JSON.stringify(groupedResult, null, 4));
|
console.log(
|
||||||
namespacedInstance.emit("searchResultAdded", groupedResult);
|
JSON.stringify(groupedResult, null, 4)
|
||||||
|
);
|
||||||
|
namespacedInstance.emit(
|
||||||
|
"searchResultAdded",
|
||||||
|
groupedResult
|
||||||
|
);
|
||||||
},
|
},
|
||||||
instance.id
|
instance.id
|
||||||
);
|
);
|
||||||
@@ -138,7 +169,10 @@ export default class SocketService extends Service {
|
|||||||
`search`,
|
`search`,
|
||||||
`search_result_updated`,
|
`search_result_updated`,
|
||||||
(updatedResult) => {
|
(updatedResult) => {
|
||||||
namespacedInstance.emit("searchResultUpdated", updatedResult);
|
namespacedInstance.emit(
|
||||||
|
"searchResultUpdated",
|
||||||
|
updatedResult
|
||||||
|
);
|
||||||
},
|
},
|
||||||
instance.id
|
instance.id
|
||||||
);
|
);
|
||||||
@@ -148,30 +182,49 @@ export default class SocketService extends Service {
|
|||||||
`search_hub_searches_sent`,
|
`search_hub_searches_sent`,
|
||||||
async (searchInfo) => {
|
async (searchInfo) => {
|
||||||
await this.sleep(5000);
|
await this.sleep(5000);
|
||||||
const currentInstance = await ADCPPSocket.get(
|
const currentInstance =
|
||||||
`search/${instance.id}`
|
await ADCPPSocket.get(
|
||||||
);
|
`search/${instance.id}`
|
||||||
|
);
|
||||||
// Send the instance to the client
|
// Send the instance to the client
|
||||||
await namespacedInstance.emit("searchesSent", {
|
await namespacedInstance.emit(
|
||||||
searchInfo,
|
"searchesSent",
|
||||||
});
|
{
|
||||||
|
searchInfo,
|
||||||
|
}
|
||||||
|
);
|
||||||
if (currentInstance.result_count === 0) {
|
if (currentInstance.result_count === 0) {
|
||||||
console.log("No more search results.");
|
console.log("No more search results.");
|
||||||
namespacedInstance.emit("searchComplete", {
|
namespacedInstance.emit(
|
||||||
message: "No more search results.",
|
"searchComplete",
|
||||||
});
|
{
|
||||||
|
message:
|
||||||
|
"No more search results.",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
instance.id
|
instance.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Perform the actual search
|
// Perform the actual search
|
||||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, query);
|
await ADCPPSocket.post(
|
||||||
|
`search/${instance.id}/hub_search`,
|
||||||
|
query
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await namespacedInstance.emit("searchError", error.message);
|
await namespacedInstance.emit(
|
||||||
throw new MoleculerError("Search failed", 500, "SEARCH_FAILED", {
|
"searchError",
|
||||||
error,
|
error.message
|
||||||
});
|
);
|
||||||
|
throw new MoleculerError(
|
||||||
|
"Search failed",
|
||||||
|
500,
|
||||||
|
"SEARCH_FAILED",
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// await ADCPPSocket.disconnect();
|
// await ADCPPSocket.disconnect();
|
||||||
}
|
}
|
||||||
@@ -222,7 +275,10 @@ export default class SocketService extends Service {
|
|||||||
"Download and metadata update successful",
|
"Download and metadata update successful",
|
||||||
bundleDBImportResult
|
bundleDBImportResult
|
||||||
);
|
);
|
||||||
this.broker.emit("downloadCompleted", bundleDBImportResult);
|
this.broker.emit(
|
||||||
|
"downloadCompleted",
|
||||||
|
bundleDBImportResult
|
||||||
|
);
|
||||||
return bundleDBImportResult;
|
return bundleDBImportResult;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -231,49 +287,203 @@ export default class SocketService extends Service {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.broker.emit("downloadError", error.message);
|
this.broker.emit("downloadError", error.message);
|
||||||
throw new MoleculerError("Download failed", 500, "DOWNLOAD_FAILED", {
|
throw new MoleculerError(
|
||||||
error,
|
"Download failed",
|
||||||
});
|
500,
|
||||||
|
"DOWNLOAD_FAILED",
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// await ADCPPSocket.disconnect();
|
// await ADCPPSocket.disconnect();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
listenBundleTick: {
|
/**
|
||||||
async handler(ctx) {
|
* Compute and broadcast current library statistics to all connected Socket.IO clients.
|
||||||
const { config } = ctx.params;
|
* Called after every filesystem event (add, unlink, etc.) to keep the UI in sync.
|
||||||
|
* Emits a single `LS_LIBRARY_STATS` event with totalLocalFiles, alreadyImported,
|
||||||
|
* newFiles, missingFiles, and percentageImported.
|
||||||
|
*/
|
||||||
|
broadcastLibraryStatistics: async (ctx: Context<{ directoryPath?: string }>) => {
|
||||||
|
try {
|
||||||
|
const result: any = await this.broker.call("library.getImportStatistics", {
|
||||||
|
directoryPath: ctx.params?.directoryPath,
|
||||||
|
});
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_LIBRARY_STATS",
|
||||||
|
args: [result],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error("[Socket] broadcastLibraryStatistics failed:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listenFileProgress: {
|
||||||
|
params: { config: "object", namespace: "string" },
|
||||||
|
async handler(
|
||||||
|
ctx: SocketCtx<{ config: any; namespace: string }>
|
||||||
|
) {
|
||||||
|
const { config, namespace } = ctx.params;
|
||||||
|
const namespacedInstance = this.io.of(namespace || "/");
|
||||||
const ADCPPSocket = new AirDCPPSocket(config);
|
const ADCPPSocket = new AirDCPPSocket(config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Connect once
|
||||||
await ADCPPSocket.connect();
|
await ADCPPSocket.connect();
|
||||||
console.log("Connected to AirDCPP successfully.");
|
await ADCPPSocket.addListener(
|
||||||
|
|
||||||
ADCPPSocket.addListener(
|
|
||||||
"queue",
|
"queue",
|
||||||
"queue_bundle_tick",
|
"queue_bundle_tick",
|
||||||
(tickData) => {
|
async (data) => {
|
||||||
console.log("Received tick data: ", tickData);
|
console.log(
|
||||||
this.io.emit("bundleTickUpdate", tickData);
|
`is mulk ne har shakz ko jo kaam tha saupa \nus shakz ne us kaam ki maachis jala di`
|
||||||
},
|
);
|
||||||
null
|
namespacedInstance.emit("downloadTick", data)
|
||||||
); // Assuming no specific ID is needed here
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"Error connecting to AirDCPP or setting listener:",
|
|
||||||
error
|
|
||||||
);
|
);
|
||||||
throw error;
|
} catch {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
// File watcher events - forward to Socket.IO clients
|
||||||
|
async "add"(ctx: Context<{ path: string }>) {
|
||||||
|
console.log(`[File Watcher] File added: ${ctx.params.path}`);
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILE_ADDED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
path: ctx.params.path,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit updated statistics after file addition
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after file add:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async "unlink"(ctx: Context<{ path: string }>) {
|
||||||
|
console.log(`[File Watcher] File removed: ${ctx.params.path}`);
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILE_REMOVED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
path: ctx.params.path,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit updated statistics after file removal
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after file remove:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async "addDir"(ctx: Context<{ path: string }>) {
|
||||||
|
console.log(`[File Watcher] Directory added: ${ctx.params.path}`);
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_DIRECTORY_ADDED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
path: ctx.params.path,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit updated statistics after directory addition
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after directory add:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async "unlinkDir"(ctx: Context<{ path: string }>) {
|
||||||
|
console.log(`[File Watcher] Directory removed: ${ctx.params.path}`);
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_DIRECTORY_REMOVED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
path: ctx.params.path,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit updated statistics after directory removal
|
||||||
|
try {
|
||||||
|
await this.broker.call("socket.broadcastLibraryStatistics", {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to emit library statistics after directory remove:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async "change"(ctx: Context<{ path: string }>) {
|
||||||
|
console.log(`[File Watcher] File changed: ${ctx.params.path}`);
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILE_CHANGED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
path: ctx.params.path,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
sleep: (ms: number): Promise<NodeJS.Timeout> => {
|
sleep: (ms: number): Promise<NodeJS.Timeout> => {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
},
|
},
|
||||||
|
handleSocketConnection: async function (socket: any) {
|
||||||
|
this.logger.info(
|
||||||
|
`Socket connected with session ID: ${socket.id}`
|
||||||
|
);
|
||||||
|
console.log("Looking up sessionId in Mongo...");
|
||||||
|
|
||||||
|
const sessionIdExists = await Session.find({
|
||||||
|
sessionId: socket.handshake.query.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionIdExists.length === 0) {
|
||||||
|
console.log(
|
||||||
|
`Socket Id ${socket.id} not found in Mongo, creating a new session...`
|
||||||
|
);
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
socket.sessionId = sessionId;
|
||||||
|
console.log(`Saving session ${sessionId} to Mongo...`);
|
||||||
|
await Session.create({
|
||||||
|
sessionId,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
socket.emit("sessionInitialized", sessionId);
|
||||||
|
} else {
|
||||||
|
console.log(`Found socketId ${socket.id}, no-op.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async started() {
|
async started() {
|
||||||
|
this.io.of("/manual").on("connection", async (socket) => {
|
||||||
|
console.log(
|
||||||
|
`socket.io server connected to /manual namespace`
|
||||||
|
);
|
||||||
|
});
|
||||||
this.io.on("connection", async (socket) => {
|
this.io.on("connection", async (socket) => {
|
||||||
console.log(
|
console.log(
|
||||||
`socket.io server connected to client with session ID: ${socket.id}`
|
`socket.io server connected to client with session ID: ${socket.id}`
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
const WebSocket = require("ws");
|
import WebSocket from "ws";
|
||||||
const { Socket } = require("airdcpp-apisocket");
|
// const { Socket } = require("airdcpp-apisocket");
|
||||||
|
import { Socket } from "airdcpp-apisocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around the AirDC++ WebSocket API socket.
|
||||||
|
* Provides methods to connect, disconnect, and interact with the AirDC++ API.
|
||||||
|
*/
|
||||||
class AirDCPPSocket {
|
class AirDCPPSocket {
|
||||||
// Explicitly declare properties
|
/**
|
||||||
options; // Holds configuration options
|
* Configuration options for the underlying socket.
|
||||||
socketInstance; // Instance of the AirDCPP Socket
|
* @private
|
||||||
|
*/
|
||||||
|
private options: {
|
||||||
|
url: string;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
reconnectInterval: number;
|
||||||
|
logLevel: string;
|
||||||
|
ignoredListenerEvents: string[];
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(configuration: any) {
|
/**
|
||||||
let socketProtocol = configuration.protocol === "https" ? "wss" : "ws";
|
* Instance of the AirDC++ API socket.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private socketInstance: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AirDCPPSocket wrapper.
|
||||||
|
* @param {{ protocol: string; hostname: string; username: string; password: string }} configuration
|
||||||
|
* Connection configuration: protocol (ws or wss), hostname, username, and password.
|
||||||
|
*/
|
||||||
|
constructor(configuration: {
|
||||||
|
protocol: string;
|
||||||
|
hostname: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) {
|
||||||
|
const socketProtocol =
|
||||||
|
configuration.protocol === "https" ? "wss" : "ws";
|
||||||
this.options = {
|
this.options = {
|
||||||
url: `${socketProtocol}://${configuration.hostname}/api/v1/`,
|
url: `${socketProtocol}://${configuration.hostname}/api/v1/`,
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
reconnectInterval: 5000, // milliseconds
|
reconnectInterval: 5000,
|
||||||
logLevel: "verbose",
|
logLevel: "verbose",
|
||||||
ignoredListenerEvents: [
|
ignoredListenerEvents: [
|
||||||
"transfer_statistics",
|
"transfer_statistics",
|
||||||
@@ -21,25 +53,33 @@ class AirDCPPSocket {
|
|||||||
username: configuration.username,
|
username: configuration.username,
|
||||||
password: configuration.password,
|
password: configuration.password,
|
||||||
};
|
};
|
||||||
// Initialize the socket instance using the configured options and WebSocket
|
// Initialize the AirDC++ socket instance
|
||||||
this.socketInstance = Socket(this.options, WebSocket);
|
this.socketInstance = Socket(this.options, WebSocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to ensure the socket connection is established if required by the library or implementation logic
|
/**
|
||||||
async connect() {
|
* Establishes a connection to the AirDC++ server.
|
||||||
// Here we'll check if a connect method exists and call it
|
* @async
|
||||||
|
* @returns {Promise<any>} Session information returned by the server.
|
||||||
|
*/
|
||||||
|
async connect(): Promise<any> {
|
||||||
if (
|
if (
|
||||||
this.socketInstance &&
|
this.socketInstance &&
|
||||||
typeof this.socketInstance.connect === "function"
|
typeof this.socketInstance.connect === "function"
|
||||||
) {
|
) {
|
||||||
const sessionInformation = await this.socketInstance.connect();
|
return await this.socketInstance.connect();
|
||||||
return sessionInformation;
|
|
||||||
}
|
}
|
||||||
|
return Promise.reject(
|
||||||
|
new Error("Connect method not available on socket instance")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to ensure the socket is disconnected properly if required by the library or implementation logic
|
/**
|
||||||
async disconnect() {
|
* Disconnects from the AirDC++ server.
|
||||||
// Similarly, check if a disconnect method exists and call it
|
* @async
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
if (
|
if (
|
||||||
this.socketInstance &&
|
this.socketInstance &&
|
||||||
typeof this.socketInstance.disconnect === "function"
|
typeof this.socketInstance.disconnect === "function"
|
||||||
@@ -48,19 +88,43 @@ class AirDCPPSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to post data to an endpoint
|
/**
|
||||||
async post(endpoint: any, data: any = {}) {
|
* Sends a POST request to a specific AirDC++ endpoint.
|
||||||
// Call post on the socket instance, assuming post is a valid method of the socket instance
|
* @async
|
||||||
|
* @param {string} endpoint - API endpoint path (e.g., "search").
|
||||||
|
* @param {object} [data={}] - Payload to send with the request.
|
||||||
|
* @returns {Promise<any>} Response from the AirDC++ server.
|
||||||
|
*/
|
||||||
|
async post(endpoint: string, data: object = {}): Promise<any> {
|
||||||
return await this.socketInstance.post(endpoint, data);
|
return await this.socketInstance.post(endpoint, data);
|
||||||
}
|
}
|
||||||
async get(endpoint: any, data: any = {}) {
|
|
||||||
// Call post on the socket instance, assuming post is a valid method of the socket instance
|
/**
|
||||||
|
* Sends a GET request to a specific AirDC++ endpoint.
|
||||||
|
* @async
|
||||||
|
* @param {string} endpoint - API endpoint path (e.g., "search/123").
|
||||||
|
* @param {object} [data={}] - Query parameters to include.
|
||||||
|
* @returns {Promise<any>} Response from the AirDC++ server.
|
||||||
|
*/
|
||||||
|
async get(endpoint: string, data: object = {}): Promise<any> {
|
||||||
return await this.socketInstance.get(endpoint, data);
|
return await this.socketInstance.get(endpoint, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to add listeners to the socket instance for handling real-time updates or events
|
/**
|
||||||
async addListener(event: any, handlerName: any, callback: any, id: any) {
|
* Adds an event listener to the AirDC++ socket.
|
||||||
// Attach a listener to the socket instance
|
* @async
|
||||||
|
* @param {string} event - Event group (e.g., "search" or "queue").
|
||||||
|
* @param {string} handlerName - Specific event within the group (e.g., "search_result_added").
|
||||||
|
* @param {Function} callback - Callback to invoke when the event occurs.
|
||||||
|
* @param {string|number} [id] - Optional identifier (e.g., search instance ID).
|
||||||
|
* @returns {Promise<any>} Listener registration result.
|
||||||
|
*/
|
||||||
|
async addListener(
|
||||||
|
event: string,
|
||||||
|
handlerName: string,
|
||||||
|
callback: (...args: any[]) => void,
|
||||||
|
id?: string | number
|
||||||
|
): Promise<any> {
|
||||||
return await this.socketInstance.addListener(
|
return await this.socketInstance.addListener(
|
||||||
event,
|
event,
|
||||||
handlerName,
|
handlerName,
|
||||||
|
|||||||
560
tests/e2e/file-watcher.spec.ts
Normal file
560
tests/e2e/file-watcher.spec.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests for the file watcher functionality
|
||||||
|
*
|
||||||
|
* Tests the chokidar-based file watcher in api.service.ts
|
||||||
|
* including file addition, removal, directory operations,
|
||||||
|
* debouncing, and watcher enable/disable coordination.
|
||||||
|
*
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
jest,
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeAll,
|
||||||
|
afterAll,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
} from "@jest/globals";
|
||||||
|
import { ServiceBroker } from "moleculer";
|
||||||
|
import chokidar from "chokidar";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import {
|
||||||
|
createTempDir,
|
||||||
|
removeTempDir,
|
||||||
|
createMockComicFile,
|
||||||
|
createNonComicFile,
|
||||||
|
createSubDir,
|
||||||
|
deleteFile,
|
||||||
|
deleteDir,
|
||||||
|
sleep,
|
||||||
|
waitForCondition,
|
||||||
|
touchFile,
|
||||||
|
} from "../utils/test-helpers";
|
||||||
|
import {
|
||||||
|
MockBrokerWrapper,
|
||||||
|
setupMockBroker,
|
||||||
|
teardownMockBroker,
|
||||||
|
} from "../utils/mock-services";
|
||||||
|
|
||||||
|
// Increase timeout for file system operations
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal file watcher similar to api.service.ts
|
||||||
|
* but testable in isolation
|
||||||
|
*/
|
||||||
|
class TestableFileWatcher {
|
||||||
|
private fileWatcher?: any; // Use any to avoid chokidar type issues
|
||||||
|
private debouncedHandlers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||||
|
public broker: ServiceBroker;
|
||||||
|
private watchDir: string;
|
||||||
|
|
||||||
|
constructor(broker: ServiceBroker, watchDir: string) {
|
||||||
|
this.broker = broker;
|
||||||
|
this.watchDir = watchDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (!fs.existsSync(this.watchDir)) {
|
||||||
|
throw new Error(`Watch directory does not exist: ${this.watchDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fileWatcher = chokidar.watch(this.watchDir, {
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
followSymlinks: true,
|
||||||
|
depth: 10,
|
||||||
|
usePolling: true, // Use polling for consistent test behavior
|
||||||
|
interval: 100,
|
||||||
|
binaryInterval: 100,
|
||||||
|
atomic: true,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }, // Shorter for tests
|
||||||
|
ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDebouncedForPath = (p: string) => {
|
||||||
|
if (this.debouncedHandlers.has(p)) {
|
||||||
|
clearTimeout(this.debouncedHandlers.get(p)!);
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.debouncedHandlers.delete(p);
|
||||||
|
}, 200);
|
||||||
|
this.debouncedHandlers.set(p, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.fileWatcher
|
||||||
|
.on("ready", () => console.log("Watcher ready"))
|
||||||
|
.on("error", (err) => console.error("Watcher error:", err))
|
||||||
|
.on("add", async (p, stats) => {
|
||||||
|
getDebouncedForPath(p);
|
||||||
|
await this.handleFileEvent("add", p, stats);
|
||||||
|
})
|
||||||
|
.on("change", async (p, stats) => {
|
||||||
|
getDebouncedForPath(p);
|
||||||
|
await this.handleFileEvent("change", p, stats);
|
||||||
|
})
|
||||||
|
.on("unlink", async (p) => {
|
||||||
|
await this.handleFileEvent("unlink", p);
|
||||||
|
})
|
||||||
|
.on("addDir", async (p) => {
|
||||||
|
getDebouncedForPath(p);
|
||||||
|
await this.handleFileEvent("addDir", p);
|
||||||
|
})
|
||||||
|
.on("unlinkDir", async (p) => {
|
||||||
|
await this.handleFileEvent("unlinkDir", p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.fileWatcher) {
|
||||||
|
await this.fileWatcher.close();
|
||||||
|
this.fileWatcher = undefined;
|
||||||
|
}
|
||||||
|
// Clear all pending debounced handlers
|
||||||
|
for (const timeout of this.debouncedHandlers.values()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
this.debouncedHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFileEvent(
|
||||||
|
event: string,
|
||||||
|
filePath: string,
|
||||||
|
stats?: fs.Stats
|
||||||
|
): Promise<void> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const isComicFile = [".cbz", ".cbr", ".cb7"].includes(ext);
|
||||||
|
|
||||||
|
// Handle file/directory removal
|
||||||
|
if (event === "unlink" || event === "unlinkDir") {
|
||||||
|
if (event === "unlinkDir" || isComicFile) {
|
||||||
|
try {
|
||||||
|
const result: any = await this.broker.call("library.markFileAsMissing", { filePath });
|
||||||
|
if (result.marked > 0) {
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILES_MISSING",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
missingComics: result.missingComics,
|
||||||
|
triggerPath: filePath,
|
||||||
|
count: result.marked,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to mark comics missing for ${filePath}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === "add" && stats && isComicFile) {
|
||||||
|
// Simulate stability check with shorter delay for tests
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const newStats = await fs.promises.stat(filePath);
|
||||||
|
if (newStats.mtime.getTime() === stats.mtime.getTime()) {
|
||||||
|
// Clear missing flag if this file was previously marked absent
|
||||||
|
await this.broker.call("library.clearFileMissingFlag", { filePath });
|
||||||
|
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "LS_FILE_DETECTED",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
fileSize: newStats.size,
|
||||||
|
extension: path.extname(filePath),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling detected file ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}, 500); // Shorter stability check for tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("File Watcher E2E Tests", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let mockBroker: MockBrokerWrapper;
|
||||||
|
let fileWatcher: TestableFileWatcher;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create temp directory for all tests
|
||||||
|
tempDir = await createTempDir("file-watcher-test-");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up temp directory
|
||||||
|
await removeTempDir(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Set up mock broker before each test
|
||||||
|
mockBroker = await setupMockBroker();
|
||||||
|
|
||||||
|
// Create file watcher with mock broker
|
||||||
|
fileWatcher = new TestableFileWatcher(mockBroker.broker, tempDir);
|
||||||
|
await fileWatcher.start();
|
||||||
|
|
||||||
|
// Wait for watcher to be ready
|
||||||
|
await sleep(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop file watcher
|
||||||
|
await fileWatcher.stop();
|
||||||
|
|
||||||
|
// Tear down mock broker
|
||||||
|
await teardownMockBroker(mockBroker);
|
||||||
|
|
||||||
|
// Clean up any files created during test
|
||||||
|
const files = await fs.promises.readdir(tempDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(tempDir, file);
|
||||||
|
const stat = await fs.promises.stat(filePath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await deleteDir(filePath);
|
||||||
|
} else {
|
||||||
|
await deleteFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File Addition Detection", () => {
|
||||||
|
it("should detect new .cbz file and emit LS_FILE_DETECTED", async () => {
|
||||||
|
// Create a new comic file
|
||||||
|
const filePath = await createMockComicFile(tempDir, "test-comic-1", ".cbz");
|
||||||
|
|
||||||
|
// Wait for the file to be detected (stability check + processing)
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
expect(detected!.args[0]).toMatchObject({
|
||||||
|
filePath,
|
||||||
|
extension: ".cbz",
|
||||||
|
});
|
||||||
|
expect(detected!.args[0].fileSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect new .cbr file", async () => {
|
||||||
|
const filePath = await createMockComicFile(tempDir, "test-comic-2", ".cbr");
|
||||||
|
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
expect(detected!.args[0].extension).toBe(".cbr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect new .cb7 file", async () => {
|
||||||
|
const filePath = await createMockComicFile(tempDir, "test-comic-3", ".cb7");
|
||||||
|
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
expect(detected!.args[0].extension).toBe(".cb7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call clearFileMissingFlag when file is added", async () => {
|
||||||
|
const filePath = await createMockComicFile(tempDir, "restored-comic", ".cbz");
|
||||||
|
|
||||||
|
await waitForCondition(
|
||||||
|
() => mockBroker.wasCalled("library.clearFileMissingFlag"),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = mockBroker.getCallsTo("library.clearFileMissingFlag");
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
expect(calls[0].params.filePath).toBe(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit LS_FILE_DETECTED for non-comic files", async () => {
|
||||||
|
await createNonComicFile(tempDir, "readme.txt", "test content");
|
||||||
|
|
||||||
|
// Wait a bit for potential events
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const detected = mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED");
|
||||||
|
expect(detected.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File Removal Detection", () => {
|
||||||
|
it("should detect deleted .cbz file and call markFileAsMissing", async () => {
|
||||||
|
// First, create a file
|
||||||
|
const filePath = await createMockComicFile(tempDir, "delete-test", ".cbz");
|
||||||
|
|
||||||
|
// Wait for it to be detected
|
||||||
|
await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
mockBroker.eventCapturer.clear();
|
||||||
|
mockBroker.clearCalls();
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
await deleteFile(filePath);
|
||||||
|
|
||||||
|
// Wait for deletion to be processed
|
||||||
|
await waitForCondition(
|
||||||
|
() => mockBroker.wasCalled("library.markFileAsMissing"),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = mockBroker.getCallsTo("library.markFileAsMissing");
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
expect(calls[0].params.filePath).toBe(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit LS_FILES_MISSING when comic file is deleted", async () => {
|
||||||
|
const filePath = await createMockComicFile(tempDir, "missing-test", ".cbz");
|
||||||
|
|
||||||
|
await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
mockBroker.eventCapturer.clear();
|
||||||
|
|
||||||
|
await deleteFile(filePath);
|
||||||
|
|
||||||
|
const missingEvent = await mockBroker.eventCapturer.waitForEvent("LS_FILES_MISSING", 5000);
|
||||||
|
|
||||||
|
expect(missingEvent).not.toBeNull();
|
||||||
|
expect(missingEvent!.args[0]).toMatchObject({
|
||||||
|
triggerPath: filePath,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore non-comic file deletions", async () => {
|
||||||
|
const filePath = await createNonComicFile(tempDir, "delete-me.txt", "content");
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
mockBroker.clearCalls();
|
||||||
|
|
||||||
|
await deleteFile(filePath);
|
||||||
|
|
||||||
|
// Wait a bit for potential events
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const calls = mockBroker.getCallsTo("library.markFileAsMissing");
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Directory Deletion Cascade", () => {
|
||||||
|
it("should mark all comics in deleted directory as missing", async () => {
|
||||||
|
// Create a subdirectory with comics
|
||||||
|
const subDir = await createSubDir(tempDir, "series-folder");
|
||||||
|
await createMockComicFile(subDir, "issue-001", ".cbz");
|
||||||
|
await createMockComicFile(subDir, "issue-002", ".cbz");
|
||||||
|
|
||||||
|
// Wait for files to be detected
|
||||||
|
await waitForCondition(
|
||||||
|
() => mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED").length >= 2,
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
mockBroker.eventCapturer.clear();
|
||||||
|
mockBroker.clearCalls();
|
||||||
|
|
||||||
|
// Delete the directory
|
||||||
|
await deleteDir(subDir);
|
||||||
|
|
||||||
|
// Wait for unlinkDir to be processed
|
||||||
|
await waitForCondition(
|
||||||
|
() => mockBroker.wasCalled("library.markFileAsMissing"),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = mockBroker.getCallsTo("library.markFileAsMissing");
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
// The call should be made with the directory path
|
||||||
|
expect(calls[0].params.filePath).toBe(subDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit LS_FILES_MISSING for directory deletion", async () => {
|
||||||
|
const subDir = await createSubDir(tempDir, "delete-dir-test");
|
||||||
|
await createMockComicFile(subDir, "comic-in-dir", ".cbz");
|
||||||
|
|
||||||
|
await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
mockBroker.eventCapturer.clear();
|
||||||
|
|
||||||
|
await deleteDir(subDir);
|
||||||
|
|
||||||
|
const missingEvent = await mockBroker.eventCapturer.waitForEvent("LS_FILES_MISSING", 5000);
|
||||||
|
|
||||||
|
expect(missingEvent).not.toBeNull();
|
||||||
|
expect(missingEvent!.args[0].triggerPath).toBe(subDir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File Filtering", () => {
|
||||||
|
it("should ignore .dctmp files", async () => {
|
||||||
|
await createNonComicFile(tempDir, "temp-download.dctmp", "partial data");
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const detected = mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED");
|
||||||
|
expect(detected.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore files in .git directory", async () => {
|
||||||
|
const gitDir = await createSubDir(tempDir, ".git");
|
||||||
|
await createMockComicFile(gitDir, "config", ".cbz");
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const detected = mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED");
|
||||||
|
expect(detected.length).toBe(0);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await deleteDir(gitDir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Debounce Functionality", () => {
|
||||||
|
it("should handle rapid file modifications", async () => {
|
||||||
|
// Create a file
|
||||||
|
const filePath = await createMockComicFile(tempDir, "debounce-test", ".cbz");
|
||||||
|
|
||||||
|
// Wait for initial detection
|
||||||
|
await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
mockBroker.eventCapturer.clear();
|
||||||
|
|
||||||
|
// Rapidly touch the file multiple times
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await touchFile(filePath);
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
// The debouncing should prevent multiple rapid events
|
||||||
|
// Note: change events may or may not fire depending on timing
|
||||||
|
// The key is that the system handles rapid events without crashing
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process multiple different files independently", async () => {
|
||||||
|
// Create multiple files nearly simultaneously
|
||||||
|
const promises = [
|
||||||
|
createMockComicFile(tempDir, "multi-1", ".cbz"),
|
||||||
|
createMockComicFile(tempDir, "multi-2", ".cbr"),
|
||||||
|
createMockComicFile(tempDir, "multi-3", ".cb7"),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Wait for all files to be detected
|
||||||
|
const allDetected = await waitForCondition(
|
||||||
|
() => mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED").length >= 3,
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(allDetected).toBe(true);
|
||||||
|
const events = mockBroker.eventCapturer.getByEvent("LS_FILE_DETECTED");
|
||||||
|
expect(events.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Nested Directory Support", () => {
|
||||||
|
it("should detect files in nested directories", async () => {
|
||||||
|
// Create nested directory structure
|
||||||
|
const level1 = await createSubDir(tempDir, "publisher");
|
||||||
|
const level2 = await createSubDir(level1, "series");
|
||||||
|
const level3 = await createSubDir(level2, "volume");
|
||||||
|
|
||||||
|
// Create a file in the deepest level
|
||||||
|
const filePath = await createMockComicFile(level3, "deep-issue", ".cbz");
|
||||||
|
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
expect(detected!.args[0].filePath).toBe(filePath);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await deleteDir(level1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect files up to depth 10", async () => {
|
||||||
|
// Create a deeply nested structure
|
||||||
|
let currentDir = tempDir;
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
currentDir = await createSubDir(currentDir, `level-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = await createMockComicFile(currentDir, "very-deep", ".cbz");
|
||||||
|
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 8000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await deleteDir(path.join(tempDir, "level-1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File Stability Check", () => {
|
||||||
|
it("should wait for file to be stable before processing", async () => {
|
||||||
|
// Create a file
|
||||||
|
const filePath = path.join(tempDir, "stability-test.cbz");
|
||||||
|
|
||||||
|
// Write initial content
|
||||||
|
await fs.promises.writeFile(filePath, Buffer.alloc(1024));
|
||||||
|
|
||||||
|
// Wait for stability check to pass
|
||||||
|
const detected = await mockBroker.eventCapturer.waitForEvent("LS_FILE_DETECTED", 5000);
|
||||||
|
|
||||||
|
expect(detected).not.toBeNull();
|
||||||
|
expect(detected!.args[0].filePath).toBe(filePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Watcher Coordination with Imports", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let mockBroker: MockBrokerWrapper;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = await createTempDir("watcher-import-test-");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await removeTempDir(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockBroker = await setupMockBroker();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await teardownMockBroker(mockBroker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit IMPORT_WATCHER_DISABLED when import starts", async () => {
|
||||||
|
// Simulate the import starting
|
||||||
|
await mockBroker.broker.broadcast("IMPORT_WATCHER_DISABLED", {
|
||||||
|
reason: "Full import in progress",
|
||||||
|
sessionId: "test-session-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real scenario, api.service.ts would handle this event
|
||||||
|
// and emit IMPORT_WATCHER_STATUS to Socket.IO
|
||||||
|
// This test verifies the event flow
|
||||||
|
|
||||||
|
expect(mockBroker.wasCalled("importstate.startSession")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit IMPORT_WATCHER_ENABLED when import completes", async () => {
|
||||||
|
// Simulate import completion
|
||||||
|
await mockBroker.broker.broadcast("IMPORT_WATCHER_ENABLED", {
|
||||||
|
sessionId: "test-session-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify event was broadcast
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tests/setup.ts
Normal file
24
tests/setup.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Jest global setup for file watcher e2e tests
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
import { jest, beforeAll, afterAll } from "@jest/globals";
|
||||||
|
|
||||||
|
// Increase Jest timeout for e2e tests that involve file system operations
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
// Suppress console logs during tests unless DEBUG is set
|
||||||
|
if (!process.env.DEBUG) {
|
||||||
|
const originalConsole = { ...console };
|
||||||
|
beforeAll(() => {
|
||||||
|
console.log = jest.fn() as typeof console.log;
|
||||||
|
console.info = jest.fn() as typeof console.info;
|
||||||
|
// Keep error and warn for debugging
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
console.log = originalConsole.log;
|
||||||
|
console.info = originalConsole.info;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
227
tests/utils/mock-services.ts
Normal file
227
tests/utils/mock-services.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Mock services for file watcher e2e tests
|
||||||
|
* Provides mock implementations of Moleculer services
|
||||||
|
*/
|
||||||
|
import { ServiceBroker, Context, ServiceSchema } from "moleculer";
|
||||||
|
import { EventCapturer } from "./test-helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock call tracking interface
|
||||||
|
*/
|
||||||
|
export interface MockCall {
|
||||||
|
action: string;
|
||||||
|
params: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock broker wrapper that tracks all calls and events
|
||||||
|
*/
|
||||||
|
export class MockBrokerWrapper {
|
||||||
|
public broker: ServiceBroker;
|
||||||
|
public calls: MockCall[] = [];
|
||||||
|
public eventCapturer: EventCapturer;
|
||||||
|
private mockResponses: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.eventCapturer = new EventCapturer();
|
||||||
|
this.broker = new ServiceBroker({
|
||||||
|
logger: false, // Suppress logs during tests
|
||||||
|
transporter: null, // No actual transport needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures a mock response for a specific action
|
||||||
|
*/
|
||||||
|
mockResponse(action: string, response: any): void {
|
||||||
|
this.mockResponses.set(action, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all calls made to a specific action
|
||||||
|
*/
|
||||||
|
getCallsTo(action: string): MockCall[] {
|
||||||
|
return this.calls.filter((c) => c.action === action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an action was called
|
||||||
|
*/
|
||||||
|
wasCalled(action: string): boolean {
|
||||||
|
return this.calls.some((c) => c.action === action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all recorded calls
|
||||||
|
*/
|
||||||
|
clearCalls(): void {
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the broker
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.broker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the broker
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.broker.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock socket service that captures broadcast events
|
||||||
|
*/
|
||||||
|
export function createMockSocketService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||||
|
return {
|
||||||
|
name: "socket",
|
||||||
|
actions: {
|
||||||
|
broadcast(ctx: Context<{ namespace: string; event: string; args: any[] }>) {
|
||||||
|
const { event, args } = ctx.params;
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "socket.broadcast",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
wrapper.eventCapturer.capture(event, ...args);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
broadcastLibraryStatistics(ctx: Context<{ directoryPath?: string }>) {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "socket.broadcastLibraryStatistics",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock library service that tracks database operations
|
||||||
|
*/
|
||||||
|
export function createMockLibraryService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||||
|
return {
|
||||||
|
name: "library",
|
||||||
|
actions: {
|
||||||
|
markFileAsMissing(ctx: Context<{ filePath: string }>) {
|
||||||
|
const { filePath } = ctx.params;
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "library.markFileAsMissing",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a mock response simulating comics being marked as missing
|
||||||
|
const mockResult = {
|
||||||
|
marked: 1,
|
||||||
|
missingComics: [
|
||||||
|
{
|
||||||
|
_id: "mock-id-123",
|
||||||
|
rawFileDetails: {
|
||||||
|
name: "Test Comic",
|
||||||
|
filePath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return mockResult;
|
||||||
|
},
|
||||||
|
clearFileMissingFlag(ctx: Context<{ filePath: string }>) {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "library.clearFileMissingFlag",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
getImportStatistics(ctx: Context<{ directoryPath?: string }>) {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "library.getImportStatistics",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
directory: ctx.params.directoryPath || "/comics",
|
||||||
|
stats: {
|
||||||
|
totalLocalFiles: 10,
|
||||||
|
alreadyImported: 5,
|
||||||
|
newFiles: 5,
|
||||||
|
missingFiles: 0,
|
||||||
|
percentageImported: "50.00%",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock importstate service
|
||||||
|
*/
|
||||||
|
export function createMockImportStateService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||||
|
let watcherEnabled = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "importstate",
|
||||||
|
actions: {
|
||||||
|
isWatcherEnabled() {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "importstate.isWatcherEnabled",
|
||||||
|
params: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return { enabled: watcherEnabled };
|
||||||
|
},
|
||||||
|
startSession(ctx: Context<{ sessionId: string; type: string; directoryPath?: string }>) {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "importstate.startSession",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
if (ctx.params.type !== "watcher") {
|
||||||
|
watcherEnabled = false;
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
completeSession(ctx: Context<{ sessionId: string; success: boolean }>) {
|
||||||
|
wrapper.calls.push({
|
||||||
|
action: "importstate.completeSession",
|
||||||
|
params: ctx.params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
watcherEnabled = true;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a complete mock broker with all services registered
|
||||||
|
*/
|
||||||
|
export async function setupMockBroker(): Promise<MockBrokerWrapper> {
|
||||||
|
const wrapper = new MockBrokerWrapper();
|
||||||
|
|
||||||
|
// Create and register mock services
|
||||||
|
wrapper.broker.createService(createMockSocketService(wrapper));
|
||||||
|
wrapper.broker.createService(createMockLibraryService(wrapper));
|
||||||
|
wrapper.broker.createService(createMockImportStateService(wrapper));
|
||||||
|
|
||||||
|
await wrapper.start();
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the mock broker
|
||||||
|
*/
|
||||||
|
export async function teardownMockBroker(wrapper: MockBrokerWrapper): Promise<void> {
|
||||||
|
await wrapper.stop();
|
||||||
|
}
|
||||||
267
tests/utils/test-helpers.ts
Normal file
267
tests/utils/test-helpers.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Test helper utilities for file watcher e2e tests
|
||||||
|
*/
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import fsExtra from "fs-extra";
|
||||||
|
|
||||||
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event capture interface for tracking emitted events
|
||||||
|
*/
|
||||||
|
export interface CapturedEvent {
|
||||||
|
event: string;
|
||||||
|
args: any[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary directory for testing
|
||||||
|
* @returns Path to the created temp directory
|
||||||
|
*/
|
||||||
|
export async function createTempDir(prefix: string = "threetwo-test-"): Promise<string> {
|
||||||
|
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a temporary directory and all its contents
|
||||||
|
* @param dirPath Path to the directory to remove
|
||||||
|
*/
|
||||||
|
export async function removeTempDir(dirPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsExtra.remove(dirPath);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors if directory doesn't exist
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock comic file with the specified extension
|
||||||
|
* @param dirPath Directory to create the file in
|
||||||
|
* @param fileName Name of the file (without extension)
|
||||||
|
* @param extension File extension (.cbz, .cbr, .cb7)
|
||||||
|
* @param sizeKB Size of the file in KB (default 10KB)
|
||||||
|
* @returns Full path to the created file
|
||||||
|
*/
|
||||||
|
export async function createMockComicFile(
|
||||||
|
dirPath: string,
|
||||||
|
fileName: string,
|
||||||
|
extension: ".cbz" | ".cbr" | ".cb7" = ".cbz",
|
||||||
|
sizeKB: number = 10
|
||||||
|
): Promise<string> {
|
||||||
|
const filePath = path.join(dirPath, `${fileName}${extension}`);
|
||||||
|
// Create a file with random content of specified size
|
||||||
|
const buffer = Buffer.alloc(sizeKB * 1024);
|
||||||
|
// Add a minimal ZIP header for .cbz files to make them somewhat valid
|
||||||
|
if (extension === ".cbz") {
|
||||||
|
buffer.write("PK\x03\x04", 0); // ZIP local file header signature
|
||||||
|
}
|
||||||
|
await fsp.writeFile(filePath, buffer);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a non-comic file (for testing filtering)
|
||||||
|
* @param dirPath Directory to create the file in
|
||||||
|
* @param fileName Full filename including extension
|
||||||
|
* @param content File content
|
||||||
|
* @returns Full path to the created file
|
||||||
|
*/
|
||||||
|
export async function createNonComicFile(
|
||||||
|
dirPath: string,
|
||||||
|
fileName: string,
|
||||||
|
content: string = "test content"
|
||||||
|
): Promise<string> {
|
||||||
|
const filePath = path.join(dirPath, fileName);
|
||||||
|
await fsp.writeFile(filePath, content);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subdirectory
|
||||||
|
* @param parentDir Parent directory path
|
||||||
|
* @param subDirName Name of the subdirectory
|
||||||
|
* @returns Full path to the created subdirectory
|
||||||
|
*/
|
||||||
|
export async function createSubDir(parentDir: string, subDirName: string): Promise<string> {
|
||||||
|
const subDirPath = path.join(parentDir, subDirName);
|
||||||
|
await fsp.mkdir(subDirPath, { recursive: true });
|
||||||
|
return subDirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file
|
||||||
|
* @param filePath Path to the file to delete
|
||||||
|
*/
|
||||||
|
export async function deleteFile(filePath: string): Promise<void> {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a directory and all its contents
|
||||||
|
* @param dirPath Path to the directory to delete
|
||||||
|
*/
|
||||||
|
export async function deleteDir(dirPath: string): Promise<void> {
|
||||||
|
await fsExtra.remove(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a specific duration
|
||||||
|
* @param ms Milliseconds to wait
|
||||||
|
*/
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a condition to be true, with timeout
|
||||||
|
* @param condition Function that returns true when condition is met
|
||||||
|
* @param timeoutMs Maximum time to wait in milliseconds
|
||||||
|
* @param intervalMs Check interval in milliseconds
|
||||||
|
* @returns True if condition was met, false if timed out
|
||||||
|
*/
|
||||||
|
export async function waitForCondition(
|
||||||
|
condition: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMs: number = 10000,
|
||||||
|
intervalMs: number = 100
|
||||||
|
): Promise<boolean> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
if (await condition()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an event capturer that records all emitted events
|
||||||
|
*/
|
||||||
|
export class EventCapturer {
|
||||||
|
private events: CapturedEvent[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an event
|
||||||
|
*/
|
||||||
|
capture(event: string, ...args: any[]): void {
|
||||||
|
this.events.push({
|
||||||
|
event,
|
||||||
|
args,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all captured events
|
||||||
|
*/
|
||||||
|
getAll(): CapturedEvent[] {
|
||||||
|
return [...this.events];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns events matching the given event name
|
||||||
|
*/
|
||||||
|
getByEvent(eventName: string): CapturedEvent[] {
|
||||||
|
return this.events.filter((e) => e.event === eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific event was captured
|
||||||
|
*/
|
||||||
|
hasEvent(eventName: string): boolean {
|
||||||
|
return this.events.some((e) => e.event === eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a specific event to be captured
|
||||||
|
*/
|
||||||
|
async waitForEvent(eventName: string, timeoutMs: number = 10000): Promise<CapturedEvent | null> {
|
||||||
|
const result = await waitForCondition(() => this.hasEvent(eventName), timeoutMs);
|
||||||
|
if (result) {
|
||||||
|
return this.getByEvent(eventName)[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all captured events
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.events = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the count of captured events
|
||||||
|
*/
|
||||||
|
get count(): number {
|
||||||
|
return this.events.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock file stats object
|
||||||
|
*/
|
||||||
|
export function createMockStats(options: Partial<fs.Stats> = {}): fs.Stats {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
dev: 0,
|
||||||
|
ino: 0,
|
||||||
|
mode: 0o100644,
|
||||||
|
nlink: 1,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
rdev: 0,
|
||||||
|
size: options.size ?? 10240,
|
||||||
|
blksize: 4096,
|
||||||
|
blocks: 8,
|
||||||
|
atimeMs: now.getTime(),
|
||||||
|
mtimeMs: options.mtimeMs ?? now.getTime(),
|
||||||
|
ctimeMs: now.getTime(),
|
||||||
|
birthtimeMs: now.getTime(),
|
||||||
|
atime: now,
|
||||||
|
mtime: options.mtime ?? now,
|
||||||
|
ctime: now,
|
||||||
|
birthtime: now,
|
||||||
|
isFile: () => true,
|
||||||
|
isDirectory: () => false,
|
||||||
|
isBlockDevice: () => false,
|
||||||
|
isCharacterDevice: () => false,
|
||||||
|
isSymbolicLink: () => false,
|
||||||
|
isFIFO: () => false,
|
||||||
|
isSocket: () => false,
|
||||||
|
} as fs.Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a file (simulates a real file transfer)
|
||||||
|
* @param sourcePath Source file path
|
||||||
|
* @param destPath Destination file path
|
||||||
|
*/
|
||||||
|
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
||||||
|
await fsp.copyFile(sourcePath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a file to a new location
|
||||||
|
* @param sourcePath Source file path
|
||||||
|
* @param destPath Destination file path
|
||||||
|
*/
|
||||||
|
export async function moveFile(sourcePath: string, destPath: string): Promise<void> {
|
||||||
|
await fsp.rename(sourcePath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touches a file (updates its mtime)
|
||||||
|
* @param filePath Path to the file
|
||||||
|
*/
|
||||||
|
export async function touchFile(filePath: string): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
await fsp.utimes(filePath, now, now);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"preserveConstEnums": true,
|
"preserveConstEnums": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"pretty": true,
|
"pretty": true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fse = require("fs-extra");
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { FileMagic, MagicFlags } from "@npcz/magic";
|
import { FileMagic, MagicFlags } from "@npcz/magic";
|
||||||
const { readdir, stat } = require("fs/promises");
|
const { stat } = require("fs/promises");
|
||||||
import {
|
import {
|
||||||
IExplodedPathResponse,
|
IExplodedPathResponse,
|
||||||
IExtractComicBookCoverErrorResponse,
|
IExtractComicBookCoverErrorResponse,
|
||||||
@@ -95,13 +95,24 @@ export const getSizeOfDirectory = async (
|
|||||||
directoryPath: string,
|
directoryPath: string,
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
) => {
|
) => {
|
||||||
const files = await readdir(directoryPath);
|
let totalSizeInBytes = 0;
|
||||||
const stats = files.map((file) => stat(path.join(directoryPath, file)));
|
let fileCount = 0;
|
||||||
|
|
||||||
return (await Promise.all(stats)).reduce(
|
await Walk.walk(directoryPath, async (err, pathname, dirent) => {
|
||||||
(accumulator, { size }) => accumulator + size,
|
if (err) return false;
|
||||||
0
|
if (dirent.isFile() && extensions.includes(path.extname(dirent.name))) {
|
||||||
);
|
const fileStat = await stat(pathname);
|
||||||
|
totalSizeInBytes += fileStat.size;
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSize: totalSizeInBytes,
|
||||||
|
totalSizeInMB: totalSizeInBytes / (1024 * 1024),
|
||||||
|
totalSizeInGB: totalSizeInBytes / (1024 * 1024 * 1024),
|
||||||
|
fileCount,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidImageFileExtension = (fileName: string): boolean => {
|
export const isValidImageFileExtension = (fileName: string): boolean => {
|
||||||
@@ -174,14 +185,16 @@ export const getMimeType = async (filePath: string) => {
|
|||||||
*/
|
*/
|
||||||
export const createDirectory = async (options: any, directoryPath: string) => {
|
export const createDirectory = async (options: any, directoryPath: string) => {
|
||||||
try {
|
try {
|
||||||
await fse.ensureDir(directoryPath, options);
|
await fse.ensureDir(directoryPath);
|
||||||
console.info(`Directory [ %s ] was created.`, directoryPath);
|
console.info(`Directory [ %s ] was created.`, directoryPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to create directory [ ${directoryPath} ]:`, error);
|
||||||
throw new Errors.MoleculerError(
|
throw new Errors.MoleculerError(
|
||||||
"Failed to create directory",
|
`Failed to create directory: ${directoryPath} - ${errMsg}`,
|
||||||
500,
|
500,
|
||||||
"FileOpsError",
|
"FileOpsError",
|
||||||
error
|
{ directoryPath, originalError: errMsg }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
331
utils/graphql.error.utils.ts
Normal file
331
utils/graphql.error.utils.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL error handling utilities
|
||||||
|
* @module utils/graphql.error.utils
|
||||||
|
* @description Provides comprehensive error handling utilities for GraphQL operations,
|
||||||
|
* including standardized error codes, error creation, error transformation, logging,
|
||||||
|
* and error sanitization for client responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GraphQLError } from "graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized error codes for GraphQL operations
|
||||||
|
* @enum {string}
|
||||||
|
* @description Comprehensive set of error codes covering client errors (4xx),
|
||||||
|
* server errors (5xx), GraphQL-specific errors, remote schema errors, and database errors.
|
||||||
|
*/
|
||||||
|
export enum GraphQLErrorCode {
|
||||||
|
// Client errors (4xx)
|
||||||
|
/** Bad request - malformed or invalid request */
|
||||||
|
BAD_REQUEST = "BAD_REQUEST",
|
||||||
|
/** Unauthorized - authentication required */
|
||||||
|
UNAUTHORIZED = "UNAUTHORIZED",
|
||||||
|
/** Forbidden - insufficient permissions */
|
||||||
|
FORBIDDEN = "FORBIDDEN",
|
||||||
|
/** Not found - requested resource doesn't exist */
|
||||||
|
NOT_FOUND = "NOT_FOUND",
|
||||||
|
/** Validation error - input validation failed */
|
||||||
|
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||||
|
|
||||||
|
// Server errors (5xx)
|
||||||
|
/** Internal server error - unexpected server-side error */
|
||||||
|
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
|
||||||
|
/** Service unavailable - service is temporarily unavailable */
|
||||||
|
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
|
||||||
|
/** Timeout - operation exceeded time limit */
|
||||||
|
TIMEOUT = "TIMEOUT",
|
||||||
|
|
||||||
|
// GraphQL specific
|
||||||
|
/** GraphQL parse failed - query syntax error */
|
||||||
|
GRAPHQL_PARSE_FAILED = "GRAPHQL_PARSE_FAILED",
|
||||||
|
/** GraphQL validation failed - query validation error */
|
||||||
|
GRAPHQL_VALIDATION_FAILED = "GRAPHQL_VALIDATION_FAILED",
|
||||||
|
|
||||||
|
// Remote schema errors
|
||||||
|
/** Remote schema error - error from remote GraphQL service */
|
||||||
|
REMOTE_SCHEMA_ERROR = "REMOTE_SCHEMA_ERROR",
|
||||||
|
/** Remote schema unavailable - cannot connect to remote schema */
|
||||||
|
REMOTE_SCHEMA_UNAVAILABLE = "REMOTE_SCHEMA_UNAVAILABLE",
|
||||||
|
|
||||||
|
// Database errors
|
||||||
|
/** Database error - database operation failed */
|
||||||
|
DATABASE_ERROR = "DATABASE_ERROR",
|
||||||
|
/** Document not found - requested document doesn't exist */
|
||||||
|
DOCUMENT_NOT_FOUND = "DOCUMENT_NOT_FOUND",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a standardized GraphQL error with consistent formatting
|
||||||
|
* @function createGraphQLError
|
||||||
|
* @param {string} message - Human-readable error message
|
||||||
|
* @param {GraphQLErrorCode} [code=INTERNAL_SERVER_ERROR] - Error code from GraphQLErrorCode enum
|
||||||
|
* @param {Record<string, any>} [extensions] - Additional error metadata
|
||||||
|
* @returns {GraphQLError} Formatted GraphQL error object
|
||||||
|
* @description Creates a GraphQL error with standardized structure including error code
|
||||||
|
* and optional extensions. The error code is automatically added to extensions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* throw createGraphQLError(
|
||||||
|
* 'Comic not found',
|
||||||
|
* GraphQLErrorCode.NOT_FOUND,
|
||||||
|
* { comicId: '123' }
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createGraphQLError(
|
||||||
|
message: string,
|
||||||
|
code: GraphQLErrorCode = GraphQLErrorCode.INTERNAL_SERVER_ERROR,
|
||||||
|
extensions?: Record<string, any>
|
||||||
|
): GraphQLError {
|
||||||
|
return new GraphQLError(message, {
|
||||||
|
extensions: {
|
||||||
|
code,
|
||||||
|
...extensions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle and format errors for GraphQL responses
|
||||||
|
* @function handleGraphQLError
|
||||||
|
* @param {any} error - The error to handle (can be any type)
|
||||||
|
* @param {string} [context] - Optional context string describing where the error occurred
|
||||||
|
* @returns {GraphQLError} Formatted GraphQL error
|
||||||
|
* @description Transforms various error types into standardized GraphQL errors.
|
||||||
|
* Handles MongoDB errors (CastError, ValidationError, DocumentNotFoundError),
|
||||||
|
* timeout errors, network errors, and generic errors. Already-formatted GraphQL
|
||||||
|
* errors are returned as-is.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* await someOperation();
|
||||||
|
* } catch (error) {
|
||||||
|
* throw handleGraphQLError(error, 'someOperation');
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function handleGraphQLError(error: any, context?: string): GraphQLError {
|
||||||
|
// If it's already a GraphQL error, return it
|
||||||
|
if (error instanceof GraphQLError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle MongoDB errors
|
||||||
|
if (error.name === "CastError") {
|
||||||
|
return createGraphQLError(
|
||||||
|
"Invalid ID format",
|
||||||
|
GraphQLErrorCode.VALIDATION_ERROR,
|
||||||
|
{ field: error.path }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === "ValidationError") {
|
||||||
|
return createGraphQLError(
|
||||||
|
`Validation failed: ${error.message}`,
|
||||||
|
GraphQLErrorCode.VALIDATION_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === "DocumentNotFoundError") {
|
||||||
|
return createGraphQLError(
|
||||||
|
"Document not found",
|
||||||
|
GraphQLErrorCode.DOCUMENT_NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout errors
|
||||||
|
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
|
||||||
|
return createGraphQLError(
|
||||||
|
"Operation timed out",
|
||||||
|
GraphQLErrorCode.TIMEOUT,
|
||||||
|
{ context }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
|
||||||
|
return createGraphQLError(
|
||||||
|
"Service unavailable",
|
||||||
|
GraphQLErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
{ context }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
return createGraphQLError(
|
||||||
|
context ? `${context}: ${error.message}` : error.message,
|
||||||
|
GraphQLErrorCode.INTERNAL_SERVER_ERROR,
|
||||||
|
{
|
||||||
|
originalError: error.name,
|
||||||
|
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a resolver function with automatic error handling
|
||||||
|
* @function withErrorHandling
|
||||||
|
* @template T - The resolver function type
|
||||||
|
* @param {T} resolver - The resolver function to wrap
|
||||||
|
* @param {string} [context] - Optional context string for error messages
|
||||||
|
* @returns {T} Wrapped resolver function with error handling
|
||||||
|
* @description Higher-order function that wraps a resolver with try-catch error handling.
|
||||||
|
* Automatically transforms errors using handleGraphQLError before re-throwing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const getComic = withErrorHandling(
|
||||||
|
* async (_, { id }) => {
|
||||||
|
* return await Comic.findById(id);
|
||||||
|
* },
|
||||||
|
* 'getComic'
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withErrorHandling<T extends (...args: any[]) => any>(
|
||||||
|
resolver: T,
|
||||||
|
context?: string
|
||||||
|
): T {
|
||||||
|
return (async (...args: any[]) => {
|
||||||
|
try {
|
||||||
|
return await resolver(...args);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw handleGraphQLError(error, context);
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error logging context
|
||||||
|
* @interface ErrorContext
|
||||||
|
* @property {string} [operation] - Name of the GraphQL operation
|
||||||
|
* @property {string} [query] - The GraphQL query string
|
||||||
|
* @property {any} [variables] - Query variables
|
||||||
|
* @property {string} [userId] - User ID if available
|
||||||
|
*/
|
||||||
|
interface ErrorContext {
|
||||||
|
operation?: string;
|
||||||
|
query?: string;
|
||||||
|
variables?: any;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error with structured context information
|
||||||
|
* @function logError
|
||||||
|
* @param {any} logger - Logger instance (e.g., Moleculer logger)
|
||||||
|
* @param {Error} error - The error to log
|
||||||
|
* @param {ErrorContext} context - Additional context for the error
|
||||||
|
* @returns {void}
|
||||||
|
* @description Logs errors with structured context including operation name, query,
|
||||||
|
* variables, and user ID. Includes GraphQL error extensions if present.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* logError(this.logger, error, {
|
||||||
|
* operation: 'getComic',
|
||||||
|
* query: 'query { comic(id: "123") { title } }',
|
||||||
|
* variables: { id: '123' }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function logError(
|
||||||
|
logger: any,
|
||||||
|
error: Error,
|
||||||
|
context: ErrorContext
|
||||||
|
): void {
|
||||||
|
const errorInfo: any = {
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
stack: error.stack,
|
||||||
|
...context,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error instanceof GraphQLError) {
|
||||||
|
errorInfo.extensions = error.extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("GraphQL Error:", errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable
|
||||||
|
* @function isRetryableError
|
||||||
|
* @param {any} error - The error to check
|
||||||
|
* @returns {boolean} True if the error is retryable, false otherwise
|
||||||
|
* @description Determines if an error represents a transient failure that could
|
||||||
|
* succeed on retry. Returns true for network errors, timeout errors, and
|
||||||
|
* service unavailable errors.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* if (isRetryableError(error)) {
|
||||||
|
* // Implement retry logic
|
||||||
|
* await retryOperation();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isRetryableError(error: any): boolean {
|
||||||
|
// Network errors are retryable
|
||||||
|
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout errors are retryable
|
||||||
|
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service unavailable errors are retryable
|
||||||
|
if (error.extensions?.code === GraphQLErrorCode.SERVICE_UNAVAILABLE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize error for client response
|
||||||
|
* @function sanitizeError
|
||||||
|
* @param {GraphQLError} error - The GraphQL error to sanitize
|
||||||
|
* @param {boolean} [includeStack=false] - Whether to include stack trace
|
||||||
|
* @returns {any} Sanitized error object safe for client consumption
|
||||||
|
* @description Sanitizes errors for client responses by removing sensitive information
|
||||||
|
* and including only safe fields. Stack traces are only included if explicitly requested
|
||||||
|
* (typically only in development environments).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const sanitized = sanitizeError(
|
||||||
|
* error,
|
||||||
|
* process.env.NODE_ENV === 'development'
|
||||||
|
* );
|
||||||
|
* return { errors: [sanitized] };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function sanitizeError(error: GraphQLError, includeStack: boolean = false): any {
|
||||||
|
const sanitized: any = {
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.extensions?.code || GraphQLErrorCode.INTERNAL_SERVER_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include additional safe extensions
|
||||||
|
if (error.extensions?.field) {
|
||||||
|
sanitized.extensions.field = error.extensions.field;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.extensions?.context) {
|
||||||
|
sanitized.extensions.context = error.extensions.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include stack trace only in development
|
||||||
|
if (includeStack && error.stack) {
|
||||||
|
sanitized.extensions.stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
302
utils/graphql.schema.utils.ts
Normal file
302
utils/graphql.schema.utils.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL schema utilities for remote schema fetching and validation
|
||||||
|
* @module utils/graphql.schema.utils
|
||||||
|
* @description Provides utilities for fetching remote GraphQL schemas via introspection,
|
||||||
|
* creating remote executors for schema stitching, and validating GraphQL schemas.
|
||||||
|
* Includes retry logic, timeout handling, and comprehensive error management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GraphQLSchema, getIntrospectionQuery, buildClientSchema, IntrospectionQuery } from "graphql";
|
||||||
|
import { print } from "graphql";
|
||||||
|
import { fetch } from "undici";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for remote schema fetching
|
||||||
|
* @interface RemoteSchemaConfig
|
||||||
|
* @property {string} url - The URL of the remote GraphQL endpoint
|
||||||
|
* @property {number} [timeout=10000] - Request timeout in milliseconds
|
||||||
|
* @property {number} [retries=3] - Number of retry attempts for failed requests
|
||||||
|
* @property {number} [retryDelay=2000] - Base delay between retries in milliseconds (uses exponential backoff)
|
||||||
|
*/
|
||||||
|
export interface RemoteSchemaConfig {
|
||||||
|
url: string;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a schema fetch operation
|
||||||
|
* @interface SchemaFetchResult
|
||||||
|
* @property {boolean} success - Whether the fetch operation succeeded
|
||||||
|
* @property {GraphQLSchema} [schema] - The fetched GraphQL schema (present if success is true)
|
||||||
|
* @property {Error} [error] - Error object if the fetch failed
|
||||||
|
* @property {number} attempts - Number of attempts made before success or final failure
|
||||||
|
*/
|
||||||
|
export interface SchemaFetchResult {
|
||||||
|
success: boolean;
|
||||||
|
schema?: GraphQLSchema;
|
||||||
|
error?: Error;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch remote GraphQL schema via introspection with retry logic
|
||||||
|
* @async
|
||||||
|
* @function fetchRemoteSchema
|
||||||
|
* @param {RemoteSchemaConfig} config - Configuration for the remote schema fetch
|
||||||
|
* @returns {Promise<SchemaFetchResult>} Result object containing schema or error
|
||||||
|
* @description Fetches a GraphQL schema from a remote endpoint using introspection.
|
||||||
|
* Implements exponential backoff retry logic and timeout handling. The function will
|
||||||
|
* retry failed requests up to the specified number of times with increasing delays.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await fetchRemoteSchema({
|
||||||
|
* url: 'http://localhost:3080/graphql',
|
||||||
|
* timeout: 5000,
|
||||||
|
* retries: 3,
|
||||||
|
* retryDelay: 1000
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* if (result.success) {
|
||||||
|
* console.log('Schema fetched:', result.schema);
|
||||||
|
* } else {
|
||||||
|
* console.error('Failed after', result.attempts, 'attempts:', result.error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function fetchRemoteSchema(
|
||||||
|
config: RemoteSchemaConfig
|
||||||
|
): Promise<SchemaFetchResult> {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
timeout = 10000,
|
||||||
|
retries = 3,
|
||||||
|
retryDelay = 2000,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
attempts = attempt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const introspectionQuery = getIntrospectionQuery();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query: introspectionQuery }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as {
|
||||||
|
data?: IntrospectionQuery;
|
||||||
|
errors?: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Introspection errors: ${JSON.stringify(result.errors)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data) {
|
||||||
|
throw new Error("No data returned from introspection query");
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = buildClientSchema(result.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
schema,
|
||||||
|
attempts,
|
||||||
|
};
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (fetchError.name === "AbortError") {
|
||||||
|
throw new Error(`Request timeout after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Don't retry on the last attempt
|
||||||
|
if (attempt < retries) {
|
||||||
|
await sleep(retryDelay * attempt); // Exponential backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lastError || new Error("Unknown error during schema fetch"),
|
||||||
|
attempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an executor function for remote GraphQL endpoint with error handling
|
||||||
|
* @function createRemoteExecutor
|
||||||
|
* @param {string} url - The URL of the remote GraphQL endpoint
|
||||||
|
* @param {number} [timeout=30000] - Request timeout in milliseconds
|
||||||
|
* @returns {Function} Executor function compatible with schema stitching
|
||||||
|
* @description Creates an executor function that can be used with GraphQL schema stitching.
|
||||||
|
* The executor handles query execution against a remote GraphQL endpoint, including
|
||||||
|
* timeout handling and error formatting. Returns errors in GraphQL-compatible format.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const executor = createRemoteExecutor('http://localhost:3080/graphql', 10000);
|
||||||
|
*
|
||||||
|
* // Used in schema stitching:
|
||||||
|
* const stitchedSchema = stitchSchemas({
|
||||||
|
* subschemas: [{
|
||||||
|
* schema: remoteSchema,
|
||||||
|
* executor: executor
|
||||||
|
* }]
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createRemoteExecutor(url: string, timeout: number = 30000) {
|
||||||
|
return async ({ document, variables, context }: any) => {
|
||||||
|
const query = print(document);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `Remote GraphQL request failed: ${response.statusText}`,
|
||||||
|
extensions: {
|
||||||
|
code: "REMOTE_ERROR",
|
||||||
|
status: response.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const errorMessage = error.name === "AbortError"
|
||||||
|
? `Remote request timeout after ${timeout}ms`
|
||||||
|
: `Remote GraphQL execution error: ${error.message}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: errorMessage,
|
||||||
|
extensions: {
|
||||||
|
code: error.name === "AbortError" ? "TIMEOUT" : "NETWORK_ERROR",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result for GraphQL schema
|
||||||
|
* @interface ValidationResult
|
||||||
|
* @property {boolean} valid - Whether the schema is valid
|
||||||
|
* @property {string[]} errors - Array of validation error messages
|
||||||
|
*/
|
||||||
|
interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a GraphQL schema for basic correctness
|
||||||
|
* @function validateSchema
|
||||||
|
* @param {GraphQLSchema} schema - The GraphQL schema to validate
|
||||||
|
* @returns {ValidationResult} Validation result with status and any error messages
|
||||||
|
* @description Performs basic validation on a GraphQL schema, checking for:
|
||||||
|
* - Presence of a Query type
|
||||||
|
* - At least one field in the Query type
|
||||||
|
* Returns a result object indicating validity and any error messages.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const validation = validateSchema(mySchema);
|
||||||
|
* if (!validation.valid) {
|
||||||
|
* console.error('Schema validation failed:', validation.errors);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateSchema(schema: GraphQLSchema): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if schema has Query type
|
||||||
|
const queryType = schema.getQueryType();
|
||||||
|
if (!queryType) {
|
||||||
|
errors.push("Schema must have a Query type");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if schema has at least one field
|
||||||
|
if (queryType && Object.keys(queryType.getFields()).length === 0) {
|
||||||
|
errors.push("Query type must have at least one field");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [`Schema validation error: ${error.message}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep utility for implementing retry delays
|
||||||
|
* @private
|
||||||
|
* @function sleep
|
||||||
|
* @param {number} ms - Number of milliseconds to sleep
|
||||||
|
* @returns {Promise<void>} Promise that resolves after the specified delay
|
||||||
|
* @description Helper function that returns a promise which resolves after
|
||||||
|
* the specified number of milliseconds. Used for implementing retry delays
|
||||||
|
* with exponential backoff.
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
436
utils/graphql.validation.utils.ts
Normal file
436
utils/graphql.validation.utils.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL input validation utilities
|
||||||
|
* @module utils/graphql.validation.utils
|
||||||
|
* @description Provides comprehensive validation utilities for GraphQL inputs including
|
||||||
|
* pagination parameters, IDs, search queries, file paths, metadata sources, and JSON strings.
|
||||||
|
* Includes custom ValidationError class and conversion to GraphQL errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GraphQLError } from "graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom validation error class
|
||||||
|
* @class ValidationError
|
||||||
|
* @extends Error
|
||||||
|
* @description Custom error class for validation failures with optional field and code information
|
||||||
|
*/
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
/**
|
||||||
|
* Create a validation error
|
||||||
|
* @param {string} message - Human-readable error message
|
||||||
|
* @param {string} [field] - The field that failed validation
|
||||||
|
* @param {string} [code='VALIDATION_ERROR'] - Error code for categorization
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public field?: string,
|
||||||
|
public code: string = "VALIDATION_ERROR"
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate pagination parameters
|
||||||
|
* @function validatePaginationParams
|
||||||
|
* @param {Object} params - Pagination parameters to validate
|
||||||
|
* @param {number} [params.page] - Page number (must be >= 1)
|
||||||
|
* @param {number} [params.limit] - Items per page (must be 1-100)
|
||||||
|
* @param {number} [params.offset] - Offset for cursor-based pagination (must be >= 0)
|
||||||
|
* @throws {ValidationError} If any parameter is invalid
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates pagination parameters ensuring page is positive, limit is within
|
||||||
|
* acceptable range (1-100), and offset is non-negative.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validatePaginationParams({ page: 1, limit: 20 }); // OK
|
||||||
|
* validatePaginationParams({ page: 0, limit: 20 }); // Throws ValidationError
|
||||||
|
* validatePaginationParams({ limit: 150 }); // Throws ValidationError
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validatePaginationParams(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): void {
|
||||||
|
const { page, limit, offset } = params;
|
||||||
|
|
||||||
|
if (page !== undefined) {
|
||||||
|
if (!Number.isInteger(page) || page < 1) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Page must be a positive integer",
|
||||||
|
"page",
|
||||||
|
"INVALID_PAGE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit !== undefined) {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Limit must be between 1 and 100",
|
||||||
|
"limit",
|
||||||
|
"INVALID_LIMIT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset !== undefined) {
|
||||||
|
if (!Number.isInteger(offset) || offset < 0) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Offset must be a non-negative integer",
|
||||||
|
"offset",
|
||||||
|
"INVALID_OFFSET"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a MongoDB ObjectId
|
||||||
|
* @function validateId
|
||||||
|
* @param {string} id - The ID to validate
|
||||||
|
* @param {string} [fieldName='id'] - Name of the field for error messages
|
||||||
|
* @throws {ValidationError} If ID is invalid
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that an ID is a string and matches MongoDB ObjectId format
|
||||||
|
* (24 hexadecimal characters).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateId('507f1f77bcf86cd799439011'); // OK
|
||||||
|
* validateId('invalid-id'); // Throws ValidationError
|
||||||
|
* validateId('', 'comicId'); // Throws ValidationError with field 'comicId'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateId(id: string, fieldName: string = "id"): void {
|
||||||
|
if (!id || typeof id !== "string") {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} is required and must be a string`,
|
||||||
|
fieldName,
|
||||||
|
"INVALID_ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDB ObjectId validation (24 hex characters)
|
||||||
|
if (!/^[a-f\d]{24}$/i.test(id)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} must be a valid ObjectId`,
|
||||||
|
fieldName,
|
||||||
|
"INVALID_ID_FORMAT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an array of MongoDB ObjectIds
|
||||||
|
* @function validateIds
|
||||||
|
* @param {string[]} ids - Array of IDs to validate
|
||||||
|
* @param {string} [fieldName='ids'] - Name of the field for error messages
|
||||||
|
* @throws {ValidationError} If array is invalid or contains invalid IDs
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that the input is a non-empty array (max 100 items) and
|
||||||
|
* all elements are valid MongoDB ObjectIds.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateIds(['507f1f77bcf86cd799439011', '507f191e810c19729de860ea']); // OK
|
||||||
|
* validateIds([]); // Throws ValidationError (empty array)
|
||||||
|
* validateIds(['invalid']); // Throws ValidationError (invalid ID)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateIds(ids: string[], fieldName: string = "ids"): void {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} must be a non-empty array`,
|
||||||
|
fieldName,
|
||||||
|
"INVALID_IDS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length > 100) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} cannot contain more than 100 items`,
|
||||||
|
fieldName,
|
||||||
|
"TOO_MANY_IDS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
try {
|
||||||
|
validateId(id, `${fieldName}[${index}]`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid ID at index ${index}: ${error.message}`,
|
||||||
|
fieldName,
|
||||||
|
"INVALID_ID_IN_ARRAY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a search query string
|
||||||
|
* @function validateSearchQuery
|
||||||
|
* @param {string} [query] - Search query to validate
|
||||||
|
* @throws {ValidationError} If query is invalid
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that a search query is a string and doesn't exceed 500 characters.
|
||||||
|
* Undefined or null values are allowed (optional search).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateSearchQuery('Batman'); // OK
|
||||||
|
* validateSearchQuery(undefined); // OK (optional)
|
||||||
|
* validateSearchQuery('a'.repeat(501)); // Throws ValidationError (too long)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateSearchQuery(query?: string): void {
|
||||||
|
if (query !== undefined && query !== null) {
|
||||||
|
if (typeof query !== "string") {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Search query must be a string",
|
||||||
|
"query",
|
||||||
|
"INVALID_QUERY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length > 500) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Search query cannot exceed 500 characters",
|
||||||
|
"query",
|
||||||
|
"QUERY_TOO_LONG"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a confidence threshold value
|
||||||
|
* @function validateConfidenceThreshold
|
||||||
|
* @param {number} [threshold] - Confidence threshold to validate (0-1)
|
||||||
|
* @throws {ValidationError} If threshold is invalid
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that a confidence threshold is a number between 0 and 1 inclusive.
|
||||||
|
* Undefined values are allowed (optional threshold).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateConfidenceThreshold(0.8); // OK
|
||||||
|
* validateConfidenceThreshold(undefined); // OK (optional)
|
||||||
|
* validateConfidenceThreshold(1.5); // Throws ValidationError (out of range)
|
||||||
|
* validateConfidenceThreshold('0.8'); // Throws ValidationError (not a number)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateConfidenceThreshold(threshold?: number): void {
|
||||||
|
if (threshold !== undefined) {
|
||||||
|
if (typeof threshold !== "number" || isNaN(threshold)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Confidence threshold must be a number",
|
||||||
|
"minConfidenceThreshold",
|
||||||
|
"INVALID_THRESHOLD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threshold < 0 || threshold > 1) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"Confidence threshold must be between 0 and 1",
|
||||||
|
"minConfidenceThreshold",
|
||||||
|
"THRESHOLD_OUT_OF_RANGE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string input by removing control characters and limiting length
|
||||||
|
* @function sanitizeString
|
||||||
|
* @param {string} input - String to sanitize
|
||||||
|
* @param {number} [maxLength=1000] - Maximum allowed length
|
||||||
|
* @returns {string} Sanitized string
|
||||||
|
* @description Removes null bytes and control characters, trims whitespace,
|
||||||
|
* and truncates to maximum length. Non-string inputs return empty string.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* sanitizeString(' Hello\x00World '); // 'HelloWorld'
|
||||||
|
* sanitizeString('a'.repeat(2000), 100); // 'aaa...' (100 chars)
|
||||||
|
* sanitizeString(123); // '' (non-string)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function sanitizeString(input: string, maxLength: number = 1000): string {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove null bytes and control characters
|
||||||
|
let sanitized = input.replace(/[\x00-\x1F\x7F]/g, "");
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
sanitized = sanitized.trim();
|
||||||
|
|
||||||
|
// Truncate to max length
|
||||||
|
if (sanitized.length > maxLength) {
|
||||||
|
sanitized = sanitized.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a validation error to a GraphQL error
|
||||||
|
* @function toGraphQLError
|
||||||
|
* @param {Error} error - Error to convert
|
||||||
|
* @returns {GraphQLError} GraphQL-formatted error
|
||||||
|
* @description Converts ValidationError instances to GraphQL errors with proper
|
||||||
|
* extensions. Other errors are converted to generic GraphQL errors.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* validateId('invalid');
|
||||||
|
* } catch (error) {
|
||||||
|
* throw toGraphQLError(error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function toGraphQLError(error: Error): GraphQLError {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
return new GraphQLError(error.message, {
|
||||||
|
extensions: {
|
||||||
|
code: error.code,
|
||||||
|
field: error.field,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphQLError(error.message, {
|
||||||
|
extensions: {
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a file path for security and correctness
|
||||||
|
* @function validateFilePath
|
||||||
|
* @param {string} filePath - File path to validate
|
||||||
|
* @throws {ValidationError} If file path is invalid or unsafe
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates file paths to prevent path traversal attacks and ensure
|
||||||
|
* reasonable length. Rejects paths containing ".." or "~" and paths exceeding 4096 characters.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateFilePath('/comics/batman.cbz'); // OK
|
||||||
|
* validateFilePath('../../../etc/passwd'); // Throws ValidationError (path traversal)
|
||||||
|
* validateFilePath('~/comics/file.cbz'); // Throws ValidationError (tilde expansion)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateFilePath(filePath: string): void {
|
||||||
|
if (!filePath || typeof filePath !== "string") {
|
||||||
|
throw new ValidationError(
|
||||||
|
"File path is required and must be a string",
|
||||||
|
"filePath",
|
||||||
|
"INVALID_FILE_PATH"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if (filePath.includes("..") || filePath.includes("~")) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"File path contains invalid characters",
|
||||||
|
"filePath",
|
||||||
|
"UNSAFE_FILE_PATH"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath.length > 4096) {
|
||||||
|
throw new ValidationError(
|
||||||
|
"File path is too long",
|
||||||
|
"filePath",
|
||||||
|
"FILE_PATH_TOO_LONG"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a metadata source value
|
||||||
|
* @function validateMetadataSource
|
||||||
|
* @param {string} source - Metadata source to validate
|
||||||
|
* @throws {ValidationError} If source is not a valid metadata source
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that a metadata source is one of the allowed values:
|
||||||
|
* COMICVINE, METRON, GRAND_COMICS_DATABASE, LOCG, COMICINFO_XML, or MANUAL.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateMetadataSource('COMICVINE'); // OK
|
||||||
|
* validateMetadataSource('INVALID_SOURCE'); // Throws ValidationError
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateMetadataSource(source: string): void {
|
||||||
|
const validSources = [
|
||||||
|
"COMICVINE",
|
||||||
|
"METRON",
|
||||||
|
"GRAND_COMICS_DATABASE",
|
||||||
|
"LOCG",
|
||||||
|
"COMICINFO_XML",
|
||||||
|
"MANUAL",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!validSources.includes(source)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid metadata source. Must be one of: ${validSources.join(", ")}`,
|
||||||
|
"source",
|
||||||
|
"INVALID_METADATA_SOURCE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a JSON string for correctness and size
|
||||||
|
* @function validateJSONString
|
||||||
|
* @param {string} jsonString - JSON string to validate
|
||||||
|
* @param {string} [fieldName='metadata'] - Name of the field for error messages
|
||||||
|
* @throws {ValidationError} If JSON is invalid or too large
|
||||||
|
* @returns {void}
|
||||||
|
* @description Validates that a string is valid JSON and doesn't exceed 1MB in size.
|
||||||
|
* Checks for proper JSON syntax and enforces size limits to prevent memory issues.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validateJSONString('{"title": "Batman"}'); // OK
|
||||||
|
* validateJSONString('invalid json'); // Throws ValidationError (malformed)
|
||||||
|
* validateJSONString('{"data": "' + 'x'.repeat(2000000) + '"}'); // Throws (too large)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateJSONString(jsonString: string, fieldName: string = "metadata"): void {
|
||||||
|
if (!jsonString || typeof jsonString !== "string") {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} must be a valid JSON string`,
|
||||||
|
fieldName,
|
||||||
|
"INVALID_JSON"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonString);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} contains invalid JSON`,
|
||||||
|
fieldName,
|
||||||
|
"MALFORMED_JSON"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString.length > 1048576) { // 1MB limit
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} exceeds maximum size of 1MB`,
|
||||||
|
fieldName,
|
||||||
|
"JSON_TOO_LARGE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
utils/import.graphql.utils.ts
Normal file
391
utils/import.graphql.utils.ts
Normal 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.graphql", {
|
||||||
|
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.graphql", {
|
||||||
|
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.graphql", {
|
||||||
|
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.graphql", {
|
||||||
|
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.graphql", {
|
||||||
|
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.graphql", {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
304
utils/import.utils.ts
Normal file
304
utils/import.utils.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Import utilities for checking existing records and managing incremental imports
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Comic from "../models/comic.model";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all imported file paths from MongoDB as a Set for O(1) lookup
|
||||||
|
* @returns Set of normalized file paths
|
||||||
|
*/
|
||||||
|
export async function getImportedFilePaths(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
// Query only the rawFileDetails.filePath field for efficiency
|
||||||
|
const comics = await Comic.find(
|
||||||
|
{ "rawFileDetails.filePath": { $exists: true, $ne: null } },
|
||||||
|
{ "rawFileDetails.filePath": 1, _id: 0 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const filePaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const comic of comics) {
|
||||||
|
if (comic.rawFileDetails?.filePath) {
|
||||||
|
// Normalize the path to handle different path formats
|
||||||
|
const normalizedPath = path.normalize(comic.rawFileDetails.filePath);
|
||||||
|
filePaths.add(normalizedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${filePaths.size} imported files in database`);
|
||||||
|
return filePaths;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching imported file paths:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all imported file names (without extension) as a Set
|
||||||
|
* @returns Set of file names for path-independent matching
|
||||||
|
*/
|
||||||
|
export async function getImportedFileNames(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
// Query only the rawFileDetails.name field for efficiency
|
||||||
|
const comics = await Comic.find(
|
||||||
|
{ "rawFileDetails.name": { $exists: true, $ne: null } },
|
||||||
|
{ "rawFileDetails.name": 1, _id: 0 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const fileNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const comic of comics) {
|
||||||
|
if (comic.rawFileDetails?.name) {
|
||||||
|
fileNames.add(comic.rawFileDetails.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${fileNames.size} imported file names in database`);
|
||||||
|
return fileNames;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching imported file names:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file path exists in the database
|
||||||
|
* @param filePath - Full file path to check
|
||||||
|
* @returns true if file is imported
|
||||||
|
*/
|
||||||
|
export async function isFileImported(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const normalizedPath = path.normalize(filePath);
|
||||||
|
const exists = await Comic.exists({
|
||||||
|
"rawFileDetails.filePath": normalizedPath,
|
||||||
|
});
|
||||||
|
return exists !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking if file is imported: ${filePath}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file name exists in the database
|
||||||
|
* @param fileName - File name without extension
|
||||||
|
* @returns true if file name is imported
|
||||||
|
*/
|
||||||
|
export async function isFileNameImported(fileName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const exists = await Comic.exists({
|
||||||
|
"rawFileDetails.name": fileName,
|
||||||
|
});
|
||||||
|
return exists !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking if file name is imported: ${fileName}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter array to only new (unimported) files
|
||||||
|
* @param files - Array of objects with path property
|
||||||
|
* @param importedPaths - Set of imported paths
|
||||||
|
* @returns Filtered array of new files
|
||||||
|
*/
|
||||||
|
export function filterNewFiles<T extends { path: string }>(
|
||||||
|
files: T[],
|
||||||
|
importedPaths: Set<string>
|
||||||
|
): T[] {
|
||||||
|
return files.filter((file) => {
|
||||||
|
const normalizedPath = path.normalize(file.path);
|
||||||
|
return !importedPaths.has(normalizedPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter array to only new files by name
|
||||||
|
* @param files - Array of objects with name property
|
||||||
|
* @param importedNames - Set of imported names
|
||||||
|
* @returns Filtered array of new files
|
||||||
|
*/
|
||||||
|
export function filterNewFilesByName<T extends { name: string }>(
|
||||||
|
files: T[],
|
||||||
|
importedNames: Set<string>
|
||||||
|
): T[] {
|
||||||
|
return files.filter((file) => !importedNames.has(file.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare local files against database to get import statistics
|
||||||
|
* Uses batch queries for better performance with large libraries
|
||||||
|
* @param localFilePaths - Array of local file paths
|
||||||
|
* @returns Statistics object with counts and imported paths Set
|
||||||
|
*/
|
||||||
|
export async function getImportStatistics(localFilePaths: string[]): Promise<{
|
||||||
|
total: number;
|
||||||
|
alreadyImported: number;
|
||||||
|
newFiles: number;
|
||||||
|
importedPaths: Set<string>;
|
||||||
|
}> {
|
||||||
|
console.log(`[Import Stats] Checking ${localFilePaths.length} files against database...`);
|
||||||
|
|
||||||
|
// Extract file names (without extension) from paths
|
||||||
|
// This matches how comics are stored in the database (rawFileDetails.name)
|
||||||
|
const fileNameToPath = new Map<string, string>();
|
||||||
|
const fileNames: string[] = [];
|
||||||
|
|
||||||
|
for (const filePath of localFilePaths) {
|
||||||
|
const fileName = path.basename(filePath, path.extname(filePath));
|
||||||
|
fileNames.push(fileName);
|
||||||
|
fileNameToPath.set(fileName, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Import Stats] Extracted ${fileNames.length} file names from paths`);
|
||||||
|
|
||||||
|
// Query by file name (matches how comics are checked during import)
|
||||||
|
const importedComics = await Comic.find(
|
||||||
|
{
|
||||||
|
"rawFileDetails.name": { $in: fileNames },
|
||||||
|
},
|
||||||
|
{ "rawFileDetails.name": 1, "rawFileDetails.filePath": 1, _id: 0 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
console.log(`[Import Stats] Found ${importedComics.length} matching comics in database`);
|
||||||
|
|
||||||
|
// Build Set of imported paths based on name matching
|
||||||
|
const importedPaths = new Set<string>();
|
||||||
|
const importedNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const comic of importedComics) {
|
||||||
|
if (comic.rawFileDetails?.name) {
|
||||||
|
importedNames.add(comic.rawFileDetails.name);
|
||||||
|
// Map back to the local file path
|
||||||
|
const localPath = fileNameToPath.get(comic.rawFileDetails.name);
|
||||||
|
if (localPath) {
|
||||||
|
importedPaths.add(localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyImported = importedPaths.size;
|
||||||
|
const newFiles = localFilePaths.length - alreadyImported;
|
||||||
|
|
||||||
|
console.log(`[Import Stats] Results: ${alreadyImported} already imported, ${newFiles} new files`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: localFilePaths.length,
|
||||||
|
alreadyImported,
|
||||||
|
newFiles,
|
||||||
|
importedPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch check multiple files in a single query (more efficient than individual checks)
|
||||||
|
* @param filePaths - Array of file paths to check
|
||||||
|
* @returns Map of filePath -> isImported boolean
|
||||||
|
*/
|
||||||
|
export async function batchCheckImported(
|
||||||
|
filePaths: string[]
|
||||||
|
): Promise<Map<string, boolean>> {
|
||||||
|
try {
|
||||||
|
const normalizedPaths = filePaths.map((p) => path.normalize(p));
|
||||||
|
|
||||||
|
// Query all at once
|
||||||
|
const importedComics = await Comic.find(
|
||||||
|
{
|
||||||
|
"rawFileDetails.filePath": { $in: normalizedPaths },
|
||||||
|
},
|
||||||
|
{ "rawFileDetails.filePath": 1, _id: 0 }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
// Create a map of imported paths
|
||||||
|
const importedSet = new Set(
|
||||||
|
importedComics
|
||||||
|
.map((c: any) => c.rawFileDetails?.filePath)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p: string) => path.normalize(p))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build result map
|
||||||
|
const resultMap = new Map<string, boolean>();
|
||||||
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
|
resultMap.set(filePaths[i], importedSet.has(normalizedPaths[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultMap;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error batch checking imported files:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find comics with files but missing canonical metadata
|
||||||
|
* @returns Array of comic documents needing re-import
|
||||||
|
*/
|
||||||
|
export async function getComicsNeedingReimport(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
// Find comics that have files but missing canonical metadata
|
||||||
|
const comics = await Comic.find({
|
||||||
|
"rawFileDetails.filePath": { $exists: true, $ne: null },
|
||||||
|
$or: [
|
||||||
|
{ canonicalMetadata: { $exists: false } },
|
||||||
|
{ "canonicalMetadata.title": { $exists: false } },
|
||||||
|
{ "canonicalMetadata.series": { $exists: false } },
|
||||||
|
],
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
console.log(`Found ${comics.length} comics needing re-import`);
|
||||||
|
return comics;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error finding comics needing re-import:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find files with same name but different paths
|
||||||
|
* @returns Array of duplicates with name, paths, and count
|
||||||
|
*/
|
||||||
|
export async function findDuplicateFiles(): Promise<
|
||||||
|
Array<{ name: string; paths: string[]; count: number }>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const duplicates = await Comic.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
"rawFileDetails.name": { $exists: true, $ne: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$rawFileDetails.name",
|
||||||
|
paths: { $push: "$rawFileDetails.filePath" },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
count: { $gt: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
name: "$_id",
|
||||||
|
paths: 1,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: { count: -1 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Found ${duplicates.length} duplicate file names`);
|
||||||
|
return duplicates;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error finding duplicate files:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
436
utils/metadata.resolution.utils.ts
Normal file
436
utils/metadata.resolution.utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ SOFTWARE.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createReadStream, createWriteStream, existsSync, statSync } from "fs";
|
import { createReadStream, createWriteStream, existsSync, statSync } from "fs";
|
||||||
|
import { execFile } from "child_process";
|
||||||
import { isEmpty, isNil, isUndefined, remove, each, map, reject } from "lodash";
|
import { isEmpty, isNil, isUndefined, remove, each, map, reject } from "lodash";
|
||||||
import * as p7zip from "p7zip-threetwo";
|
import * as p7zip from "p7zip-threetwo";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -88,124 +89,335 @@ export const extractComicInfoXMLFromRar = async (
|
|||||||
)}`;
|
)}`;
|
||||||
await createDirectory(directoryOptions, targetDirectory);
|
await createDirectory(directoryOptions, targetDirectory);
|
||||||
|
|
||||||
const archive = new Unrar({
|
// Try unrar-based extraction first, fall back to p7zip if it fails
|
||||||
path: path.resolve(filePath),
|
let unrarError: Error | null = null;
|
||||||
bin: `${UNRAR_BIN_PATH}`, // this will change depending on Docker base OS
|
try {
|
||||||
arguments: ["-v"],
|
const result = await extractComicInfoXMLFromRarUsingUnrar(
|
||||||
});
|
filePath,
|
||||||
const filesInArchive: [RarFile] = await new Promise(
|
mimeType,
|
||||||
(resolve, reject) => {
|
targetDirectory,
|
||||||
return archive.list((err, entries) => {
|
fileNameWithoutExtension,
|
||||||
if (err) {
|
extension
|
||||||
console.log(`DEBUG: ${JSON.stringify(err, null, 2)}`);
|
);
|
||||||
reject(err);
|
return result;
|
||||||
}
|
} catch (err) {
|
||||||
resolve(entries);
|
unrarError = err;
|
||||||
});
|
console.warn(
|
||||||
}
|
`unrar-based extraction failed for ${filePath}: ${err.message}. Falling back to p7zip.`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
remove(filesInArchive, ({ type }) => type === "Directory");
|
try {
|
||||||
const comicInfoXML = remove(
|
const result = await extractComicInfoXMLFromRarUsingP7zip(
|
||||||
filesInArchive,
|
filePath,
|
||||||
({ name }) => path.basename(name).toLowerCase() === "comicinfo.xml"
|
mimeType,
|
||||||
);
|
targetDirectory,
|
||||||
|
fileNameWithoutExtension,
|
||||||
|
extension
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (p7zipError) {
|
||||||
|
console.error(
|
||||||
|
`p7zip-based extraction also failed for ${filePath}: ${p7zipError.message}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to extract RAR archive: ${filePath}. ` +
|
||||||
|
`unrar error: ${unrarError?.message}. ` +
|
||||||
|
`p7zip error: ${p7zipError.message}. ` +
|
||||||
|
`Ensure 'unrar' is installed at ${UNRAR_BIN_PATH} or '7z' is available via SEVENZ_BINARY_PATH.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
remove(
|
/**
|
||||||
filesInArchive,
|
* List files in a RAR archive using the unrar binary directly.
|
||||||
({ name }) =>
|
* Uses `unrar lb` (bare list) for reliable output — one filename per line.
|
||||||
!IMPORT_IMAGE_FILE_FORMATS.includes(
|
*/
|
||||||
path.extname(name).toLowerCase()
|
const listRarFiles = (filePath: string): Promise<string[]> => {
|
||||||
)
|
return new Promise((resolve, reject) => {
|
||||||
);
|
execFile(
|
||||||
const files = filesInArchive.sort((a, b) => {
|
UNRAR_BIN_PATH,
|
||||||
if (!isUndefined(a) && !isUndefined(b)) {
|
["lb", path.resolve(filePath)],
|
||||||
return path
|
{ maxBuffer: 10 * 1024 * 1024 },
|
||||||
.basename(a.name)
|
(err, stdout, stderr) => {
|
||||||
.toLowerCase()
|
if (err) {
|
||||||
.localeCompare(path.basename(b.name).toLowerCase());
|
return reject(
|
||||||
}
|
new Error(
|
||||||
});
|
`unrar lb failed for ${filePath}: ${err.message}${stderr ? ` (stderr: ${stderr})` : ""}`
|
||||||
const comicInfoXMLFilePromise = new Promise((resolve, reject) => {
|
)
|
||||||
let comicinfostring = "";
|
|
||||||
if (!isUndefined(comicInfoXML[0])) {
|
|
||||||
const comicInfoXMLFileName = path.basename(
|
|
||||||
comicInfoXML[0].name
|
|
||||||
);
|
|
||||||
const writeStream = createWriteStream(
|
|
||||||
`${targetDirectory}/${comicInfoXMLFileName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
archive.stream(comicInfoXML[0]["name"]).pipe(writeStream);
|
|
||||||
writeStream.on("finish", async () => {
|
|
||||||
console.log(`Attempting to write comicInfo.xml...`);
|
|
||||||
const readStream = createReadStream(
|
|
||||||
`${targetDirectory}/${comicInfoXMLFileName}`
|
|
||||||
);
|
|
||||||
readStream.on("data", (data) => {
|
|
||||||
comicinfostring += data;
|
|
||||||
});
|
|
||||||
readStream.on("error", (error) => reject(error));
|
|
||||||
readStream.on("end", async () => {
|
|
||||||
if (
|
|
||||||
existsSync(
|
|
||||||
`${targetDirectory}/${comicInfoXMLFileName}`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const comicInfoJSON = await convertXMLToJSON(
|
|
||||||
comicinfostring.toString()
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`comicInfo.xml successfully written: ${comicInfoJSON.comicinfo}`
|
|
||||||
);
|
|
||||||
resolve({ comicInfoJSON: comicInfoJSON.comicinfo });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve({ comicInfoJSON: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const coverFilePromise = new Promise((resolve, reject) => {
|
|
||||||
const coverFile = path.basename(files[0].name);
|
|
||||||
const sharpStream = sharp().resize(275).toFormat("png");
|
|
||||||
const coverExtractionStream = archive.stream(files[0].name);
|
|
||||||
const resizeStream = coverExtractionStream.pipe(sharpStream);
|
|
||||||
resizeStream.toFile(
|
|
||||||
`${targetDirectory}/${coverFile}`,
|
|
||||||
(err, info) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
checkFileExists(`${targetDirectory}/${coverFile}`).then(
|
|
||||||
(bool) => {
|
|
||||||
console.log(`${coverFile} exists: ${bool}`);
|
|
||||||
// orchestrate result
|
|
||||||
resolve({
|
|
||||||
filePath,
|
|
||||||
name: fileNameWithoutExtension,
|
|
||||||
extension,
|
|
||||||
containedIn: targetDirectory,
|
|
||||||
fileSize: fse.statSync(filePath).size,
|
|
||||||
mimeType,
|
|
||||||
cover: {
|
|
||||||
filePath: path.relative(
|
|
||||||
process.cwd(),
|
|
||||||
`${targetDirectory}/${coverFile}`
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
const files = stdout
|
||||||
});
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
resolve(files);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return Promise.all([comicInfoXMLFilePromise, coverFilePromise]);
|
/**
|
||||||
} catch (err) {
|
* Extract a single file from a RAR archive to stdout as a Buffer.
|
||||||
reject(err);
|
* Uses `unrar p -inul` (print to stdout, no messages).
|
||||||
|
*/
|
||||||
|
const extractRarFileToBuffer = (
|
||||||
|
filePath: string,
|
||||||
|
entryName: string
|
||||||
|
): Promise<Buffer> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
UNRAR_BIN_PATH,
|
||||||
|
["p", "-inul", path.resolve(filePath), entryName],
|
||||||
|
{ maxBuffer: 50 * 1024 * 1024, encoding: "buffer" },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(
|
||||||
|
new Error(
|
||||||
|
`unrar p failed for ${entryName} in ${filePath}: ${err.message}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(stdout as unknown as Buffer);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract comic info and cover from a RAR archive using the unrar binary directly.
|
||||||
|
* Bypasses the `unrar` npm package which has parsing bugs.
|
||||||
|
*/
|
||||||
|
const extractComicInfoXMLFromRarUsingUnrar = async (
|
||||||
|
filePath: string,
|
||||||
|
mimeType: string,
|
||||||
|
targetDirectory: string,
|
||||||
|
fileNameWithoutExtension: string,
|
||||||
|
extension: string
|
||||||
|
): Promise<any> => {
|
||||||
|
// List all files in the archive using bare listing
|
||||||
|
const allFiles = await listRarFiles(filePath);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`RAR (unrar direct): ${allFiles.length} total entries in ${filePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find ComicInfo.xml
|
||||||
|
const comicInfoXMLEntry = allFiles.find(
|
||||||
|
(name) => path.basename(name).toLowerCase() === "comicinfo.xml"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to image files only
|
||||||
|
const imageFiles = allFiles
|
||||||
|
.filter((name) =>
|
||||||
|
IMPORT_IMAGE_FILE_FORMATS.includes(
|
||||||
|
path.extname(name).toLowerCase()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
path
|
||||||
|
.basename(a)
|
||||||
|
.toLowerCase()
|
||||||
|
.localeCompare(path.basename(b).toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No image files found via unrar in RAR archive: ${filePath}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract and parse ComicInfo.xml if present
|
||||||
|
let comicInfoResult: { comicInfoJSON: any } = { comicInfoJSON: null };
|
||||||
|
if (comicInfoXMLEntry) {
|
||||||
|
try {
|
||||||
|
const xmlBuffer = await extractRarFileToBuffer(
|
||||||
|
filePath,
|
||||||
|
comicInfoXMLEntry
|
||||||
|
);
|
||||||
|
const comicInfoJSON = await convertXMLToJSON(
|
||||||
|
xmlBuffer.toString("utf-8")
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`comicInfo.xml successfully extracted: ${comicInfoJSON.comicinfo}`
|
||||||
|
);
|
||||||
|
comicInfoResult = { comicInfoJSON: comicInfoJSON.comicinfo };
|
||||||
|
} catch (xmlErr) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to extract ComicInfo.xml from ${filePath}: ${xmlErr.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and resize cover image (first image file)
|
||||||
|
const coverEntryName = imageFiles[0];
|
||||||
|
const coverFile = path.basename(coverEntryName);
|
||||||
|
const coverBaseName = sanitize(path.basename(coverFile, path.extname(coverFile)));
|
||||||
|
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`;
|
||||||
|
|
||||||
|
const coverBuffer = await extractRarFileToBuffer(
|
||||||
|
filePath,
|
||||||
|
coverEntryName
|
||||||
|
);
|
||||||
|
|
||||||
|
await sharp(coverBuffer)
|
||||||
|
.resize(275)
|
||||||
|
.toFormat("png")
|
||||||
|
.toFile(coverOutputFile);
|
||||||
|
|
||||||
|
console.log(`${coverFile} cover written to: ${coverOutputFile}`);
|
||||||
|
|
||||||
|
const relativeCoverPath = path.relative(process.cwd(), coverOutputFile);
|
||||||
|
console.log(`RAR cover path (relative): ${relativeCoverPath}`);
|
||||||
|
console.log(`RAR cover file exists: ${existsSync(coverOutputFile)}`);
|
||||||
|
|
||||||
|
const coverResult = {
|
||||||
|
filePath,
|
||||||
|
name: fileNameWithoutExtension,
|
||||||
|
extension,
|
||||||
|
containedIn: targetDirectory,
|
||||||
|
fileSize: fse.statSync(filePath).size,
|
||||||
|
mimeType,
|
||||||
|
cover: {
|
||||||
|
filePath: relativeCoverPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [comicInfoResult, coverResult];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: Extract comic info and cover from a RAR archive using p7zip (7z).
|
||||||
|
* Uses the same approach as extractComicInfoXMLFromZip since p7zip handles RAR files.
|
||||||
|
*/
|
||||||
|
const extractComicInfoXMLFromRarUsingP7zip = async (
|
||||||
|
filePath: string,
|
||||||
|
mimeType: string,
|
||||||
|
targetDirectory: string,
|
||||||
|
fileNameWithoutExtension: string,
|
||||||
|
extension: string
|
||||||
|
): Promise<any> => {
|
||||||
|
let filesToWriteToDisk = { coverFile: null, comicInfoXML: null };
|
||||||
|
const extractionTargets = [];
|
||||||
|
|
||||||
|
// read the archive using p7zip (supports RAR)
|
||||||
|
let filesFromArchive = await p7zip.read(path.resolve(filePath));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`RAR (p7zip): ${filesFromArchive.files.length} total entries in ${filePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// detect ComicInfo.xml
|
||||||
|
const comicInfoXMLFileObject = remove(
|
||||||
|
filesFromArchive.files,
|
||||||
|
(file) => path.basename(file.name.toLowerCase()) === "comicinfo.xml"
|
||||||
|
);
|
||||||
|
// only allow allowed image formats
|
||||||
|
remove(
|
||||||
|
filesFromArchive.files,
|
||||||
|
({ name }) =>
|
||||||
|
!IMPORT_IMAGE_FILE_FORMATS.includes(
|
||||||
|
path.extname(name).toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Natural sort
|
||||||
|
const files = filesFromArchive.files.sort((a, b) => {
|
||||||
|
if (!isUndefined(a) && !isUndefined(b)) {
|
||||||
|
return path
|
||||||
|
.basename(a.name)
|
||||||
|
.toLowerCase()
|
||||||
|
.localeCompare(path.basename(b.name).toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`No image files found in RAR archive: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the first file (cover) to our extraction target
|
||||||
|
extractionTargets.push(files[0].name);
|
||||||
|
filesToWriteToDisk.coverFile = path.basename(files[0].name);
|
||||||
|
|
||||||
|
if (!isEmpty(comicInfoXMLFileObject)) {
|
||||||
|
filesToWriteToDisk.comicInfoXML = comicInfoXMLFileObject[0].name;
|
||||||
|
extractionTargets.push(filesToWriteToDisk.comicInfoXML);
|
||||||
|
}
|
||||||
|
// Extract the files.
|
||||||
|
await p7zip.extract(
|
||||||
|
filePath,
|
||||||
|
targetDirectory,
|
||||||
|
extractionTargets,
|
||||||
|
"",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// ComicInfoXML detection, parsing and conversion to JSON
|
||||||
|
const comicInfoXMLPromise = new Promise((resolve, reject) => {
|
||||||
|
if (
|
||||||
|
!isNil(filesToWriteToDisk.comicInfoXML) &&
|
||||||
|
existsSync(
|
||||||
|
`${targetDirectory}/${path.basename(
|
||||||
|
filesToWriteToDisk.comicInfoXML
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
let comicinfoString = "";
|
||||||
|
const comicInfoXMLStream = createReadStream(
|
||||||
|
`${targetDirectory}/${path.basename(
|
||||||
|
filesToWriteToDisk.comicInfoXML
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
comicInfoXMLStream.on(
|
||||||
|
"data",
|
||||||
|
(data) => (comicinfoString += data)
|
||||||
|
);
|
||||||
|
comicInfoXMLStream.on("end", async () => {
|
||||||
|
const comicInfoJSON = await convertXMLToJSON(
|
||||||
|
comicinfoString.toString()
|
||||||
|
);
|
||||||
|
resolve({
|
||||||
|
comicInfoJSON: comicInfoJSON.comicinfo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
comicInfoJSON: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write the cover to disk
|
||||||
|
const coverBaseName = sanitize(path.basename(
|
||||||
|
filesToWriteToDisk.coverFile,
|
||||||
|
path.extname(filesToWriteToDisk.coverFile)
|
||||||
|
));
|
||||||
|
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`;
|
||||||
|
const coverInputFile = `${targetDirectory}/${filesToWriteToDisk.coverFile}`;
|
||||||
|
|
||||||
|
await sharp(coverInputFile)
|
||||||
|
.resize(275)
|
||||||
|
.toFormat("png")
|
||||||
|
.toFile(coverOutputFile);
|
||||||
|
|
||||||
|
const comicInfoResult = await comicInfoXMLPromise;
|
||||||
|
|
||||||
|
const coverResult = {
|
||||||
|
filePath,
|
||||||
|
name: fileNameWithoutExtension,
|
||||||
|
extension,
|
||||||
|
mimeType,
|
||||||
|
containedIn: targetDirectory,
|
||||||
|
fileSize: fse.statSync(filePath).size,
|
||||||
|
cover: {
|
||||||
|
filePath: path.relative(process.cwd(), coverOutputFile),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [comicInfoResult, coverResult];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractComicInfoXMLFromZip = async (
|
export const extractComicInfoXMLFromZip = async (
|
||||||
@@ -252,6 +464,11 @@ export const extractComicInfoXMLFromZip = async (
|
|||||||
.localeCompare(path.basename(b.name).toLowerCase());
|
.localeCompare(path.basename(b.name).toLowerCase());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`No image files found in ZIP archive: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Push the first file (cover) to our extraction target
|
// Push the first file (cover) to our extraction target
|
||||||
extractionTargets.push(files[0].name);
|
extractionTargets.push(files[0].name);
|
||||||
filesToWriteToDisk.coverFile = path.basename(files[0].name);
|
filesToWriteToDisk.coverFile = path.basename(files[0].name);
|
||||||
@@ -306,45 +523,32 @@ export const extractComicInfoXMLFromZip = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Write the cover to disk
|
// Write the cover to disk
|
||||||
const coverFilePromise = new Promise((resolve, reject) => {
|
const coverBaseName = sanitize(path.basename(filesToWriteToDisk.coverFile, path.extname(filesToWriteToDisk.coverFile)));
|
||||||
const sharpStream = sharp().resize(275).toFormat("png");
|
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`;
|
||||||
const coverStream = createReadStream(
|
const coverInputFile = `${targetDirectory}/${filesToWriteToDisk.coverFile}`;
|
||||||
`${targetDirectory}/${filesToWriteToDisk.coverFile}`
|
|
||||||
);
|
|
||||||
coverStream
|
|
||||||
.pipe(sharpStream)
|
|
||||||
.toFile(
|
|
||||||
`${targetDirectory}/${path.basename(
|
|
||||||
filesToWriteToDisk.coverFile
|
|
||||||
)}`,
|
|
||||||
(err, info) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
// Update metadata
|
|
||||||
resolve({
|
|
||||||
filePath,
|
|
||||||
name: fileNameWithoutExtension,
|
|
||||||
extension,
|
|
||||||
mimeType,
|
|
||||||
containedIn: targetDirectory,
|
|
||||||
fileSize: fse.statSync(filePath).size,
|
|
||||||
cover: {
|
|
||||||
filePath: path.relative(
|
|
||||||
process.cwd(),
|
|
||||||
`${targetDirectory}/${path.basename(
|
|
||||||
filesToWriteToDisk.coverFile
|
|
||||||
)}`
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.all([comicInfoXMLPromise, coverFilePromise]);
|
await sharp(coverInputFile)
|
||||||
|
.resize(275)
|
||||||
|
.toFormat("png")
|
||||||
|
.toFile(coverOutputFile);
|
||||||
|
|
||||||
|
const comicInfoResult = await comicInfoXMLPromise;
|
||||||
|
|
||||||
|
const coverResult = {
|
||||||
|
filePath,
|
||||||
|
name: fileNameWithoutExtension,
|
||||||
|
extension,
|
||||||
|
mimeType,
|
||||||
|
containedIn: targetDirectory,
|
||||||
|
fileSize: fse.statSync(filePath).size,
|
||||||
|
cover: {
|
||||||
|
filePath: path.relative(process.cwd(), coverOutputFile),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [comicInfoResult, coverResult];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,6 +565,9 @@ export const extractFromArchive = async (filePath: string) => {
|
|||||||
filePath,
|
filePath,
|
||||||
mimeType
|
mimeType
|
||||||
);
|
);
|
||||||
|
if (!Array.isArray(cbzResult)) {
|
||||||
|
throw new Error(`extractComicInfoXMLFromZip returned a non-iterable result for: ${filePath}`);
|
||||||
|
}
|
||||||
return Object.assign({}, ...cbzResult);
|
return Object.assign({}, ...cbzResult);
|
||||||
|
|
||||||
case "application/x-rar; charset=binary":
|
case "application/x-rar; charset=binary":
|
||||||
@@ -368,6 +575,9 @@ export const extractFromArchive = async (filePath: string) => {
|
|||||||
filePath,
|
filePath,
|
||||||
mimeType
|
mimeType
|
||||||
);
|
);
|
||||||
|
if (!Array.isArray(cbrResult)) {
|
||||||
|
throw new Error(`extractComicInfoXMLFromRar returned a non-iterable result for: ${filePath}`);
|
||||||
|
}
|
||||||
return Object.assign({}, ...cbrResult);
|
return Object.assign({}, ...cbrResult);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -419,7 +629,7 @@ export const uncompressZipArchive = async (filePath: string, options: any) => {
|
|||||||
mode: 0o2775,
|
mode: 0o2775,
|
||||||
};
|
};
|
||||||
const { fileNameWithoutExtension } = getFileConstituents(filePath);
|
const { fileNameWithoutExtension } = getFileConstituents(filePath);
|
||||||
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${fileNameWithoutExtension}`;
|
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${sanitize(fileNameWithoutExtension)}`;
|
||||||
await createDirectory(directoryOptions, targetDirectory);
|
await createDirectory(directoryOptions, targetDirectory);
|
||||||
await p7zip.extract(filePath, targetDirectory, [], "", false);
|
await p7zip.extract(filePath, targetDirectory, [], "", false);
|
||||||
|
|
||||||
@@ -433,7 +643,7 @@ export const uncompressRarArchive = async (filePath: string, options: any) => {
|
|||||||
};
|
};
|
||||||
const { fileNameWithoutExtension, extension } =
|
const { fileNameWithoutExtension, extension } =
|
||||||
getFileConstituents(filePath);
|
getFileConstituents(filePath);
|
||||||
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${fileNameWithoutExtension}`;
|
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${sanitize(fileNameWithoutExtension)}`;
|
||||||
await createDirectory(directoryOptions, targetDirectory);
|
await createDirectory(directoryOptions, targetDirectory);
|
||||||
|
|
||||||
const archive = new Unrar({
|
const archive = new Unrar({
|
||||||
|
|||||||
Reference in New Issue
Block a user