128 Commits

Author SHA1 Message Date
Rishi Ghan
664da47ea2 Added e2e tests for filewatcher
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-15 12:35:25 -04:00
Rishi Ghan
c4cf233053 Refactored file watcher code
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-15 10:59:48 -04:00
Rishi Ghan
83f905ebb6 getDirectoryStatus endpoint
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-14 17:28:44 -04:00
Rishi Ghan
735f0dbb38 Fix for dep docker compose
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-14 12:39:40 -04:00
a70d469c36 Added metadata reconciliation types
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-14 07:09:40 -04:00
bf7e57a274 📖 Metadata field mapping of popular sources
Some checks failed
Docker Image CI / build (push) Has been cancelled
2026-04-03 18:15:49 -04:00
bfaf7bb664 🔨 Fix for schema stitching upon init 2026-03-24 10:58:09 -04:00
e54063c1a4 📛 Added comicsMissingFiles in lib stats response 2026-03-09 21:54:41 -04:00
7f86497cfc 🔢 Fix for disk size consumed by comics 2026-03-09 21:32:41 -04:00
c9f323e610 🔨 graphql refactor for missingFiles flag 2026-03-09 17:11:14 -04:00
e5e4e82f11 🔍 Missing file watching logic for chokidar 2026-03-07 21:09:15 -05:00
42c427c7ea removed useless files 2026-03-05 21:49:32 -05:00
c7d3d46bcf 🔨 Fixes for import statuses 2026-03-05 21:29:38 -05:00
8138e0fe4f 📚 Import stats hardening 2026-03-05 15:05:12 -05:00
71267ecc7e ⬇ Import flow fixes 2026-03-05 13:33:48 -05:00
cc30dcc14f 📈 Added real time import stats and stats cache 2026-03-05 12:40:57 -05:00
a2f9be71ed 📕 Added terse JSDoc to graphql.service 2026-03-05 11:07:41 -05:00
965565f7b8 🪢 Fixed schema stitching and added JSDoc 2026-03-05 10:45:51 -05:00
17f80682e1 🐘 graphql consolidation, validators and cleanup 2026-03-05 10:39:33 -05:00
8a8acc656a 🍱 graphql schema stitching related changes 2026-03-05 01:00:04 -05:00
22cbdcd468 🪢Added resolvers for lib, dashboard endpoints 2026-03-04 21:49:38 -05:00
8c224bad68 🔨 Fixed import flow 2026-02-27 00:00:46 -05:00
a1fa12f181 🔨 Fixes to import 2026-02-26 23:56:39 -05:00
f7804ee3f0 🛠 graphql changes 2026-02-24 16:29:48 -05:00
cd446a9ca3 🛠 Fixes for GraphQL related schema changes 2026-02-17 12:05:28 -05:00
136a7f494f 🐳 Added graphql deps 2025-07-14 11:58:42 -04:00
b332d9d75a 📜 Added JsDoc to methods 2025-06-10 13:55:11 -04:00
a0671ce6d1 👀 Refactoring file watcher code 2025-06-10 13:30:31 -04:00
999af29800 ⬇️ Fixing ADC++ socket download notifications 2025-06-03 21:57:44 -04:00
7313fc4df7 🧦 Changes to socket service to support UI 2025-05-18 20:46:37 -04:00
8b8f470f52 😂 IDK anymore 2025-02-25 16:00:37 -05:00
a2eae27c31 🏗️ Added a builder step 2025-02-25 15:36:44 -05:00
58168b1a9c 💻 Switched back to x86_64 2025-02-25 14:31:45 -05:00
bd62866340 🔧 Yet another fix for sharp 2025-02-25 14:18:44 -05:00
77d21d3046 🗻 Switched to node 21 alpine 2025-02-25 13:45:35 -05:00
030f89b258 🤷🏼 YOLO 2025-02-25 12:22:17 -05:00
a702f724f7 🪓 Arch change 2025-02-24 17:52:03 -05:00
d0b4219aef 🔧 Fixed Dockerfile 2025-02-24 17:29:48 -05:00
09d7fa2772 🪓 Attempting to get sharp installed in the image 2025-02-24 15:20:54 -05:00
b0c56f65c4 🔧 Update for libsharp for arm64 2025-02-24 14:05:07 -05:00
10ff192ce1 Bumped up elasticsearch to 8.17.2 2025-02-20 12:37:36 -05:00
1d48499c64 Revert "Merge branch 'master' into getbundles-fix"
This reverts commit 30168844f3, reversing
changes made to 2e60e2e3d5.
2024-10-24 10:59:09 -04:00
c9ecbb911a Merge pull request #12 from rishighan/getbundles-fix
getBundles Fix
2024-10-24 10:50:29 -04:00
30168844f3 Merge branch 'master' into getbundles-fix 2024-10-24 10:47:31 -04:00
2e60e2e3d5 Added package-lock.json 2024-10-24 10:45:19 -04:00
8254ec2093 ⌫ package.json deleted 2024-10-24 10:41:59 -04:00
7381d03045 🔧 Fixed getBundles endpoint 2024-10-23 23:14:21 -04:00
d7e865f84f 🔧 Prettification 2024-10-23 14:26:24 -04:00
baa5a99855 🔧 Removed indirection for getBundles 2024-10-23 13:42:05 -04:00
68c2dacff4 🔧 getBundles endpoint WIP 2024-10-21 18:04:16 -04:00
55e0ce6d36 🖌️ Formatting changes 2024-10-18 13:19:57 -04:00
4ffad69c44 🔧 Todo to move the method from UI 2024-10-16 18:50:14 -04:00
f9438f2129 🔧 Fixing broken AirDCPP search 2024-09-26 21:33:02 -04:00
2247411ac8 🪳 Added kafka to the docker-compose deps 2024-05-28 08:40:33 -04:00
e61ecb1143 🔧 Refactor for docker-compose 2024-05-24 14:22:59 -04:00
e01421f17b 🐳 Added all other deps 2024-05-23 23:15:03 -04:00
cc271021e0 🐳 Created a deps docker-compose stack 2024-05-19 21:19:15 -04:00
cc772504ae 🔧 Fixed the Redis disconnection issue 2024-05-17 01:26:16 -04:00
8dcb17a6a0 🔧 Reverted 2024-05-16 14:15:09 -04:00
a06896ffcc 🔧 Reverting to nats for transporter needs 2024-05-16 14:03:07 -04:00
03f6623ed0 🔧 Fixes 2024-05-15 21:27:38 -05:00
66f9d63b44 🔧 Debuggin Redis connectivity issue 2024-05-15 16:58:49 -05:00
a936df3144 🪲 Added a console.log 2024-05-15 12:13:50 -05:00
dc9dabca48 🔧 Fixed REDIS_URI 2024-05-15 12:00:51 -05:00
4680fd0875 ⬅️ Reverted changes 2024-05-15 11:47:08 -05:00
323548c0ff 🔧 WIP Dockerfile fixes 2024-05-15 11:32:11 -05:00
f4563c12c6 🔧 Added startup scripts fixing MongoDB timeouts 2024-05-12 23:35:01 -04:00
1b0cada848 🏗️ Added validation to db mixin 2024-05-11 13:31:10 -04:00
750a74cd9f Merge pull request #10 from rishighan/automated-download-loop
Automated download loop
2024-05-10 22:59:08 -04:00
402ee4d81b 🏗️ Updated Dockerfile 2024-05-10 22:55:57 -04:00
1fa35ac0e3 🏗️ Automatic downloads endpoint support 2024-05-09 13:49:26 -04:00
680594e67c 🔧 Added wiring for AirDC++ service 2024-04-23 22:47:32 -05:00
5593fcb4a0 🔧 Added DC++ search and download actions 2024-04-17 21:14:48 -05:00
d7f3d3a7cf 🔧 Modified Comic model 2024-04-16 22:41:33 -05:00
94cb95f4bf 📚 Changes to CV model 2024-04-14 00:25:41 -04:00
c6651cdd91 Merge pull request #9 from rishighan/qbittorrent-settings
🏗️ Added torrent attrs to comic model
2024-03-30 21:40:02 -04:00
b35e2140b5 🧲 Created a dedicated queue for torrent ops 2024-03-29 19:36:16 -04:00
f053dcb789 🧲 Massaging data to be sent to UI 2024-03-27 22:22:40 -05:00
aea7a24f76 🧲 Added a job for deleted torrents clean-up 2024-03-24 17:31:31 -04:00
8f0c2f4302 ⚙️ getAllSettings is parameterized 2024-03-12 16:39:44 -05:00
7dbe2b2701 🏗️ Added torrent attrs to comic model 2024-03-03 12:22:40 -05:00
5b9ef9fbbb Merge pull request #8 from rishighan/qbittorrent-settings
Miscellaneous Settings
2024-01-08 16:42:54 -05:00
4cdb11fcbd Cleaned the console.logs 2024-01-08 16:40:12 -05:00
78f7c1b595 🤐 Added uncompression event 2024-01-07 22:13:02 -05:00
bbd2906ebf 🏗️ Added some archive-related keys to Comic model 2024-01-06 11:17:40 -05:00
1861c2eeed Merge pull request #7 from rishighan/qbittorrent-settings
🌊 Modified settings model schema
2023-12-30 00:52:17 -05:00
f3965437b5 🏗 Added a job for full archive extraction 2023-12-30 00:50:06 -05:00
78e0e9f8ce 🏗️ Refactored the searchIssue method 2023-12-28 22:52:33 -05:00
c926758db6 🏗️ Added a downloads array to bittorent schema 2023-12-20 00:08:38 -05:00
b2b35aedc0 🏗️ Fixed a mongo update query 2023-11-27 02:14:16 -05:00
f35e3ccbe0 Removed useless code 2023-11-15 16:02:07 -06:00
7b0c0a7420 Added the importSingleIssue action 2023-11-15 15:59:27 -06:00
c2bbbf311d 🏗️ Fixed setQueueStatus 2023-11-14 13:24:49 -06:00
b8ca03220f 🏗 Implemented setQueueStatus 2023-11-13 22:01:01 -05:00
b87b0c875d 🏗️ Fleshed out resumeSession event 2023-11-13 21:18:19 -05:00
11fbaf10db 🏗 Wired up the events correctly 2023-11-13 16:41:58 -05:00
1229feb69c 🏗️ Refactor for zustand and tanstack react query support 2023-11-09 10:22:45 -06:00
3efdc7c2e2 ⚙️ Refactored saveSettings endpoint 2023-09-15 15:49:13 -04:00
1fff931941 🌊 Modified settings model schema 2023-09-13 22:09:25 -05:00
f4e2db5a5f 📦 Instruction for paths for unrar and p7zip 2023-09-01 09:44:02 -05:00
1d7561279b 📕 Updated local dev instructions in README 2023-09-01 09:22:02 -05:00
9e47ae0436 Merge pull request #6 from rishighan/migration-to-bullmq
🐂 Migration to moleculer-bullMQ
2023-08-30 13:50:47 -04:00
b1b1cb527b 🔧 Moved moleculer-bullmq to dependencies 2023-08-30 13:16:58 -04:00
cfa09691e8 🔧 Fixed an errant condition
This error was because I checked for active AND prioritized jobs in BullMQ, when none existed, because everything was active, and the socket.io event never fired, causing the browser to be in a bad state and never "resuming" an import even when one was in progress.
2023-08-30 12:21:43 -04:00
356b093db9 🔧 Fixed a dep 2023-08-30 00:08:05 -04:00
b4b83e5c75 🔧 Reworked the jobResults aggregation 2023-08-29 23:58:06 -04:00
c718456adc 🔧 jobResult aggregate query first draft 2023-08-28 23:56:44 -04:00
76d4e6b10f 🔢 Persisting the sessionId in the JobResult 2023-08-27 20:25:04 -04:00
bde548695c 🔧 Refactored the getJobResultStatistics method 2023-08-24 23:45:51 -04:00
fd4dd1d5c4 🥭 Aggregation for jobResult 2023-08-24 23:18:27 -04:00
5540bb16ec ⏱️ Added a timestamp to job results schema 2023-08-24 09:06:38 -05:00
6ee609f2b9 🔧 Refactor and added getJobCounts 2023-08-23 11:47:47 -05:00
8b584080e2 🐂 Queue controls 2023-08-22 22:07:51 -05:00
01975079e3 🐂 Queue drain event 2023-08-22 05:20:24 -04:00
fe9fbe9c3a 🔢 Getting job counts 2023-08-22 00:04:47 -04:00
df6652cce9 🐂 Queue pause/resume functionality 2023-08-21 17:55:08 -04:00
e5fc879b2d 🐂 Added some job counters 2023-08-18 11:39:18 -04:00
625447e7f1 🧊 Added shared Redis config 2023-08-14 22:15:19 -04:00
fdcf1f7d68 🧹 Linted code 2023-08-14 19:45:40 -04:00
4003f666cf 🔧 Tooling for resumable socket.io sessions 2023-07-27 11:09:26 -07:00
7b855f8cf1 🐂 BullMQ support code 2023-07-13 08:02:12 -07:00
007ce4b66f 🏗️ Applying the refactor patc 2023-07-05 23:14:46 -04:00
cb84e4893f 🐂 Migration to moleculer-bullMQ 2023-06-29 14:16:58 -04:00
795ac561c7 🔼 Updated deps 2023-04-19 09:14:22 -04:00
175e01dc2d Added elasticsearch dep 2023-04-09 15:53:36 -04:00
66e0a26c68 Merge pull request #4 from elgohr-update/master 2023-04-04 17:46:31 -04:00
Lars Gohr
959d248273 Updated elgohr/Publish-Docker-Github-Action to a supported version (v5) 2023-03-30 06:52:23 +02:00
745ec5d774 Merge pull request #3 from rishighan/mimetype-check
 MIMEtype check for comic book archives
2023-03-23 23:59:30 -04:00
49 changed files with 17045 additions and 4318 deletions

View File

@@ -2,6 +2,7 @@ node_modules
comics/*
userdata/*
npm-debug.log
logs/*
Dockerfile
.dockerignore
.git

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@master
uses: elgohr/Publish-Docker-Github-Action@v5
with:
name: frishi/threetwo-core-service
username: ${{ secrets.DOCKER_USERNAME }}

4
.gitignore vendored
View File

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

View File

@@ -1,40 +1,104 @@
FROM alpine:3.14
# Use a non-ARM image (x86_64) for Node.js
FROM --platform=linux/amd64 node:21-alpine3.18 AS builder
# Set metadata for contact
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Show all node logs
ENV NPM_CONFIG_LOGLEVEL warn
# Set environment variables
ENV NPM_CONFIG_LOGLEVEL=warn
ENV NODE_ENV=production
# Set the working directory
WORKDIR /core-services
RUN apk add --update \
--repository http://nl.alpinelinux.org/alpine/v3.14/main \
vips-tools \
# Install required dependencies using apk
RUN apk update && apk add --no-cache \
bash \
wget \
imagemagick \
python3 \
unrar \
p7zip \
nodejs \
npm \
xvfb \
xz
build-base \
g++ \
python3-dev \
p7zip \
curl \
git \
glib \
cairo-dev \
pango-dev \
icu-dev \
pkgconfig
# Install libvips from source
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
RUN wget https://www.rarlab.com/rar/rarlinux-x64-621.tar.gz \
&& tar -zxvf rarlinux-x64-621.tar.gz \
&& cp rar/unrar /usr/bin/ \
&& rm -rf rarlinux-x64-621.tar.gz rar
# Verify Node.js installation
RUN node -v && npm -v
# Copy application configuration files
COPY package.json package-lock.json ./
COPY moleculer.config.ts ./
COPY tsconfig.json ./
RUN npm i
# Install Dependncies
# Install application dependencies
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
# Copy the rest of the application files (e.g., source code)
COPY . .
# Build and cleanup
RUN npm run build \
&& npm prune
# Build the app
RUN npm run build
# 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 3000
# Start server
CMD ["npm", "start"]
# Command to run the application (this will now work)
CMD ["npm", "start"]

View File

@@ -3,21 +3,35 @@
This [moleculer-based](https://github.com/moleculerjs/moleculer-web) microservice houses endpoints for the following functions:
1. Local import of a comic library into mongo (currently supports `cbr` and `cbz` files)
2. Metadata extraction from file, `comicinfo.xml`
2. Metadata extraction from file, `comicinfo.xml`
3. Mongo comic object orchestration
4. CRUD operations on `Comic` model
5. Helper utils to help with image metadata extraction, file operations and more.
## Local Development
1. ~~You need `calibre` in your local path.
On `macOS` you can `brew install calibre` and make sure that `ebook-meta` is present on the path~~ Calibre is no longer required as a dependency. Ignore this step.
2. You need `mongo` for the data store. on `macOS` you can use [these instructions](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/) to install it
1. You need the following dependencies installed: `mongo`, `elasticsearch` and `redis`
2. You also need binaries for `unrar` and `p7zip`
3. Clone this repo
4. Run `npm i`
5. Assuming you installed mongo correctly, run `MONGO_URI=mongodb://localhost:27017/threetwo npm run dev` to start the service
5. Assuming you installed the dependencies correctly, run:
```
COMICS_DIRECTORY=<PATH_TO_COMICS_DIRECTORY> \
USERDATA_DIRECTORY=<PATH_TO_USERDATA_DIRECTORY> \
REDIS_URI=redis://<REDIS_HOST:REDIS_PORT> \
ELASTICSEARCH_URI=<ELASTICSEARCH_HOST:ELASTICSEARCH_PORT> \
MONGO_URI=mongodb://<MONGO_HOST:MONGO_PORT>/threetwo \
UNRAR_BIN_PATH=<UNRAR_BIN_PATH> \
SEVENZ_BINARY_PATH=<SEVENZ_BINARY_PATH> \
npm run dev
```
to start the service
6. You should see the service spin up and a list of all the endpoints in the terminal
7. The service can be accessed through `http://localhost:3000/api/import/*`
7. The service can be accessed through `http://localhost:3000/api/<serviceName>/*`
## Docker Instructions
1. Build the image using `docker build . -t frishi/threetwo-import-service`. Give it a hot minute.

176
config/graphql.config.ts Normal file
View 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;
}

10
config/redis.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createClient } from "redis";
const redisURL = new URL(process.env.REDIS_URI);
const pubClient = createClient({ url: `redis://${redisURL.hostname}:6379` });
(async () => {
await pubClient.connect();
})();
const subClient = pubClient.duplicate();
export { subClient, pubClient };

View File

@@ -1,2 +1,2 @@
export const COMICS_DIRECTORY = "./comics";
export const USERDATA_DIRECTORY = "./userdata";
export const COMICS_DIRECTORY = process.env.COMICS_DIRECTORY || "./comics";
export const USERDATA_DIRECTORY = process.env.USERDATA_DIRECTORY || "./userdata";

View File

@@ -0,0 +1,97 @@
services:
zoo1:
image: confluentinc/cp-zookeeper:7.3.2
hostname: zoo1
container_name: zoo1
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_SERVER_ID: 1
ZOOKEEPER_SERVERS: zoo1:2888:3888
networks:
- kafka-net
kafka1:
image: confluentinc/cp-kafka:7.3.2
hostname: kafka1
container_name: kafka1
ports:
- "127.0.0.1:9092:9092" # exposed ONLY to host localhost
environment:
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_INTERNAL://0.0.0.0:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka1:29092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
KAFKA_BROKER_ID: 1
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
depends_on:
- zoo1
networks:
- kafka-net
kafka-ui:
container_name: kafka-ui
image: provectuslabs/kafka-ui:latest
ports:
- 8087:8080
environment:
DYNAMIC_CONFIG_ENABLED: true
volumes:
- /Users/rishi/work/config/kafka-ui/config.yml:/etc/kafkaui/dynamic_config.yaml
depends_on:
- kafka1
- zoo1
networks:
- kafka-net
db:
image: "mongo:latest"
container_name: database
networks:
- kafka-net
ports:
- "27017:27017"
volumes:
- "mongodb_data:/bitnami/mongodb"
redis:
image: "bitnami/redis:latest"
container_name: queue
environment:
ALLOW_EMPTY_PASSWORD: "yes"
networks:
- kafka-net
ports:
- "6379:6379"
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.2
container_name: elasticsearch
environment:
- "discovery.type=single-node"
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "xpack.security.enabled=true"
- "xpack.security.authc.api_key.enabled=true"
- "ELASTIC_PASSWORD=password"
ulimits:
memlock:
soft: -1
hard: -1
ports:
- "9200:9200"
networks:
- kafka-net
networks:
kafka-net:
driver: bridge
volumes:
mongodb_data:
driver: local
elasticsearch:
driver: local

View 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.

View 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 ~937990 (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

View 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;

View File

@@ -1,8 +1,7 @@
const paginate = require("mongoose-paginate-v2");
const { Client } = require("@elastic/elasticsearch");
import ComicVineMetadataSchema from "./comicvine.metadata.model";
import { mongoosastic } from "mongoosastic-ts";
const mongoose = require("mongoose")
const mongoose = require("mongoose");
import {
MongoosasticDocument,
MongoosasticModel,
@@ -19,6 +18,216 @@ export const eSClient = new Client({
},
});
// Metadata source enumeration
export enum MetadataSource {
COMICVINE = "comicvine",
METRON = "metron",
GRAND_COMICS_DATABASE = "gcd",
LOCG = "locg",
COMICINFO_XML = "comicinfo",
MANUAL = "manual",
}
// Provenance schema - tracks where each piece of metadata came from
const ProvenanceSchema = new mongoose.Schema(
{
_id: false,
source: {
type: String,
enum: Object.values(MetadataSource),
required: true,
},
sourceId: String, // External ID from the source (e.g., ComicVine ID)
confidence: { type: Number, min: 0, max: 1, default: 1 }, // 0-1 confidence score
fetchedAt: { type: Date, default: Date.now },
url: String, // Source URL if applicable
},
{ _id: false }
);
// Individual metadata field with provenance
const MetadataFieldSchema = new mongoose.Schema(
{
_id: false,
value: mongoose.Schema.Types.Mixed, // The actual value
provenance: ProvenanceSchema, // Where it came from
userOverride: { type: Boolean, default: false }, // User manually set this
},
{ _id: false }
);
// Creator with provenance
const CreatorSchema = new mongoose.Schema(
{
_id: false,
name: String,
role: String, // writer, artist, colorist, letterer, etc.
id: String, // External ID from source (e.g., Metron creator ID)
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Story Arc with provenance
const StoryArcSchema = new mongoose.Schema(
{
_id: false,
name: String,
number: Number, // Issue's position in the arc
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Universe schema for multiverse/alternate reality tracking
const UniverseSchema = new mongoose.Schema(
{
_id: false,
name: String,
designation: String, // e.g., "Earth-616", "Earth-25"
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Price information with country codes
const PriceSchema = new mongoose.Schema(
{
_id: false,
country: String, // ISO country code (e.g., "US", "GB")
amount: Number,
currency: String, // ISO currency code (e.g., "USD", "GBP")
provenance: ProvenanceSchema,
},
{ _id: false }
);
// External IDs from various sources
const ExternalIDSchema = new mongoose.Schema(
{
_id: false,
source: String, // e.g., "Metron", "Comic Vine", "Grand Comics Database", "MangaDex"
id: String,
primary: { type: Boolean, default: false },
provenance: ProvenanceSchema,
},
{ _id: false }
);
// GTIN (Global Trade Item Number) - includes ISBN, UPC, etc.
const GTINSchema = new mongoose.Schema(
{
_id: false,
isbn: String,
upc: String,
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Reprint information
const ReprintSchema = new mongoose.Schema(
{
_id: false,
description: String, // e.g., "Foo Bar #001 (2002)"
id: String, // External ID from source
provenance: ProvenanceSchema,
},
{ _id: false }
);
// URL with primary flag
const URLSchema = new mongoose.Schema(
{
_id: false,
url: String,
primary: { type: Boolean, default: false },
provenance: ProvenanceSchema,
},
{ _id: false }
);
// Canonical metadata - resolved from multiple sources
const CanonicalMetadataSchema = new mongoose.Schema(
{
_id: false,
// Core identifiers
title: MetadataFieldSchema,
series: MetadataFieldSchema,
volume: MetadataFieldSchema,
issueNumber: MetadataFieldSchema,
// External IDs from various sources (Metron, ComicVine, GCD, MangaDex, etc.)
externalIDs: [ExternalIDSchema],
// Publication info
publisher: MetadataFieldSchema,
imprint: MetadataFieldSchema, // Publisher imprint (e.g., Vertigo for DC Comics)
publicationDate: MetadataFieldSchema, // Store/release date
coverDate: MetadataFieldSchema, // Cover date (often different from store date)
// Series information
seriesInfo: {
type: {
_id: false,
id: String, // External series ID
language: String, // ISO language code (e.g., "en", "de")
sortName: String, // Alternative sort name
startYear: Number,
issueCount: Number, // Total issues in series
volumeCount: Number, // Total volumes/collections
alternativeNames: [MetadataFieldSchema], // Alternative series names
provenance: ProvenanceSchema,
},
default: null,
},
// Content
description: MetadataFieldSchema, // Summary/synopsis
notes: MetadataFieldSchema, // Additional notes about the issue
stories: [MetadataFieldSchema], // Story titles within the issue
storyArcs: [StoryArcSchema], // Story arcs with position tracking
characters: [MetadataFieldSchema],
teams: [MetadataFieldSchema],
locations: [MetadataFieldSchema],
universes: [UniverseSchema], // Multiverse/alternate reality information
// Creators
creators: [CreatorSchema],
// Classification
genres: [MetadataFieldSchema],
tags: [MetadataFieldSchema],
ageRating: MetadataFieldSchema,
// Physical/Digital properties
pageCount: MetadataFieldSchema,
format: MetadataFieldSchema, // Single Issue, TPB, HC, etc.
// Commercial information
prices: [PriceSchema], // Prices in different countries/currencies
gtin: GTINSchema, // ISBN, UPC, etc.
// Reprints
reprints: [ReprintSchema], // Information about reprinted content
// URLs
urls: [URLSchema], // External URLs (ComicVine, Metron, etc.)
// Ratings and popularity
communityRating: MetadataFieldSchema,
// Cover image
coverImage: MetadataFieldSchema,
// Metadata tracking
lastModified: MetadataFieldSchema, // Last modification timestamp from source
},
{ _id: false }
);
const RawFileDetailsSchema = mongoose.Schema({
_id: false,
name: String,
@@ -28,6 +237,10 @@ const RawFileDetailsSchema = mongoose.Schema({
mimeType: String,
containedIn: String,
pageCount: Number,
archive: {
uncompressed: Boolean,
expandedPath: String,
},
cover: {
filePath: String,
stats: Object,
@@ -46,18 +259,52 @@ const LOCGSchema = mongoose.Schema({
pulls: Number,
potw: Number,
});
const DirectConnectBundleSchema = mongoose.Schema({
bundleId: Number,
name: String,
size: String,
type: {},
_id: false,
});
const wantedSchema = mongoose.Schema(
{
source: { type: String, default: null },
markEntireVolumeWanted: Boolean,
issues: {
type: [
{
_id: false,
id: Number,
url: String,
image: { type: Array, default: [] },
coverDate: String,
issueNumber: String,
},
],
default: null,
},
volume: {
type: {
_id: false,
id: Number,
url: String,
image: { type: Array, default: [] },
name: String,
},
default: null,
},
},
{ _id: false }
);
const ComicSchema = mongoose.Schema(
{
importStatus: {
isImported: Boolean,
tagged: Boolean,
isRawFileMissing: { type: Boolean, default: false },
matchedResult: {
score: String,
},
@@ -65,21 +312,27 @@ const ComicSchema = mongoose.Schema(
userAddedMetadata: {
tags: [String],
},
// NEW: Canonical metadata with provenance
canonicalMetadata: {
type: CanonicalMetadataSchema,
es_indexed: true,
default: {},
},
// LEGACY: Keep existing sourced metadata for backward compatibility
sourcedMetadata: {
comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} },
comicvine: {
type: ComicVineMetadataSchema,
es_indexed: true,
default: {},
},
shortboxed: {},
comicvine: { type: mongoose.Schema.Types.Mixed, default: {} },
metron: { type: mongoose.Schema.Types.Mixed, default: {} },
gcd: { type: mongoose.Schema.Types.Mixed, default: {} }, // Grand Comics Database
locg: {
type: LOCGSchema,
es_indexed: true,
default: {},
},
gcd: {},
},
rawFileDetails: {
type: RawFileDetailsSchema,
es_indexed: true,
@@ -98,10 +351,12 @@ const ComicSchema = mongoose.Schema(
subtitle: { type: String, es_indexed: true },
},
},
wanted: wantedSchema,
acquisition: {
source: {
wanted: Boolean,
name: String,
wanted: { type: Boolean, default: false },
name: { type: String, default: null },
},
release: {},
directconnect: {
@@ -111,12 +366,13 @@ const ComicSchema = mongoose.Schema(
default: [],
},
},
torrent: {
sourceApplication: String,
magnet: String,
tracker: String,
status: String,
},
torrent: [
{
infoHash: String,
name: String,
announce: [String],
},
],
usenet: {
sourceApplication: String,
},
@@ -132,5 +388,10 @@ ComicSchema.plugin(mongoosastic, {
} as MongoosasticPluginOpts);
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);
export default Comic;

View File

@@ -1,95 +0,0 @@
const mongoose = require("mongoose");
const Things = mongoose.Schema({
_id: false,
api_detail_url: String,
id: Number,
name: String,
site_detail_url: String,
count: String,
});
const Issue = mongoose.Schema({
_id: false,
api_detail_url: String,
id: Number,
name: String,
issue_number: String,
});
const VolumeInformation = mongoose.Schema({
_id: false,
aliases: [String],
api_detail_url: String,
characters: [Things],
concepts: [Things],
count_of_issues: String,
date_added: String,
date_last_updated: String,
deck: String,
description: String,
first_issue: Issue,
id: Number,
image: {
icon_url: String,
medium_url: String,
screen_url: String,
screen_large_url: String,
small_url: String,
super_url: String,
thumb_url: String,
tiny_url: String,
original_url: String,
image_tags: String,
},
issues: [
{
api_detail_url: String,
id: Number,
name: String,
issue_number: String,
site_detail_url: String,
},
],
last_issue: Issue,
locations: [Things],
name: String,
objects: [Things],
people: [Things],
publisher: {
api_detail_url: String,
id: Number,
name: String,
},
site_detail_url: String,
start_year: String,
});
const ComicVineMetadataSchema = mongoose.Schema({
_id: false,
aliases: [String],
api_detail_url: String,
has_staff_review: { type: mongoose.Schema.Types.Mixed },
cover_date: Date,
date_added: String,
date_last_updated: String,
deck: String,
description: String,
image: {
icon_url: String,
medium_url: String,
screen_url: String,
screen_large_url: String,
small_url: String,
super_url: String,
thumb_url: String,
tiny_url: String,
original_url: String,
image_tags: String,
},
id: Number,
name: String,
resource_type: String,
volumeInformation: VolumeInformation,
});
export default ComicVineMetadataSchema;

2016
models/graphql/resolvers.ts Normal file

File diff suppressed because it is too large Load Diff

1042
models/graphql/typedef.ts Normal file

File diff suppressed because it is too large Load Diff

12
models/jobresult.model.ts Normal file
View File

@@ -0,0 +1,12 @@
const mongoose = require("mongoose");
const JobResultScehma = mongoose.Schema({
id: Number,
status: String,
sessionId: String,
failedReason: Object,
timestamp: Date,
});
const JobResult = mongoose.model("JobResult", JobResultScehma);
export default JobResult;

9
models/session.model.ts Normal file
View File

@@ -0,0 +1,9 @@
const mongoose = require("mongoose");
const SessionScehma = mongoose.Schema({
sessionId: String,
socketId: String,
});
const Session = mongoose.model("Session", SessionScehma);
export default Session;

View File

@@ -1,21 +1,34 @@
const mongoose = require("mongoose");
const paginate = require("mongoose-paginate-v2");
const HostSchema = mongoose.Schema({
_id: false,
username: String,
password: String,
hostname: String,
port: String,
protocol: String,
});
const SettingsScehma = mongoose.Schema({
directConnect: {
client: {
host: {
username: String,
password: String,
hostname: String,
port: String,
protocol: String,
},
host: HostSchema,
airDCPPUserSettings: Object,
hubs: Array,
},
},
bittorrent: {
client: {
name: String,
host: HostSchema,
},
},
prowlarr: {
client: {
host: HostSchema,
apiKey: String,
},
},
});
const Settings = mongoose.model("Settings", SettingsScehma);

View File

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

View File

@@ -102,7 +102,7 @@ const brokerConfig: BrokerOptions = {
serializer: "JSON",
// 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
retryPolicy: {

8709
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,8 @@
"lint": "eslint --ext .js,.ts .",
"dc:up": "docker-compose up --build -d",
"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": [
"microservices",
@@ -20,7 +21,6 @@
],
"author": "Rishi Ghan",
"devDependencies": {
"@elastic/elasticsearch": "^8.6.0",
"@types/lodash": "^4.14.168",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
@@ -35,28 +35,37 @@
"npm": "^8.4.1",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"typescript": "^5.0.2"
"typescript": "^5.0.2",
"uuid": "^9.0.0"
},
"dependencies": {
"@npcz/magic": "^1.3.14",
"redis": "^4.6.5",
"@socket.io/redis-adapter": "^8.1.0",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@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",
"@npcz/magic": "^1.3.14",
"@root/walk": "^1.1.0",
"@socket.io/redis-adapter": "^8.1.0",
"@types/jest": "^27.4.1",
"@types/mkdirp": "^1.0.0",
"@types/node": "^13.9.8",
"@types/string-similarity": "^4.0.0",
"axios": "^0.25.0",
"airdcpp-apisocket": "^3.0.0-beta.8",
"axios": "^1.6.8",
"axios-retry": "^3.2.4",
"bree": "^7.1.5",
"calibre-opds": "^1.0.7",
"chokidar": "^3.5.3",
"chokidar": "^4.0.3",
"delay": "^5.0.0",
"dotenv": "^10.0.0",
"filename-parser": "^1.0.4",
"fs-extra": "^10.0.0",
"graphql": "^16.11.0",
"graphql-tag": "^2.12.6",
"http-response-stream": "^1.0.9",
"image-js": "^0.34.0",
"imghash": "^0.0.9",
@@ -65,8 +74,8 @@
"leven": "^3.1.0",
"lodash": "^4.17.21",
"mkdirp": "^0.5.5",
"moleculer": "^0.14.29",
"moleculer-bull": "github:rishighan/moleculer-bull#1.0.0",
"moleculer-apollo-server": "^0.4.0",
"moleculer-bullmq": "^3.0.0",
"moleculer-db": "^0.8.23",
"moleculer-db-adapter-mongoose": "^0.9.2",
"moleculer-io": "^2.2.0",
@@ -75,14 +84,16 @@
"mongoose": "^6.10.4",
"mongoose-paginate-v2": "^1.3.18",
"nats": "^1.3.2",
"opds-extra": "^3.0.9",
"opds-extra": "^3.0.10",
"p7zip-threetwo": "^1.0.4",
"redis": "^4.6.5",
"sanitize-filename-ts": "^1.0.2",
"sharp": "^0.30.4",
"sharp": "^0.33.3",
"threetwo-ui-typings": "^1.0.14",
"through2": "^4.0.2",
"undici": "^7.22.0",
"unrar": "^0.2.0",
"xml2js": "^0.4.23"
"xml2js": "^0.6.2"
},
"engines": {
"node": ">= 18.x.x"
@@ -90,6 +101,7 @@
"jest": {
"coverageDirectory": "<rootDir>/coverage",
"testEnvironment": "node",
"testTimeout": 30000,
"moduleFileExtensions": [
"ts",
"tsx",
@@ -101,9 +113,16 @@
"testMatch": [
"**/*.spec.(ts|js)"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.ts"
],
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
"tsconfig": "tsconfig.json"
}
}
}

156
services/airdcpp.service.ts Normal file
View File

@@ -0,0 +1,156 @@
"use strict";
import {
Context,
Service,
ServiceBroker,
ServiceSchema,
Errors,
} from "moleculer";
import axios from "axios";
import AirDCPPSocket from "../shared/airdcpp.socket";
export default class AirDCPPService extends Service {
// @ts-ignore
public constructor(
public broker: ServiceBroker,
schema: ServiceSchema<{}> = { name: "airdcpp" }
) {
super(broker);
this.parseServiceSchema({
name: "airdcpp",
mixins: [],
hooks: {},
actions: {
initialize: {
rest: "POST /initialize",
handler: async (
ctx: Context<{
host: {
hostname: string;
port: string;
protocol: string;
username: string;
password: string;
};
}>
) => {
try {
const {
host: {
hostname,
protocol,
port,
username,
password,
},
} = ctx.params;
const airDCPPSocket = new AirDCPPSocket({
protocol,
hostname: `${hostname}:${port}`,
username,
password,
});
return await airDCPPSocket.connect();
} catch (err) {
console.error(err);
}
},
},
getHubs: {
rest: "POST /getHubs",
timeout: 70000,
handler: async (
ctx: Context<{
host: {
hostname: string;
port: string;
protocol: string;
username: string;
password: string;
};
}>
) => {
const {
host: {
hostname,
port,
protocol,
username,
password,
},
} = ctx.params;
try {
const airDCPPSocket = new AirDCPPSocket({
protocol,
hostname: `${hostname}:${port}`,
username,
password,
});
await airDCPPSocket.connect();
return await airDCPPSocket.get(`hubs`);
} catch (err) {
throw err;
}
},
},
search: {
rest: "POST /search",
timeout: 20000,
handler: async (
ctx: Context<{
host: {
hostname;
port;
protocol;
username;
password;
};
dcppSearchQuery;
}>
) => {
try {
const {
host: {
hostname,
port,
protocol,
username,
password,
},
dcppSearchQuery,
} = ctx.params;
const airDCPPSocket = new AirDCPPSocket({
protocol,
hostname: `${hostname}:${port}`,
username,
password,
});
await airDCPPSocket.connect();
const searchInstance = await airDCPPSocket.post(
`search`
);
// Post the search
const searchInfo = await airDCPPSocket.post(
`search/${searchInstance.id}/hub_search`,
dcppSearchQuery
);
await this.sleep(10000);
const results = await airDCPPSocket.get(
`search/${searchInstance.id}/results/0/5`
);
return results;
} catch (err) {
throw err;
}
},
},
},
methods: {
sleep: (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
},
},
});
}
}

View File

@@ -1,190 +1,333 @@
import chokidar from "chokidar";
import chokidar, { FSWatcher } from "chokidar";
import fs from "fs";
import { Service, ServiceBroker } from "moleculer";
import ApiGateway from "moleculer-web";
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 {
public constructor(broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "api",
mixins: [ApiGateway],
// 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: {},
/**
* The chokidar file system watcher instance.
* @private
*/
private fileWatcher?: any;
bodyParsers: {
json: {
strict: false,
limit: "1MB",
},
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: {
/**
* Per-path debounced handlers for add/change events, keyed by file path.
* @private
*/
private debouncedHandlers: Map<string, ReturnType<typeof debounce>> = new Map();
},
/**
* 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 {
// Filewatcher
const fileWatcher = chokidar.watch(
path.resolve("/comics"),
{
ignored: (filePath) =>
path.extname(filePath) === ".dctmp",
persistent: true,
usePolling: true,
interval: 5000,
ignoreInitial: true,
followSymlinks: true,
atomic: true,
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
}
);
const fileCopyDelaySeconds = 3;
const checkEnd = (path, prev) => {
fs.stat(path, async (err, stat) => {
// Replace error checking with something appropriate for your app.
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
);
});
};
/**
* Listen for watcher enable events
*/
"IMPORT_WATCHER_ENABLED": {
async handler(ctx: Context<{ sessionId: string }>) {
const { sessionId } = ctx.params;
this.logger.info(`[Watcher] Re-enabled after session: ${sessionId}`);
// Broadcast to frontend
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "IMPORT_WATCHER_STATUS",
args: [{
enabled: true,
sessionId,
}],
});
},
},
},
actions: {},
methods: {},
started: this.startWatcher,
stopped: this.stopWatcher,
});
}
fileWatcher
.on("add", (path, stats) => {
console.log("Watcher detected new files.");
console.log(
`File ${path} has been added with stats: ${JSON.stringify(
stats,
null,
2
)}`
);
/**
* Initializes and starts the chokidar watcher on the COMICS_DIRECTORY.
* Debounces rapid events and logs initial scan completion.
* @private
*/
private async startWatcher(): Promise<void> {
const rawDir = process.env.COMICS_DIRECTORY;
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.
if (err) throw err;
setTimeout(
checkEnd,
fileCopyDelaySeconds,
path,
stat
);
});
})
// .once(
// "change",
/**
* Returns a debounced handler for a specific path, creating one if needed.
* Debouncing per-path prevents duplicate events for the same file while
* ensuring each distinct path is always processed.
*/
const getDebouncedForPath = (p: string) => {
if (!this.debouncedHandlers.has(p)) {
const fn = debounce(
(event: string, filePath: string, stats?: fs.Stats) => {
this.debouncedHandlers.delete(filePath);
try {
this.handleFileEvent(event, filePath, stats);
} 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) =>
// console.log(
// `File ${path} has been changed. Stats: ${JSON.stringify(
// stats,
// null,
// 2
// )}`
// )
// )
.on(
"unlink",
this.fileWatcher
.on("ready", () => this.logger.info("Initial scan complete."))
.on("error", (err) => this.logger.error("Watcher error:", err))
.on("add", (p, stats) => getDebouncedForPath(p)("add", p, stats))
.on("change", (p, stats) => getDebouncedForPath(p)("change", p, stats))
// 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));
}
(path) =>
console.log(`File ${path} has been removed`)
)
.on(
"addDir",
/**
* Stops and closes the chokidar watcher, freeing resources.
* @private
*/
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
View 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");
},
};

View File

@@ -7,6 +7,8 @@ import {
ServiceSchema,
Errors,
} from "moleculer";
import { DbMixin } from "../mixins/db.mixin";
import Comic from "../models/comic.model";
import path from "path";
import {
analyze,
@@ -22,16 +24,13 @@ export default class ImageTransformation extends Service {
super(broker);
this.parseServiceSchema({
name: "imagetransformation",
mixins: [],
mixins: [DbMixin("comics", Comic)],
settings: {
// Available fields in the responses
fields: ["_id", "name", "quantity", "price"],
fields: ["_id"],
// Validator for the `create` & `insert` actions.
entityValidator: {
name: "string|min:3",
price: "number|positive",
},
entityValidator: {},
},
hooks: {},
actions: {

View File

@@ -1,291 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2022 Rishi Ghan
*
The MIT License (MIT)
Copyright (c) 2015 Rishi Ghan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
* Revision History:
* Initial: 2022/01/28 Rishi Ghan
*/
"use strict";
import { refineQuery } from "filename-parser";
import { isNil, isUndefined } from "lodash";
import { Context, Service, ServiceBroker, ServiceSchema } from "moleculer";
import BullMQMixin, { SandboxedJob } from "moleculer-bull";
import { DbMixin } from "../mixins/db.mixin";
import Comic from "../models/comic.model";
import {
extractFromArchive,
uncompressEntireArchive,
} from "../utils/uncompression.utils";
const REDIS_URI = process.env.REDIS_URI || `redis://localhost:6379`;
const EventEmitter = require("events");
EventEmitter.defaultMaxListeners = 20;
console.log(`REDIS -> ${REDIS_URI}`);
export default class QueueService extends Service {
public constructor(
public broker: ServiceBroker,
schema: ServiceSchema<{}> = { name: "importqueue" }
) {
super(broker);
this.parseServiceSchema({
name: "importqueue",
mixins: [BullMQMixin(REDIS_URI), DbMixin("comics", Comic)],
settings: {
bullmq: {
maxStalledCount: 0,
},
},
hooks: {},
queues: {
"process.import": {
concurrency: 10,
async process(job: SandboxedJob) {
console.info("New job received!", job.data);
console.info(`Processing queue...`);
// extract the cover
const result = await extractFromArchive(
job.data.fileObject.filePath
);
const {
name,
filePath,
fileSize,
extension,
mimeType,
cover,
containedIn,
comicInfoJSON,
} = result;
// Infer any issue-related metadata from the filename
const { inferredIssueDetails } = refineQuery(
result.name
);
console.log(
"Issue metadata inferred: ",
JSON.stringify(inferredIssueDetails, null, 2)
);
// Add the bundleId, if present to the payload
let bundleId = null;
if (!isNil(job.data.bundleId)) {
bundleId = job.data.bundleId;
}
// Orchestrate the payload
const payload = {
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
rawFileDetails: {
name,
filePath,
fileSize,
extension,
mimeType,
containedIn,
cover,
},
inferredMetadata: {
issue: inferredIssueDetails,
},
sourcedMetadata: {
// except for ComicInfo.xml, everything else should be copied over from the
// parent comic
comicInfo: comicInfoJSON,
},
// since we already have at least 1 copy
// mark it as not wanted by default
"acquisition.source.wanted": false,
// clear out the downloads array
// "acquisition.directconnect.downloads": [],
// mark the metadata source
"acquisition.source.name": job.data.sourcedFrom,
};
// Add the sourcedMetadata, if present
if (!isNil(job.data.sourcedMetadata) && !isUndefined(job.data.sourcedMetadata.comicvine)) {
Object.assign(
payload.sourcedMetadata,
job.data.sourcedMetadata
);
}
// write to mongo
const importResult = await this.broker.call(
"library.rawImportToDB",
{
importType: job.data.importType,
bundleId,
payload,
}
);
return {
data: {
importResult,
},
id: job.id,
worker: process.pid,
};
},
},
"process.uncompressAndResize": {
concurrency: 2,
async process(job: SandboxedJob) {
console.log(`Initiating uncompression job...`);
return await uncompressEntireArchive(
job.data.filePath,
job.data.options
);
},
},
},
actions: {
uncompressResize: {
rest: "POST /uncompressResize",
params: {},
async handler(
ctx: Context<{
data: { filePath: string; options: any };
}>
) {
return await this.createJob(
"process.uncompressAndResize",
ctx.params
);
},
},
processImport: {
rest: "POST /processImport",
params: {},
async handler(
ctx: Context<{
fileObject: object;
importType: string;
bundleId: number;
sourcedFrom?: string;
sourcedMetadata: object;
}>
) {
return await this.createJob("process.import", {
fileObject: ctx.params.fileObject,
importType: ctx.params.importType,
bundleId: ctx.params.bundleId,
sourcedFrom: ctx.params.sourcedFrom,
sourcedMetadata: ctx.params.sourcedMetadata,
});
},
},
toggleImportQueue: {
rest: "POST /pauseImportQueue",
params: {},
handler: async (ctx: Context<{ action: string }>) => {
switch (ctx.params.action) {
case "pause":
const foo = await this.getQueue(
"process.import"
).pause();
console.log("paused", foo);
return foo;
case "resume":
const soo = await this.getQueue(
"process.import"
).resume();
console.log("resumed", soo);
return soo;
default:
console.log("Unrecognized queue action.");
}
},
},
},
methods: {},
async started(): Promise<any> {
await this.getQueue("process.import").on(
"failed",
async (job, error) => {
console.error(
`An error occured in 'process.import' queue on job id '${job.id}': ${error.message}`
);
console.error(job.data);
}
);
await this.getQueue("process.import").on(
"completed",
async (job, res) => {
await this.broker.call("socket.broadcast", {
namespace: "/", //optional
event: "action",
args: [{ type: "LS_COVER_EXTRACTED", result: res }], //optional
});
console.info(
`Import Job with the id '${job.id}' completed.`
);
}
);
await this.getQueue("process.import").on(
"stalled",
async (job) => {
console.warn(`Import job '${job.id} stalled!`);
console.log(`${JSON.stringify(job, null, 2)}`);
console.log(`is stalled.`);
}
);
await this.getQueue("process.uncompressAndResize").on(
"completed",
async (job, res) => {
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "action",
args: [
{
type: "COMICBOOK_EXTRACTION_SUCCESS",
result: {
files: res,
purpose: job.data.options.purpose,
},
},
],
});
console.info(`Uncompression Job ${job.id} completed.`);
}
);
},
});
}
}

View 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);
},
});
}
}

View File

@@ -0,0 +1,475 @@
import { Context, Service, ServiceBroker } from "moleculer";
import JobResult from "../models/jobresult.model";
import { refineQuery } from "filename-parser";
import BullMqMixin from "moleculer-bullmq";
import { DbMixin } from "../mixins/db.mixin";
import Comic from "../models/comic.model";
const ObjectId = require("mongoose").Types.ObjectId;
import {
extractFromArchive,
uncompressEntireArchive,
} from "../utils/uncompression.utils";
import { isNil, isUndefined } from "lodash";
import { pubClient } from "../config/redis.config";
import path from "path";
const { MoleculerError } = require("moleculer").Errors;
console.log(process.env.REDIS_URI);
export default class JobQueueService extends Service {
public constructor(public broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "jobqueue",
hooks: {},
mixins: [DbMixin("comics", Comic), BullMqMixin],
settings: {
bullmq: {
client: process.env.REDIS_URI,
},
},
actions: {
getJobCountsByType: {
rest: "GET /getJobCountsByType",
handler: async (ctx: Context<{}>) => {
console.log(ctx.params);
return await this.$resolve("jobqueue").getJobCounts();
},
},
toggle: {
rest: "GET /toggle",
handler: async (ctx: Context<{ action: String }>) => {
switch (ctx.params.action) {
case "pause":
this.pause();
break;
case "resume":
this.resume();
break;
default:
console.log(`Unknown queue action.`);
}
},
},
enqueue: {
queue: true,
rest: "GET /enqueue",
handler: async (
ctx: Context<{ action: string; description: string }>
) => {
const { action, description } = ctx.params;
// Enqueue the job
const job = await this.localQueue(
ctx,
action,
ctx.params,
{
priority: 10,
}
);
console.log(`Job ${job.id} enqueued`);
console.log(`${description}`);
return job.id;
},
},
// Comic Book Import Job Queue
"enqueue.async": {
handler: async (
ctx: Context<{
sessionId: String;
}>
) => {
try {
console.log(
`Recieved Job ID ${ctx.locals.job.id}, processing...`
);
// 1. De-structure the job params
const { fileObject } = ctx.locals.job.data.params;
// 2. Extract metadata from the archive
const result = await extractFromArchive(
fileObject.filePath
);
const {
name,
filePath,
fileSize,
extension,
mimeType,
cover,
containedIn,
comicInfoJSON,
} = result;
// 3a. Infer any issue-related metadata from the filename
const { inferredIssueDetails } = refineQuery(
result.name
);
console.log(
"Issue metadata inferred: ",
JSON.stringify(inferredIssueDetails, null, 2)
);
// 3b. Orchestrate the payload
const payload = {
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
rawFileDetails: {
name,
filePath,
fileSize,
extension,
mimeType,
containedIn,
cover,
},
inferredMetadata: {
issue: inferredIssueDetails,
},
sourcedMetadata: {
// except for ComicInfo.xml, everything else should be copied over from the
// parent comic
comicInfo: comicInfoJSON,
},
// since we already have at least 1 copy
// mark it as not wanted by default
"acquisition.source.wanted": false,
// clear out the downloads array
// "acquisition.directconnect.downloads": [],
// mark the metadata source
"acquisition.source.name":
ctx.locals.job.data.params.sourcedFrom,
};
// 3c. Add the bundleId, if present to the payload
let bundleId = null;
if (!isNil(ctx.locals.job.data.params.bundleId)) {
bundleId = ctx.locals.job.data.params.bundleId;
}
// 3d. Add the sourcedMetadata, if present
if (
!isNil(
ctx.locals.job.data.params.sourcedMetadata
) &&
!isUndefined(
ctx.locals.job.data.params.sourcedMetadata
.comicvine
)
) {
Object.assign(
payload.sourcedMetadata,
ctx.locals.job.data.params.sourcedMetadata
);
}
// 4. write to mongo
const importResult = await this.broker.call(
"library.rawImportToDB",
{
importType:
ctx.locals.job.data.params.importType,
bundleId,
payload,
}
);
return {
data: {
importResult,
},
id: ctx.locals.job.id,
sessionId: ctx.params.sessionId,
};
} catch (error) {
console.error(
`An error occurred processing Job ID ${ctx.locals.job.id}:`,
error instanceof Error ? error.message : error,
error instanceof Error ? error.stack : ""
);
throw new MoleculerError(
error,
500,
"IMPORT_JOB_ERROR",
{
data: ctx.params.sessionId,
}
);
}
},
},
getJobResultStatistics: {
rest: "GET /getJobResultStatistics",
handler: async (ctx: Context<{}>) => {
return await JobResult.aggregate([
{
$group: {
_id: {
sessionId: "$sessionId",
status: "$status",
},
earliestTimestamp: {
$min: "$timestamp",
},
count: {
$sum: 1,
},
},
},
{
$group: {
_id: "$_id.sessionId",
statuses: {
$push: {
status: "$_id.status",
earliestTimestamp:
"$earliestTimestamp",
count: "$count",
},
},
},
},
{
$project: {
_id: 0,
sessionId: "$_id",
completedJobs: {
$reduce: {
input: "$statuses",
initialValue: 0,
in: {
$sum: [
"$$value",
{
$cond: [
{
$eq: [
"$$this.status",
"completed",
],
},
"$$this.count",
0,
],
},
],
},
},
},
failedJobs: {
$reduce: {
input: "$statuses",
initialValue: 0,
in: {
$sum: [
"$$value",
{
$cond: [
{
$eq: [
"$$this.status",
"failed",
],
},
"$$this.count",
0,
],
},
],
},
},
},
earliestTimestamp: {
$min: "$statuses.earliestTimestamp",
},
},
},
]);
},
},
"uncompressFullArchive.async": {
rest: "POST /uncompressFullArchive",
handler: async (
ctx: Context<{
filePath: string;
comicObjectId: string;
options: any;
}>
) => {
console.log(
`Recieved Job ID ${JSON.stringify(
ctx.locals
)}, processing...`
);
const { filePath, options, comicObjectId } = ctx.params;
const comicId = new ObjectId(comicObjectId);
// 2. Extract metadata from the archive
const result: string[] = await uncompressEntireArchive(
filePath,
options
);
if (Array.isArray(result) && result.length !== 0) {
// Get the containing directory of the uncompressed archive
const directoryPath = path.dirname(result[0]);
// Add to mongo object
await Comic.findByIdAndUpdate(
comicId,
{
$set: {
"rawFileDetails.archive": {
uncompressed: true,
expandedPath: directoryPath,
},
},
},
{ new: true, safe: true, upsert: true }
);
return result;
}
},
},
},
events: {
async "uncompressFullArchive.async.active"(
ctx: Context<{ id: number }>
) {
console.log(
`Uncompression Job ID ${ctx.params.id} is set to active.`
);
},
async "uncompressFullArchive.async.completed"(
ctx: Context<{ id: number }>
) {
console.log(
`Uncompression Job ID ${ctx.params.id} completed.`
);
const job = await this.job(ctx.params.id);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_UNCOMPRESSION_JOB_COMPLETE",
args: [
{
uncompressedArchive: job.returnvalue,
},
],
});
return job.returnvalue;
},
// use the `${QUEUE_NAME}.QUEUE_EVENT` scheme
async "enqueue.async.active"(ctx: Context<{ id: Number }>) {
console.log(`Job ID ${ctx.params.id} is set to active.`);
},
async drained(ctx) {
console.log("Queue drained.");
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_IMPORT_QUEUE_DRAINED",
args: [
{
message: "drained",
},
],
});
// 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 }>) {
// 1. Fetch the job result using the job Id
const job = await this.job(ctx.params.id);
// 2. Increment the completed job counter
await pubClient.incr("completedJobCount");
// 3. Fetch the completed and total job counts for the progress payload
const [completedJobCount, totalJobCount] = await Promise.all([
pubClient.get("completedJobCount"),
pubClient.get("totalJobCount"),
]);
// 4. Emit the LS_COVER_EXTRACTED event with the necessary details
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_COVER_EXTRACTED",
args: [
{
completedJobCount,
totalJobCount,
importResult: job.returnvalue.data.importResult,
},
],
});
// 5. Persist the job results in mongo for posterity
await JobResult.create({
id: ctx.params.id,
status: "completed",
timestamp: job.timestamp,
sessionId: job.returnvalue.sessionId,
failedReason: {},
});
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) {
const job = await this.job(ctx.params.id);
await pubClient.incr("failedJobCount");
const failedJobCount = await pubClient.get(
"failedJobCount"
);
await JobResult.create({
id: ctx.params.id,
status: "failed",
failedReason: job.failedReason,
sessionId: job.data.params.sessionId,
timestamp: job.timestamp,
});
// 4. Emit the LS_COVER_EXTRACTION_FAILED event with the necessary details
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_COVER_EXTRACTION_FAILED",
args: [
{
failedJobCount,
importResult: job,
},
],
});
},
},
methods: {},
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -46,14 +46,15 @@ export default class SettingsService extends Service {
.map((item) => JSON.stringify(item))
.join("\n");
queries += "\n";
const { body } = await eSClient.msearch({
const { responses } = await eSClient.msearch({
body: queries,
});
body.responses.forEach((match) => {
responses.forEach((match) => {
console.log(match.hits);
});
return body.responses;
return responses;
},
},
issue: {
@@ -74,9 +75,9 @@ export default class SettingsService extends Service {
) => {
try {
console.log(ctx.params);
const { query, pagination } = ctx.params;
const { query, pagination, type } = ctx.params;
let eSQuery = {};
switch (ctx.params.type) {
switch (type) {
case "all":
Object.assign(eSQuery, {
match_all: {},
@@ -99,12 +100,19 @@ export default class SettingsService extends Service {
case "wanted":
Object.assign(eSQuery, {
bool: {
must: {
term: {
"acquisition.source.wanted":
true,
should: [
{
exists: {
field: "wanted.issues",
},
},
},
{
exists: {
field: "wanted.volume",
},
},
],
minimum_should_match: 1,
},
});
break;
@@ -115,6 +123,11 @@ export default class SettingsService extends Service {
},
});
break;
case "missingFiles":
Object.assign(eSQuery, {
term: query,
});
break;
}
console.log(
"Searching ElasticSearch index with this query -> "

View File

@@ -8,7 +8,10 @@ import {
} from "moleculer";
import { DbMixin } from "../mixins/db.mixin";
import Settings from "../models/settings.model";
import { isEmpty, pickBy, identity, map } 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;
export default class SettingsService extends Service {
@@ -24,16 +27,112 @@ export default class SettingsService extends Service {
settings: {},
hooks: {},
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: {
rest: "GET /getAllSettings",
params: {},
async handler(ctx: Context<{ settingsKey: string }>) {
const settings = await Settings.find({});
if (isEmpty(settings)) {
const { settingsKey } = ctx.params;
// Initialize a projection object. Include everything by default.
let projection = settingsKey
? { _id: 0, [settingsKey]: 1 }
: {};
// Find the settings with the dynamic projection
const settings = await Settings.find({}, projection);
if (settings.length === 0) {
return {};
}
console.log(settings[0]);
return settings[0];
// If settingsKey is provided, return the specific part of the settings.
// Otherwise, return the entire settings document.
if (settingsKey) {
// Check if the specific key exists in the settings document.
// Since `settings` is an array, we access the first element.
// Then, we use the settingsKey to return only that part of the document.
return settings[0][settingsKey] || {};
} else {
// Return the entire settings document
return settings[0];
}
},
},
@@ -42,44 +141,107 @@ export default class SettingsService extends Service {
params: {},
async handler(
ctx: Context<{
settingsPayload: {
host: object;
airDCPPUserSettings: object;
hubs: [];
settingsPayload?: {
protocol: string;
hostname: string;
port: string;
username: string;
password: string;
_id?: string;
airDCPPUserSettings?: object;
hubs?: [];
};
settingsObjectId: string;
settingsObjectId?: string;
settingsKey: string;
}>
) {
console.log("varan bhat", ctx.params);
const { host, airDCPPUserSettings, hubs } =
ctx.params.settingsPayload;
let query = {
host,
airDCPPUserSettings,
hubs,
};
const keysToUpdate = pickBy(query, identity);
let updateQuery = {};
try {
console.log(ctx.params);
let query = {};
const { settingsKey, settingsObjectId } =
ctx.params;
const {
hostname,
protocol,
port,
username,
password,
} = ctx.params.settingsPayload;
const host = {
hostname,
protocol,
port,
username,
password,
};
const undefinedPropsInHostname = Object.values(
host
).filter((value) => value === undefined);
map(Object.keys(keysToUpdate), (key) => {
updateQuery[`directConnect.client.${key}`] =
query[key];
});
const options = {
upsert: true,
new: true,
setDefaultsOnInsert: true,
};
const filter = {
_id: new ObjectId(ctx.params.settingsObjectId),
};
const result = Settings.findOneAndUpdate(
filter,
{ $set: updateQuery },
options
);
// Update, depending what key was passed in params
// 1. Construct the update query
switch (settingsKey) {
case "bittorrent":
console.log(
`Recieved settings for ${settingsKey}, building query...`
);
query = {
...(undefinedPropsInHostname.length ===
0 && {
$set: {
"bittorrent.client.host": host,
},
}),
};
break;
case "directConnect":
console.log(
`Recieved settings for ${settingsKey}, building query...`
);
const { hubs, airDCPPUserSettings } =
ctx.params.settingsPayload;
query = {
...(undefinedPropsInHostname.length ===
0 && {
$set: {
"directConnect.client.host":
host,
},
}),
...(!isNil(hubs) && {
$set: {
"directConnect.client.hubs":
hubs,
},
}),
};
console.log(JSON.stringify(query, null, 4));
break;
return result;
default:
return false;
}
// 2. Set up options, filters
const options = {
upsert: true,
setDefaultsOnInsert: true,
returnDocument: "after",
};
const filter = settingsObjectId
? { _id: settingsObjectId }
: {};
// 3. Execute the mongo query
const result = await Settings.findOneAndUpdate(
filter,
query,
options
);
return result;
} catch (err) {
return err;
}
},
},
deleteSettings: {

View File

@@ -1,16 +1,20 @@
"use strict";
import { Service, ServiceBroker, ServiceSchema } from "moleculer";
import { Service, ServiceBroker, ServiceSchema, Context } from "moleculer";
import { JobType } from "moleculer-bullmq";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
import Session from "../models/session.model";
import { pubClient, subClient } from "../config/redis.config";
const { MoleculerError } = require("moleculer").Errors;
const SocketIOService = require("moleculer-io");
const redisURL = new URL(process.env.REDIS_URI);
// console.log(redisURL.hostname);
const { v4: uuidv4 } = require("uuid");
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 }>;
const pubClient = createClient({ url: `redis://${redisURL.hostname}:6379` });
(async () => {
await pubClient.connect();
})();
const subClient = pubClient.duplicate();
export default class SocketService extends Service {
// @ts-ignore
public constructor(
@@ -25,51 +29,20 @@ export default class SocketService extends Service {
port: process.env.PORT || 3001,
io: {
namespaces: {
"/": {
"/automated": {
events: {
call: {
// whitelist: ["math.*", "say.*", "accounts.*", "rooms.*", "io.*"],
},
action: async (data, ack) => {
// write your handler function here.
switch (data.type) {
case "LS_IMPORT":
console.log(
`Recieved ${data.type} event.`
);
// 1. Send task to queue
await this.broker.call(
"library.newImport",
data.data,
{}
);
break;
case "LS_TOGGLE_IMPORT_QUEUE":
await this.broker.call(
"importqueue.toggleImportQueue",
data.data,
{}
);
break;
case "LS_SINGLE_IMPORT":
console.info(
"AirDC++ finished a download -> "
);
console.log(data);
await this.broker.call(
"library.importDownloadedComic",
{ bundle: data },
{}
);
break;
// uncompress archive events
case "COMICBOOK_EXTRACTION_SUCCESS":
console.log(data);
return data;
}
whitelist: [
"socket.*", // Allow 'search' in the automated namespace
],
},
},
},
"/manual": {
events: {
call: { whitelist: ["socket.*"] },
},
},
},
options: {
adapter: createAdapter(pubClient, subClient),
@@ -77,12 +50,467 @@ export default class SocketService extends Service {
},
},
hooks: {},
actions: {},
methods: {},
actions: {
resumeSession: async (ctx: Context<{ sessionId: string }>) => {
const { sessionId } = ctx.params;
console.log("Attempting to resume session...");
try {
const sessionRecord = await Session.find({
sessionId,
});
// 1. Check for sessionId's existence, and a match
if (
sessionRecord.length !== 0 &&
sessionRecord[0].sessionId === sessionId
) {
// 2. Find if the queue has active, paused or waiting jobs
const jobs: JobType = await this.broker.call(
"jobqueue.getJobCountsByType",
{}
);
const { active, paused, waiting } = jobs;
if (active > 0 || paused > 0 || waiting > 0) {
// 3. Get job counts
const completedJobCount = await pubClient.get(
"completedJobCount"
);
const failedJobCount = await pubClient.get(
"failedJobCount"
);
// 4. Send the counts to the active socket.io session
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION",
args: [
{
completedJobCount,
failedJobCount,
queueStatus: "running",
},
],
});
}
}
} catch (err) {
throw new MoleculerError(
err,
500,
"SESSION_ID_NOT_FOUND",
{
data: sessionId,
}
);
}
},
setQueueStatus: async (
ctx: Context<{
queueAction: string;
queueStatus: string;
}>
) => {
const { queueAction } = ctx.params;
await this.broker.call(
"jobqueue.toggle",
{ action: queueAction },
{}
);
},
importSingleIssue: async (ctx: Context<{}>) => {
console.info("AirDC++ finished a download -> ");
console.log(ctx.params);
// await this.broker.call(
// "library.importDownloadedComic",
// { bundle: data },
// {}
// );
},
search: {
params: {
query: "object",
config: "object",
},
async handler(ctx) {
const { query, config, namespace } = ctx.params;
const namespacedInstance = this.io.of(namespace || "/");
const ADCPPSocket = new AirDCPPSocket(config);
try {
await ADCPPSocket.connect();
const instance = await ADCPPSocket.post(
"search",
query
);
// Send the instance to the client
await namespacedInstance.emit("searchInitiated", {
instance,
});
// Setting up listeners
await ADCPPSocket.addListener(
`search`,
`search_result_added`,
(groupedResult) => {
console.log(
JSON.stringify(groupedResult, null, 4)
);
namespacedInstance.emit(
"searchResultAdded",
groupedResult
);
},
instance.id
);
await ADCPPSocket.addListener(
`search`,
`search_result_updated`,
(updatedResult) => {
namespacedInstance.emit(
"searchResultUpdated",
updatedResult
);
},
instance.id
);
await ADCPPSocket.addListener(
`search`,
`search_hub_searches_sent`,
async (searchInfo) => {
await this.sleep(5000);
const currentInstance =
await ADCPPSocket.get(
`search/${instance.id}`
);
// Send the instance to the client
await namespacedInstance.emit(
"searchesSent",
{
searchInfo,
}
);
if (currentInstance.result_count === 0) {
console.log("No more search results.");
namespacedInstance.emit(
"searchComplete",
{
message:
"No more search results.",
}
);
}
},
instance.id
);
// Perform the actual search
await ADCPPSocket.post(
`search/${instance.id}/hub_search`,
query
);
} catch (error) {
await namespacedInstance.emit(
"searchError",
error.message
);
throw new MoleculerError(
"Search failed",
500,
"SEARCH_FAILED",
{
error,
}
);
} finally {
// await ADCPPSocket.disconnect();
}
},
},
download: {
// params: {
// searchInstanceId: "string",
// resultId: "string",
// comicObjectId: "string",
// name: "string",
// size: "number",
// type: "any", // Define more specific type if possible
// config: "object",
// },
async handler(ctx) {
console.log(ctx.params);
const {
searchInstanceId,
resultId,
config,
comicObjectId,
name,
size,
type,
} = ctx.params;
const ADCPPSocket = new AirDCPPSocket(config);
try {
await ADCPPSocket.connect();
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`
);
if (downloadResult && downloadResult.bundle_info) {
// Assume bundle_info is part of the response and contains the necessary details
const bundleDBImportResult = await ctx.call(
"library.applyAirDCPPDownloadMetadata",
{
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
}
);
this.logger.info(
"Download and metadata update successful",
bundleDBImportResult
);
this.broker.emit(
"downloadCompleted",
bundleDBImportResult
);
return bundleDBImportResult;
} else {
throw new Error(
"Failed to download or missing download result information"
);
}
} catch (error) {
this.broker.emit("downloadError", error.message);
throw new MoleculerError(
"Download failed",
500,
"DOWNLOAD_FAILED",
{
error,
}
);
} finally {
// await ADCPPSocket.disconnect();
}
},
},
/**
* Compute and broadcast current library statistics to all connected Socket.IO clients.
* 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);
try {
// Connect once
await ADCPPSocket.connect();
await ADCPPSocket.addListener(
"queue",
"queue_bundle_tick",
async (data) => {
console.log(
`is mulk ne har shakz ko jo kaam tha saupa \nus shakz ne us kaam ki maachis jala di`
);
namespacedInstance.emit("downloadTick", data)
}
);
} 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: {
sleep: (ms: number): Promise<NodeJS.Timeout> => {
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() {
this.io.on("connection", (data) =>
console.log("socket.io server initialized.")
);
this.io.of("/manual").on("connection", async (socket) => {
console.log(
`socket.io server connected to /manual namespace`
);
});
this.io.on("connection", async (socket) => {
console.log(
`socket.io server connected to client with session ID: ${socket.id}`
);
console.log("Looking up sessionId in Mongo...");
const sessionIdExists = await Session.find({
sessionId: socket.handshake.query.sessionId,
});
// 1. if sessionId isn't found in Mongo, create one and persist it
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);
}
// 2. else, retrieve it from Mongo and "resume" the socket.io connection
else {
console.log(`Found socketId ${socket.id}, no-op.`);
}
});
},
});
}

View File

@@ -0,0 +1,98 @@
"use strict";
import {
Context,
Service,
ServiceBroker,
ServiceSchema,
Errors,
} from "moleculer";
import { DbMixin } from "../mixins/db.mixin";
import Comic from "../models/comic.model";
import BullMqMixin from "moleculer-bullmq";
const { MoleculerError } = require("moleculer").Errors;
export default class ImageTransformation extends Service {
// @ts-ignore
public constructor(
public broker: ServiceBroker,
schema: ServiceSchema<{}> = { name: "torrentjobs" }
) {
super(broker);
this.parseServiceSchema({
name: "torrentjobs",
mixins: [DbMixin("comics", Comic), BullMqMixin],
settings: {
bullmq: {
client: process.env.REDIS_URI,
},
},
hooks: {},
actions: {
getTorrentData: {
queue: true,
rest: "GET /getTorrentData",
handler: async (ctx: Context<{ trigger: string }>) => {
const { trigger } = ctx.params;
console.log(`Recieved ${trigger} as the trigger...`);
const jobOptions = {
jobId: "retrieveTorrentData",
name: "bossy",
repeat: {
every: 10000, // Repeat every 10000 ms
limit: 100, // Limit to 100 repeats
},
};
const job = await this.localQueue(
ctx,
"fetchTorrentData",
ctx.params,
jobOptions
);
return job;
},
},
fetchTorrentData: {
rest: "GET /fetchTorrentData",
handler: async (
ctx: Context<{
birdName: String;
}>
) => {
const repeatableJob = await this.$resolve(
"torrentjobs"
).getRepeatableJobs();
console.info(repeatableJob);
console.info(
`Scheduled job for fetching torrent data fired.`
);
// 1. query mongo for infohashes
const infoHashes = await this.broker.call(
"library.getInfoHashes",
{}
);
// 2. query qbittorrent to see if they exist
const torrents: any = await this.broker.call(
"qbittorrent.getTorrentRealTimeStats",
{ infoHashes }
);
// 4.
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "AS_TORRENT_DATA",
args: [
{
torrents,
},
],
});
// 3. If they do, don't do anything
// 4. If they don't purge them from mongo
},
},
},
methods: {},
});
}
}

137
shared/airdcpp.socket.ts Normal file
View File

@@ -0,0 +1,137 @@
import WebSocket from "ws";
// 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 {
/**
* Configuration options for the underlying socket.
* @private
*/
private options: {
url: string;
autoReconnect: boolean;
reconnectInterval: number;
logLevel: string;
ignoredListenerEvents: string[];
username: string;
password: string;
};
/**
* 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 = {
url: `${socketProtocol}://${configuration.hostname}/api/v1/`,
autoReconnect: true,
reconnectInterval: 5000,
logLevel: "verbose",
ignoredListenerEvents: [
"transfer_statistics",
"hash_statistics",
"hub_counts_updated",
],
username: configuration.username,
password: configuration.password,
};
// Initialize the AirDC++ socket instance
this.socketInstance = Socket(this.options, WebSocket);
}
/**
* Establishes a connection to the AirDC++ server.
* @async
* @returns {Promise<any>} Session information returned by the server.
*/
async connect(): Promise<any> {
if (
this.socketInstance &&
typeof this.socketInstance.connect === "function"
) {
return await this.socketInstance.connect();
}
return Promise.reject(
new Error("Connect method not available on socket instance")
);
}
/**
* Disconnects from the AirDC++ server.
* @async
* @returns {Promise<void>}
*/
async disconnect(): Promise<void> {
if (
this.socketInstance &&
typeof this.socketInstance.disconnect === "function"
) {
await this.socketInstance.disconnect();
}
}
/**
* Sends a POST request to a specific AirDC++ endpoint.
* @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);
}
/**
* 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);
}
/**
* Adds an event listener to the AirDC++ socket.
* @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(
event,
handlerName,
callback,
id
);
}
}
export default AirDCPPSocket;

View 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
View 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 {};

View 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
View 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);
}

View File

@@ -4,6 +4,7 @@
"esModuleInterop": true,
"noImplicitAny": false,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"preserveConstEnums": true,
"sourceMap": true,
"pretty": true,

View File

@@ -4,7 +4,7 @@ const fse = require("fs-extra");
import path from "path";
import fs from "fs";
import { FileMagic, MagicFlags } from "@npcz/magic";
const { readdir, stat } = require("fs/promises");
const { stat } = require("fs/promises");
import {
IExplodedPathResponse,
IExtractComicBookCoverErrorResponse,
@@ -21,7 +21,7 @@ const ALLOWED_IMAGE_FILE_FORMATS = [".jpg", ".jpeg", ".png"];
// Tell FileMagic where to find the magic.mgc file
FileMagic.magicFile = require.resolve("@npcz/magic/dist/magic.mgc");
// We can onlu use MAGIC_PRESERVE_ATIME on operating suystems that support
// We can only use MAGIC_PRESERVE_ATIME on operating suystems that support
// it and that includes OS X for example. It's a good practice as we don't
// want to change the last access time because we are just checking the file
// contents type
@@ -95,19 +95,38 @@ export const getSizeOfDirectory = async (
directoryPath: string,
extensions: string[]
) => {
const files = await readdir(directoryPath);
const stats = files.map((file) => stat(path.join(directoryPath, file)));
let totalSizeInBytes = 0;
let fileCount = 0;
return (await Promise.all(stats)).reduce(
(accumulator, { size }) => accumulator + size,
0
);
await Walk.walk(directoryPath, async (err, pathname, dirent) => {
if (err) return false;
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 => {
return includes(ALLOWED_IMAGE_FILE_FORMATS, path.extname(fileName));
};
/**
* This function constructs paths for a target extraction folder and an input file based on extraction
* options and a walked folder.
* @param {IExtractionOptions} extractionOptions - An object containing options for the extraction
* process, such as the target extraction folder.
* @param {IFolderData} walkedFolder - `walkedFolder` is an object that represents a folder that has
* been walked through during a file extraction process. It contains the following properties:
*/
export const constructPaths = (
extractionOptions: IExtractionOptions,
walkedFolder: IFolderData
@@ -142,7 +161,7 @@ export const getFileConstituents = (filePath: string) => {
};
/**
* Method that infers MIME type from a filepath
* Method that infers MIME type from a filepath
* @param {string} filePath
* @returns {Promise} string
*/
@@ -155,16 +174,27 @@ export const getMimeType = async (filePath: string) => {
});
};
/**
* This function creates a directory at a specified path using the fse.ensureDir method and throws an
* error if it fails.
* @param {any} options - The options parameter is an optional object that can be passed to the
* fse.ensureDir method to configure its behavior. It can include properties such as mode, which sets
* the permissions of the directory, and fs, which specifies the file system module to use.
* @param {string} directoryPath - The `directoryPath` parameter is a string that represents the path
* of the directory that needs to be created.
*/
export const createDirectory = async (options: any, directoryPath: string) => {
try {
await fse.ensureDir(directoryPath, options);
await fse.ensureDir(directoryPath);
console.info(`Directory [ %s ] was created.`, directoryPath);
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.error(`Failed to create directory [ ${directoryPath} ]:`, error);
throw new Errors.MoleculerError(
"Failed to create directory",
`Failed to create directory: ${directoryPath} - ${errMsg}`,
500,
"FileOpsError",
error
{ directoryPath, originalError: errMsg }
);
}
};

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

View 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));
}

View 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"
);
}
}

View File

@@ -0,0 +1,391 @@
/**
* GraphQL Import Utilities
* Helper functions for importing comics using GraphQL mutations
*/
import { ServiceBroker } from "moleculer";
/**
* Import a comic using GraphQL mutation
*/
export async function importComicViaGraphQL(
broker: ServiceBroker,
importData: {
filePath: string;
fileSize?: number;
sourcedMetadata?: {
comicInfo?: any;
comicvine?: any;
metron?: any;
gcd?: any;
locg?: any;
};
inferredMetadata?: {
issue: {
name?: string;
number?: number;
year?: string;
subtitle?: string;
};
};
rawFileDetails: {
name: string;
filePath: string;
fileSize?: number;
extension?: string;
mimeType?: string;
containedIn?: string;
pageCount?: number;
};
wanted?: {
source?: string;
markEntireVolumeWanted?: boolean;
issues?: any[];
volume?: any;
};
acquisition?: {
source?: {
wanted?: boolean;
name?: string;
};
directconnect?: {
downloads?: any[];
};
};
}
): Promise<{
success: boolean;
comic: any;
message: string;
canonicalMetadataResolved: boolean;
}> {
const mutation = `
mutation ImportComic($input: ImportComicInput!) {
importComic(input: $input) {
success
message
canonicalMetadataResolved
comic {
id
canonicalMetadata {
title { value, provenance { source, confidence } }
series { value, provenance { source } }
issueNumber { value, provenance { source } }
publisher { value, provenance { source } }
description { value, provenance { source } }
}
rawFileDetails {
name
filePath
fileSize
}
}
}
}
`;
// Prepare input
const input: any = {
filePath: importData.filePath,
rawFileDetails: importData.rawFileDetails,
};
if (importData.fileSize) {
input.fileSize = importData.fileSize;
}
if (importData.inferredMetadata) {
input.inferredMetadata = importData.inferredMetadata;
}
if (importData.sourcedMetadata) {
input.sourcedMetadata = {};
if (importData.sourcedMetadata.comicInfo) {
input.sourcedMetadata.comicInfo = JSON.stringify(
importData.sourcedMetadata.comicInfo
);
}
if (importData.sourcedMetadata.comicvine) {
input.sourcedMetadata.comicvine = JSON.stringify(
importData.sourcedMetadata.comicvine
);
}
if (importData.sourcedMetadata.metron) {
input.sourcedMetadata.metron = JSON.stringify(
importData.sourcedMetadata.metron
);
}
if (importData.sourcedMetadata.gcd) {
input.sourcedMetadata.gcd = JSON.stringify(
importData.sourcedMetadata.gcd
);
}
if (importData.sourcedMetadata.locg) {
input.sourcedMetadata.locg = importData.sourcedMetadata.locg;
}
}
if (importData.wanted) {
input.wanted = importData.wanted;
}
if (importData.acquisition) {
input.acquisition = importData.acquisition;
}
try {
const result: any = await broker.call("graphql.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
View 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;
}
}

View File

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

View File

@@ -32,6 +32,7 @@ SOFTWARE.
*/
import { createReadStream, createWriteStream, existsSync, statSync } from "fs";
import { execFile } from "child_process";
import { isEmpty, isNil, isUndefined, remove, each, map, reject } from "lodash";
import * as p7zip from "p7zip-threetwo";
import path from "path";
@@ -47,6 +48,7 @@ import {
getMimeType,
} from "../utils/file.utils";
import { convertXMLToJSON } from "./xml.utils";
const { MoleculerError } = require("moleculer").Errors;
const fse = require("fs-extra");
const Unrar = require("unrar");
interface RarFile {
@@ -73,7 +75,7 @@ const errors = [];
*/
export const extractComicInfoXMLFromRar = async (
filePath: string,
mimeType: string,
mimeType: string
): Promise<any> => {
try {
// Create the target directory
@@ -87,129 +89,340 @@ export const extractComicInfoXMLFromRar = async (
)}`;
await createDirectory(directoryOptions, targetDirectory);
const archive = new Unrar({
path: path.resolve(filePath),
bin: `${UNRAR_BIN_PATH}`, // this will change depending on Docker base OS
arguments: ["-v"],
});
const filesInArchive: [RarFile] = await new Promise(
(resolve, reject) => {
return archive.list((err, entries) => {
if (err) {
console.log(`DEBUG: ${JSON.stringify(err, null, 2)}`);
reject(err);
}
resolve(entries);
});
}
);
// Try unrar-based extraction first, fall back to p7zip if it fails
let unrarError: Error | null = null;
try {
const result = await extractComicInfoXMLFromRarUsingUnrar(
filePath,
mimeType,
targetDirectory,
fileNameWithoutExtension,
extension
);
return result;
} catch (err) {
unrarError = err;
console.warn(
`unrar-based extraction failed for ${filePath}: ${err.message}. Falling back to p7zip.`
);
}
remove(filesInArchive, ({ type }) => type === "Directory");
const comicInfoXML = remove(
filesInArchive,
({ name }) => path.basename(name).toLowerCase() === "comicinfo.xml"
);
try {
const result = await extractComicInfoXMLFromRarUsingP7zip(
filePath,
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,
({ name }) =>
!IMPORT_IMAGE_FILE_FORMATS.includes(
path.extname(name).toLowerCase()
)
);
const files = filesInArchive.sort((a, b) => {
if (!isUndefined(a) && !isUndefined(b)) {
return path
.basename(a.name)
.toLowerCase()
.localeCompare(path.basename(b.name).toLowerCase());
}
});
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}`
),
},
});
}
/**
* List files in a RAR archive using the unrar binary directly.
* Uses `unrar lb` (bare list) for reliable output — one filename per line.
*/
const listRarFiles = (filePath: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
execFile(
UNRAR_BIN_PATH,
["lb", path.resolve(filePath)],
{ maxBuffer: 10 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
return reject(
new Error(
`unrar lb failed for ${filePath}: ${err.message}${stderr ? ` (stderr: ${stderr})` : ""}`
)
);
}
);
});
const files = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
resolve(files);
}
);
});
};
return Promise.all([comicInfoXMLFilePromise, coverFilePromise]);
} catch (err) {
reject(err);
/**
* Extract a single file from a RAR archive to stdout as a Buffer.
* 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 (
filePath: string,
mimeType: string,
mimeType: string
): Promise<any> => {
try {
// Create the target directory
@@ -251,10 +464,15 @@ export const extractComicInfoXMLFromZip = async (
.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
extractionTargets.push(files[0].name);
filesToWriteToDisk.coverFile = path.basename(files[0].name);
if (!isEmpty(comicInfoXMLFileObject)) {
filesToWriteToDisk.comicInfoXML = comicInfoXMLFileObject[0].name;
extractionTargets.push(filesToWriteToDisk.comicInfoXML);
@@ -305,45 +523,32 @@ export const extractComicInfoXMLFromZip = async (
}
});
// Write the cover to disk
const coverFilePromise = new Promise((resolve, reject) => {
const sharpStream = sharp().resize(275).toFormat("png");
const coverStream = createReadStream(
`${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
)}`
),
},
});
}
);
});
const coverBaseName = sanitize(path.basename(filesToWriteToDisk.coverFile, path.extname(filesToWriteToDisk.coverFile)));
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`;
const coverInputFile = `${targetDirectory}/${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) {
reject(err);
throw err;
}
};
@@ -356,18 +561,32 @@ export const extractFromArchive = async (filePath: string) => {
switch (mimeType) {
case "application/x-7z-compressed; charset=binary":
case "application/zip; charset=binary":
const cbzResult = await extractComicInfoXMLFromZip(filePath, mimeType);
const cbzResult = await extractComicInfoXMLFromZip(
filePath,
mimeType
);
if (!Array.isArray(cbzResult)) {
throw new Error(`extractComicInfoXMLFromZip returned a non-iterable result for: ${filePath}`);
}
return Object.assign({}, ...cbzResult);
case "application/x-rar; charset=binary":
const cbrResult = await extractComicInfoXMLFromRar(filePath, mimeType);
const cbrResult = await extractComicInfoXMLFromRar(
filePath,
mimeType
);
if (!Array.isArray(cbrResult)) {
throw new Error(`extractComicInfoXMLFromRar returned a non-iterable result for: ${filePath}`);
}
return Object.assign({}, ...cbrResult);
default:
console.log(
console.error(
"Error inferring filetype for comicinfo.xml extraction."
);
break;
throw new MoleculerError({}, 500, "FILETYPE_INFERENCE_ERROR", {
data: { message: "Cannot infer filetype." },
});
}
};
@@ -410,7 +629,7 @@ export const uncompressZipArchive = async (filePath: string, options: any) => {
mode: 0o2775,
};
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 p7zip.extract(filePath, targetDirectory, [], "", false);
@@ -424,7 +643,7 @@ export const uncompressRarArchive = async (filePath: string, options: any) => {
};
const { fileNameWithoutExtension, extension } =
getFileConstituents(filePath);
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${fileNameWithoutExtension}`;
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${sanitize(fileNameWithoutExtension)}`;
await createDirectory(directoryOptions, targetDirectory);
const archive = new Unrar({