3 Commits

Author SHA1 Message Date
2d9ea15550 🔧 Added canonical metadata related changes 2025-11-17 13:00:11 -05:00
755381021d Additions 2025-10-29 12:25:05 -04:00
a9bfa479c4 🔧 Added graphQL bits 2025-09-23 18:14:35 -04:00
41 changed files with 3422 additions and 12002 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,3 +0,0 @@
{
"esversion": 10
}

356
CANONICAL_METADATA_GUIDE.md Normal file
View File

@@ -0,0 +1,356 @@
# Canonical Comic Metadata Model - Implementation Guide
## 🎯 Overview
The canonical metadata model provides a comprehensive system for managing comic book metadata from multiple sources with proper **provenance tracking**, **confidence scoring**, and **conflict resolution**.
## 🏗️ Architecture
### **Core Components:**
1. **📋 Type Definitions** ([`models/canonical-comic.types.ts`](models/canonical-comic.types.ts:1))
2. **🎯 GraphQL Schema** ([`models/graphql/canonical-typedef.ts`](models/graphql/canonical-typedef.ts:1))
3. **🔧 Resolution Engine** ([`utils/metadata-resolver.utils.ts`](utils/metadata-resolver.utils.ts:1))
4. **💾 Database Model** ([`models/canonical-comic.model.ts`](models/canonical-comic.model.ts:1))
5. **⚙️ Service Layer** ([`services/canonical-metadata.service.ts`](services/canonical-metadata.service.ts:1))
---
## 📊 Metadata Sources & Ranking
### **Source Priority (Highest to Lowest):**
```typescript
enum MetadataSourceRank {
USER_MANUAL = 1, // User overrides - highest priority
COMICINFO_XML = 2, // Embedded metadata - high trust
COMICVINE = 3, // ComicVine API - authoritative
METRON = 4, // Metron API - authoritative
GCD = 5, // Grand Comics Database - community
LOCG = 6, // League of Comic Geeks - specialized
LOCAL_FILE = 7 // Filename inference - lowest trust
}
```
### **Confidence Scoring:**
- **User Manual**: 1.0 (100% trusted)
- **ComicInfo.XML**: 0.8-0.95 (based on completeness)
- **ComicVine**: 0.9 (highly reliable API)
- **Metron**: 0.85 (reliable API)
- **GCD**: 0.8 (community-maintained)
- **Local File**: 0.3 (inference-based)
---
## 🔄 Usage Examples
### **1. Import ComicVine Metadata**
```typescript
// REST API
POST /api/canonicalMetadata/importComicVine/60f7b1234567890abcdef123
{
"comicVineData": {
"id": 142857,
"name": "Amazing Spider-Man #1",
"issue_number": "1",
"cover_date": "2023-01-01",
"volume": {
"id": 12345,
"name": "Amazing Spider-Man",
"start_year": 2023,
"publisher": { "name": "Marvel Comics" }
},
"person_credits": [
{ "name": "Dan Slott", "role": "writer" }
]
}
}
```
```typescript
// Service usage
const result = await broker.call('canonicalMetadata.importComicVineMetadata', {
comicId: '60f7b1234567890abcdef123',
comicVineData: comicVineData,
forceUpdate: false
});
```
### **2. Import ComicInfo.XML**
```typescript
POST /api/canonicalMetadata/importComicInfo/60f7b1234567890abcdef123
{
"xmlData": {
"Title": "Amazing Spider-Man",
"Series": "Amazing Spider-Man",
"Number": "1",
"Year": 2023,
"Month": 1,
"Writer": "Dan Slott",
"Penciller": "John Romita Jr",
"Publisher": "Marvel Comics"
}
}
```
### **3. Set Manual Metadata (Highest Priority)**
```typescript
PUT /api/canonicalMetadata/manual/60f7b1234567890abcdef123/title
{
"value": "The Amazing Spider-Man #1",
"confidence": 1.0,
"notes": "User corrected title formatting"
}
```
### **4. Resolve Metadata Conflicts**
```typescript
// Get conflicts
GET /api/canonicalMetadata/conflicts/60f7b1234567890abcdef123
// Resolve by selecting preferred source
POST /api/canonicalMetadata/resolve/60f7b1234567890abcdef123/title
{
"selectedSource": "COMICVINE"
}
```
### **5. Query with Source Filtering**
```graphql
query {
searchComicsByMetadata(
title: "Spider-Man"
sources: [COMICVINE, COMICINFO_XML]
minConfidence: 0.8
) {
resolvedMetadata {
title
series { name volume publisher }
creators { name role }
}
canonicalMetadata {
title {
value
source
confidence
timestamp
sourceUrl
}
}
}
}
```
---
## 🔧 Data Structure
### **Canonical Metadata Storage:**
```typescript
{
"canonicalMetadata": {
"title": [
{
"value": "Amazing Spider-Man #1",
"source": "COMICVINE",
"confidence": 0.9,
"rank": 3,
"timestamp": "2023-01-15T10:00:00Z",
"sourceId": "142857",
"sourceUrl": "https://comicvine.gamespot.com/issue/4000-142857/"
},
{
"value": "Amazing Spider-Man",
"source": "COMICINFO_XML",
"confidence": 0.8,
"rank": 2,
"timestamp": "2023-01-15T09:00:00Z"
}
],
"creators": [
{
"value": [
{ "name": "Dan Slott", "role": "Writer" },
{ "name": "John Romita Jr", "role": "Penciller" }
],
"source": "COMICINFO_XML",
"confidence": 0.85,
"rank": 2,
"timestamp": "2023-01-15T09:00:00Z"
}
]
}
}
```
### **Resolved Metadata (Best Values):**
```typescript
{
"resolvedMetadata": {
"title": "Amazing Spider-Man #1", // From ComicVine (higher confidence)
"series": {
"name": "Amazing Spider-Man",
"volume": 1,
"publisher": "Marvel Comics"
},
"creators": [
{ "name": "Dan Slott", "role": "Writer" },
{ "name": "John Romita Jr", "role": "Penciller" }
],
"lastResolved": "2023-01-15T10:30:00Z",
"resolutionConflicts": [
{
"field": "title",
"conflictingValues": [
{ "value": "Amazing Spider-Man #1", "source": "COMICVINE", "confidence": 0.9 },
{ "value": "Amazing Spider-Man", "source": "COMICINFO_XML", "confidence": 0.8 }
]
}
]
}
}
```
---
## ⚙️ Resolution Strategies
### **Available Strategies:**
```typescript
const strategies = {
// Use source with highest confidence score
highest_confidence: { strategy: 'highest_confidence' },
// Use source with highest rank (USER_MANUAL > COMICINFO_XML > COMICVINE...)
highest_rank: { strategy: 'highest_rank' },
// Use most recently added metadata
most_recent: { strategy: 'most_recent' },
// Prefer user manual entries
user_preference: { strategy: 'user_preference' },
// Attempt to find consensus among sources
consensus: { strategy: 'consensus' }
};
```
### **Custom Strategy:**
```typescript
const customStrategy: MetadataResolutionStrategy = {
strategy: 'highest_rank',
minimumConfidence: 0.7,
allowedSources: [MetadataSource.COMICVINE, MetadataSource.COMICINFO_XML],
fieldSpecificStrategies: {
'creators': { strategy: 'consensus' }, // Merge creators from multiple sources
'title': { strategy: 'highest_confidence' } // Use most confident title
}
};
```
---
## 🚀 Integration Workflow
### **1. Local File Import Process:**
```typescript
// 1. Extract file metadata
const localMetadata = extractLocalMetadata(filePath);
comic.addMetadata('title', inferredTitle, MetadataSource.LOCAL_FILE, 0.3);
// 2. Parse ComicInfo.XML (if exists)
if (comicInfoXML) {
await broker.call('canonicalMetadata.importComicInfoXML', {
comicId: comic._id,
xmlData: comicInfoXML
});
}
// 3. Enhance with external APIs
const comicVineMatch = await searchComicVine(comic.resolvedMetadata.title);
if (comicVineMatch) {
await broker.call('canonicalMetadata.importComicVineMetadata', {
comicId: comic._id,
comicVineData: comicVineMatch
});
}
// 4. Resolve final metadata
await broker.call('canonicalMetadata.reResolveMetadata', {
comicId: comic._id
});
```
### **2. Conflict Resolution Workflow:**
```typescript
// 1. Detect conflicts
const conflicts = await broker.call('canonicalMetadata.getMetadataConflicts', {
comicId: comic._id
});
// 2. Present to user for resolution
if (conflicts.length > 0) {
// Show UI with conflicting values and sources
const userChoice = await presentConflictResolution(conflicts);
// 3. Apply user's resolution
await broker.call('canonicalMetadata.resolveMetadataConflict', {
comicId: comic._id,
field: userChoice.field,
selectedSource: userChoice.source
});
}
```
---
## 📈 Performance Considerations
### **Database Indexes:**
-**Text search**: `resolvedMetadata.title`, `resolvedMetadata.series.name`
-**Unique identification**: `series.name` + `volume` + `issueNumber`
-**Source filtering**: `canonicalMetadata.*.source` + `confidence`
-**Import status**: `importStatus.isImported` + `tagged`
### **Optimization Tips:**
- **Batch metadata imports** for large collections
- **Cache resolved metadata** for frequently accessed comics
- **Index on confidence scores** for quality filtering
- **Paginate conflict resolution** for large libraries
---
## 🛡️ Best Practices
### **Data Quality:**
1. **Always validate** external API responses before import
2. **Set appropriate confidence** scores based on source reliability
3. **Preserve original data** in source-specific fields
4. **Log metadata changes** for audit trails
### **Conflict Management:**
1. **Prefer user overrides** for disputed fields
2. **Use consensus** for aggregatable fields (creators, characters)
3. **Maintain provenance** links to original sources
4. **Provide clear UI** for conflict resolution
### **Performance:**
1. **Re-resolve metadata** only when sources change
2. **Cache frequently accessed** resolved metadata
3. **Batch operations** for bulk imports
4. **Use appropriate indexes** for common queries
---
This canonical metadata model provides enterprise-grade metadata management with full provenance tracking, confidence scoring, and flexible conflict resolution for comic book collections of any size.

View File

@@ -0,0 +1,423 @@
# Moleculer Microservices Dependency Analysis
**ThreeTwo Core Service - Comic Book Library Management System**
## System Overview
This **ThreeTwo Core Service** is a sophisticated **comic book library management system** built on Moleculer microservices architecture. The system demonstrates advanced patterns including:
- **Event-driven architecture** with real-time WebSocket communication
- **Asynchronous job processing** with BullMQ for heavy operations
- **Multi-source metadata aggregation** with canonical data resolution
- **Hybrid search** combining MongoDB aggregation and ElasticSearch
- **External system integrations** (P2P, BitTorrent, Comic APIs)
### Technical Stack
- **Framework**: Moleculer.js microservices
- **Node ID**: `threetwo-core-service`
- **Transport**: Redis (`redis://localhost:6379`)
- **Databases**: MongoDB + ElasticSearch
- **Queue System**: BullMQ (Redis-backed)
- **Real-time**: Socket.IO with Redis adapter
- **External APIs**: ComicVine, AirDC++, qBittorrent
## Service Architecture
### Core Services
| Service | File | Role | Dependencies |
|---------|------|------|-------------|
| **API** | [`api.service.ts`](services/api.service.ts) | API Gateway + File System Watcher | → library, jobqueue |
| **Library** | [`library.service.ts`](services/library.service.ts) | Core Comic Library Management | → jobqueue, search, comicvine |
| **JobQueue** | [`jobqueue.service.ts`](services/jobqueue.service.ts) | Asynchronous Job Processing (BullMQ) | → library, socket |
| **Socket** | [`socket.service.ts`](services/socket.service.ts) | Real-time Communication (Socket.IO) | → library, jobqueue |
| **Search** | [`search.service.ts`](services/search.service.ts) | ElasticSearch Integration | ElasticSearch client |
| **GraphQL** | [`graphql.service.ts`](services/graphql.service.ts) | GraphQL API Layer | → search |
### Supporting Services
| Service | File | Role | Dependencies |
|---------|------|------|-------------|
| **AirDC++** | [`airdcpp.service.ts`](services/airdcpp.service.ts) | P2P File Sharing Integration | External AirDC++ client |
| **Settings** | [`settings.service.ts`](services/settings.service.ts) | Configuration Management | MongoDB |
| **Image Transform** | [`imagetransformation.service.ts`](services/imagetransformation.service.ts) | Cover Processing | File system |
| **OPDS** | [`opds.service.ts`](services/opds.service.ts) | Comic Catalog Feeds | File system |
| **Torrent Jobs** | [`torrentjobs.service.ts`](services/torrentjobs.service.ts) | BitTorrent Integration | → library, qbittorrent |
## Service-to-Service Dependencies
### Core Service Interactions
#### 1. API Service → Other Services
```typescript
// File system watcher triggers import
ctx.broker.call("library.walkFolders", { basePathToWalk: filePath })
ctx.broker.call("importqueue.processImport", { fileObject })
```
#### 2. Library Service → Dependencies
```typescript
// Job queue integration
this.broker.call("jobqueue.enqueue", { action: "enqueue.async" })
// Search operations
ctx.broker.call("search.searchComic", { elasticSearchQueries })
ctx.broker.call("search.deleteElasticSearchIndices", {})
// External metadata
ctx.broker.call("comicvine.getVolumes", { volumeURI })
```
#### 3. JobQueue Service → Dependencies
```typescript
// Import processing
this.broker.call("library.importFromJob", { importType, payload })
// Real-time updates
this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_COVER_EXTRACTED",
args: [{ completedJobCount, importResult }]
})
```
#### 4. Socket Service → Dependencies
```typescript
// Job management
ctx.broker.call("jobqueue.getJobCountsByType", {})
ctx.broker.call("jobqueue.toggle", { action: queueAction })
// Download tracking
ctx.call("library.applyAirDCPPDownloadMetadata", {
bundleId, comicObjectId, name, size, type
})
```
#### 5. GraphQL Service → Search
```typescript
// Wanted comics query
const result = await ctx.broker.call("search.issue", {
query: eSQuery,
pagination: { size: limit, from: offset },
type: "wanted"
})
```
## API Endpoint Mapping
### REST API Routes (`/api/*`)
#### Library Management
- `POST /api/library/walkFolders` → [`library.walkFolders`](services/library.service.ts:82)
- `POST /api/library/newImport` → [`library.newImport`](services/library.service.ts:165) → [`jobqueue.enqueue`](services/library.service.ts:219)
- `POST /api/library/getComicBooks` → [`library.getComicBooks`](services/library.service.ts:535)
- `POST /api/library/getComicBookById` → [`library.getComicBookById`](services/library.service.ts:550)
- `POST /api/library/flushDB` → [`library.flushDB`](services/library.service.ts:818) → [`search.deleteElasticSearchIndices`](services/library.service.ts:839)
- `GET /api/library/libraryStatistics` → [`library.libraryStatistics`](services/library.service.ts:684)
#### Job Management
- `GET /api/jobqueue/getJobCountsByType` → [`jobqueue.getJobCountsByType`](services/jobqueue.service.ts:31)
- `GET /api/jobqueue/toggle` → [`jobqueue.toggle`](services/jobqueue.service.ts:38)
- `GET /api/jobqueue/getJobResultStatistics` → [`jobqueue.getJobResultStatistics`](services/jobqueue.service.ts:214)
#### Search Operations
- `POST /api/search/searchComic` → [`search.searchComic`](services/search.service.ts:28)
- `POST /api/search/searchIssue` → [`search.issue`](services/search.service.ts:60)
- `GET /api/search/deleteElasticSearchIndices` → [`search.deleteElasticSearchIndices`](services/search.service.ts:171)
#### AirDC++ Integration
- `POST /api/airdcpp/initialize` → [`airdcpp.initialize`](services/airdcpp.service.ts:24)
- `POST /api/airdcpp/getHubs` → [`airdcpp.getHubs`](services/airdcpp.service.ts:59)
- `POST /api/airdcpp/search` → [`airdcpp.search`](services/airdcpp.service.ts:96)
#### Image Processing
- `POST /api/imagetransformation/resizeImage` → [`imagetransformation.resize`](services/imagetransformation.service.ts:37)
- `POST /api/imagetransformation/analyze` → [`imagetransformation.analyze`](services/imagetransformation.service.ts:57)
### GraphQL Endpoints
- `POST /graphql` → [`graphql.wantedComics`](services/graphql.service.ts:49) → [`search.issue`](services/graphql.service.ts:77)
### Static File Serving
- `/userdata/*` → Static files from `./userdata`
- `/comics/*` → Static files from `./comics`
- `/logs/*` → Static files from `logs`
## Event-Driven Communication
### Job Queue Events
#### Job Completion Events
```typescript
// Successful import completion
"enqueue.async.completed" socket.broadcast("LS_COVER_EXTRACTED", {
completedJobCount,
importResult: job.returnvalue.data.importResult
})
// Failed import handling
"enqueue.async.failed" socket.broadcast("LS_COVER_EXTRACTION_FAILED", {
failedJobCount,
importResult: job
})
// Queue drained
"drained" socket.broadcast("LS_IMPORT_QUEUE_DRAINED", {
message: "drained"
})
```
#### Archive Processing Events
```typescript
// Archive uncompression completed
"uncompressFullArchive.async.completed" socket.broadcast("LS_UNCOMPRESSION_JOB_COMPLETE", {
uncompressedArchive: job.returnvalue
})
```
### File System Events
```typescript
// File watcher events (debounced 200ms)
fileWatcher.on("add", (path, stats) {
broker.call("library.walkFolders", { basePathToWalk: filePath })
broker.call("importqueue.processImport", { fileObject })
broker.broadcast(event, { path: filePath })
})
```
### WebSocket Events
#### Real-time Search
```typescript
// Search initiation
socket.emit("searchInitiated", { instance })
// Live search results
socket.emit("searchResultAdded", groupedResult)
socket.emit("searchResultUpdated", updatedResult)
socket.emit("searchComplete", { message })
```
#### Download Progress
```typescript
// Download status
broker.emit("downloadCompleted", bundleDBImportResult)
broker.emit("downloadError", error.message)
// Progress tracking
socket.emit("downloadTick", data)
```
## Data Flow Architecture
### 1. Comic Import Processing Flow
```mermaid
graph TD
A[File System Watcher] --> B[library.walkFolders]
B --> C[jobqueue.enqueue]
C --> D[jobqueue.enqueue.async]
D --> E[Archive Extraction]
E --> F[Metadata Processing]
F --> G[Canonical Metadata Creation]
G --> H[library.importFromJob]
H --> I[MongoDB Storage]
I --> J[ElasticSearch Indexing]
J --> K[socket.broadcast LS_COVER_EXTRACTED]
```
### 2. Search & Discovery Flow
```mermaid
graph TD
A[GraphQL/REST Query] --> B[search.issue]
B --> C[ElasticSearch Query]
C --> D[Results Enhancement]
D --> E[Metadata Scoring]
E --> F[Structured Response]
```
### 3. Download Management Flow
```mermaid
graph TD
A[socket[search]] --> B[airdcpp.search]
B --> C[Real-time Results]
C --> D[socket[download]]
D --> E[library.applyAirDCPPDownloadMetadata]
E --> F[Progress Tracking]
F --> G[Import Pipeline]
```
## Database Dependencies
### MongoDB Collections
| Collection | Model | Used By Services |
|------------|-------|-----------------|
| **comics** | [`Comic`](models/comic.model.ts) | library, search, jobqueue, imagetransformation |
| **settings** | [`Settings`](models/settings.model.ts) | settings |
| **sessions** | [`Session`](models/session.model.ts) | socket |
| **jobresults** | [`JobResult`](models/jobresult.model.ts) | jobqueue |
### ElasticSearch Integration
- **Index**: `comics` - Full-text search with metadata scoring
- **Client**: [`eSClient`](services/search.service.ts:13) from [`comic.model.ts`](models/comic.model.ts)
- **Query Types**: match_all, multi_match, bool queries with field boosting
### Redis Usage
| Purpose | Services | Configuration |
|---------|----------|---------------|
| **Transport** | All services | [`moleculer.config.ts:93`](moleculer.config.ts:93) |
| **Job Queue** | jobqueue | [`jobqueue.service.ts:27`](services/jobqueue.service.ts:27) |
| **Socket.IO Adapter** | socket | [`socket.service.ts:48`](services/socket.service.ts:48) |
| **Job Counters** | jobqueue | [`completedJobCount`](services/jobqueue.service.ts:392), [`failedJobCount`](services/jobqueue.service.ts:422) |
## External System Integrations
### AirDC++ (P2P File Sharing)
```typescript
// Integration wrapper
const ADCPPSocket = new AirDCPPSocket(config)
await ADCPPSocket.connect()
// Search operations
const searchInstance = await ADCPPSocket.post("search")
const searchInfo = await ADCPPSocket.post(`search/${searchInstance.id}/hub_search`, query)
// Download management
const downloadResult = await ADCPPSocket.post(`search/${searchInstanceId}/results/${resultId}/download`)
```
### ComicVine API
```typescript
// Metadata enrichment
const volumeDetails = await this.broker.call("comicvine.getVolumes", {
volumeURI: matchedResult.volume.api_detail_url
})
```
### qBittorrent Client
```typescript
// Torrent monitoring
const torrents = await this.broker.call("qbittorrent.getTorrentRealTimeStats", { infoHashes })
```
## Metadata Management System
### Multi-Source Metadata Aggregation
The system implements sophisticated metadata management with source prioritization:
#### Source Priority Order
1. **ComicInfo.xml** (embedded in archives)
2. **ComicVine API** (external database)
3. **Metron** (comic database)
4. **Grand Comics Database (GCD)**
5. **League of Comic Geeks (LOCG)**
6. **Filename Inference** (fallback)
#### Canonical Metadata Structure
```typescript
const canonical = {
title: findBestValue('title', inferredMetadata.title),
series: {
name: findSeriesValue(['series', 'seriesName', 'name'], inferredMetadata.series),
volume: findBestValue('volume', inferredMetadata.volume || 1),
startYear: findBestValue('startYear', inferredMetadata.issue?.year)
},
issueNumber: findBestValue('issueNumber', inferredMetadata.issue?.number),
publisher: findBestValue('publisher', null),
creators: [], // Combined from all sources
completeness: {
score: calculatedScore,
missingFields: [],
lastCalculated: currentTime
}
}
```
## Performance & Scalability Insights
### Asynchronous Processing
- **Heavy Operations**: Comic import, archive extraction, metadata processing
- **Queue System**: BullMQ with Redis backing for reliability
- **Job Types**: Import processing, archive extraction, torrent monitoring
- **Real-time Updates**: WebSocket progress notifications
### Search Optimization
- **Dual Storage**: MongoDB (transactional) + ElasticSearch (search)
- **Metadata Scoring**: Canonical metadata with source priority
- **Query Types**: Full-text, field-specific, boolean combinations
- **Caching**: Moleculer built-in memory caching
### External Integration Resilience
- **Timeout Handling**: Custom timeouts for long-running operations
- **Error Propagation**: Structured error responses with context
- **Connection Management**: Reusable connections for external APIs
- **Retry Logic**: Built-in retry policies for failed operations
## Critical Dependency Patterns
### 1. Service Chain Dependencies
- **Import Pipeline**: api → library → jobqueue → socket
- **Search Pipeline**: graphql → search → ElasticSearch
- **Download Pipeline**: socket → airdcpp → library
### 2. Circular Dependencies (Managed)
- **socket ←→ library**: Download coordination and progress updates
- **jobqueue ←→ socket**: Job progress notifications and queue control
### 3. Shared Resource Dependencies
- **MongoDB**: library, search, jobqueue, settings services
- **Redis**: All services (transport) + jobqueue (BullMQ) + socket (adapter)
- **ElasticSearch**: search, graphql services
## Architecture Strengths
### 1. Separation of Concerns
- **API Gateway**: Pure routing and file serving
- **Business Logic**: Centralized in library service
- **Data Access**: Abstracted through DbMixin
- **External Integration**: Isolated in dedicated services
### 2. Event-Driven Design
- **File System Events**: Automatic import triggering
- **Job Lifecycle Events**: Progress tracking and error handling
- **Real-time Communication**: WebSocket event broadcasting
### 3. Robust Metadata Management
- **Multi-Source Aggregation**: ComicVine, ComicInfo.xml, filename inference
- **Canonical Resolution**: Smart metadata merging with source attribution
- **User Curation Support**: Framework for manual metadata override
### 4. Scalability Features
- **Microservices Architecture**: Independent service scaling
- **Asynchronous Processing**: Heavy operations don't block API responses
- **Redis Transport**: Distributed service communication
- **Job Queue**: Reliable background processing with retry logic
## Potential Areas for Improvement
### 1. Service Coupling
- **High Interdependence**: library ←→ jobqueue ←→ socket tight coupling
- **Recommendation**: Event-driven decoupling for some operations
### 2. Error Handling
- **Inconsistent Patterns**: Mix of raw errors and MoleculerError usage
- **Recommendation**: Standardized error handling middleware
### 3. Configuration Management
- **Environment Variables**: Direct access vs centralized configuration
- **Recommendation**: Enhanced settings service for runtime configuration
### 4. Testing Strategy
- **Integration Testing**: Complex service interactions need comprehensive testing
- **Recommendation**: Contract testing between services
## Summary
This Moleculer-based architecture demonstrates sophisticated microservices patterns with:
- **11 specialized services** with clear boundaries
- **47 REST endpoints** + GraphQL layer
- **3 WebSocket namespaces** for real-time communication
- **Multi-database architecture** (MongoDB + ElasticSearch)
- **Advanced job processing** with BullMQ
- **External system integration** (P2P, BitTorrent, Comic APIs)
The system successfully manages complex domain requirements while maintaining good separation of concerns and providing excellent user experience through real-time updates and comprehensive metadata management.

195
README.md
View File

@@ -1,38 +1,175 @@
# threetwo-core-service # ThreeTwo Core Service
This [moleculer-based](https://github.com/moleculerjs/moleculer-web) microservice houses endpoints for the following functions: **A comprehensive comic book library management system** built as a high-performance Moleculer microservices architecture. ThreeTwo automatically processes comic archives (CBR, CBZ, CB7), extracts metadata, generates thumbnails, and provides powerful search and real-time synchronization capabilities.
1. Local import of a comic library into mongo (currently supports `cbr` and `cbz` files) ## 🎯 What This Service Does
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 ThreeTwo transforms chaotic comic book collections into intelligently organized, searchable digital libraries by:
1. You need the following dependencies installed: `mongo`, `elasticsearch` and `redis` - **📚 Automated Library Management** - Monitors directories and automatically imports new comics
2. You also need binaries for `unrar` and `p7zip` - **🧠 Intelligent Metadata Extraction** - Parses ComicInfo.XML and enriches data from external APIs (ComicVine)
3. Clone this repo - **🔍 Advanced Search** - ElasticSearch-powered multi-field search with confidence scoring
4. Run `npm i` - **📱 Real-time Updates** - Live progress tracking and notifications via Socket.IO
5. Assuming you installed the dependencies correctly, run: - **🎨 Media Processing** - Automatic thumbnail generation and image optimization
``` ## 🏗️ Architecture
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 Built on **Moleculer microservices** with the following core services:
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/<serviceName>/*` API Gateway (REST) ←→ GraphQL API ←→ Socket.IO Hub
Library Service ←→ Search Service ←→ Job Queue Service
MongoDB ←→ Elasticsearch ←→ Redis (Cache/Queue)
```
## Docker Instructions ### **Key Features:**
- **Multi-format Support** - CBR, CBZ, CB7 archive processing
- **Confidence Tracking** - Metadata quality assessment and provenance
- **Job Queue System** - Background processing with BullMQ and Redis
- **Debounced File Watching** - Efficient file system monitoring
- **Batch Operations** - Scalable bulk import handling
- **Real-time Sync** - Live updates across all connected clients
1. Build the image using `docker build . -t frishi/threetwo-import-service`. Give it a hot minute. ## 🚀 API Interfaces
2. Run it using `docker run -it frishi/threetwo-import-service`
- **REST API** - `http://localhost:3000/api/` - Traditional HTTP endpoints
- **GraphQL API** - `http://localhost:4000/graphql` - Modern query interface
- **Socket.IO** - Real-time events and progress tracking
- **Static Assets** - Direct access to comic covers and images
## 🛠️ Technology Stack
- **Backend**: Moleculer, Node.js, TypeScript
- **Database**: MongoDB (persistence), Elasticsearch (search), Redis (cache/queue)
- **Processing**: BullMQ (job queues), Sharp (image processing)
- **Communication**: Socket.IO (real-time), GraphQL + REST APIs
## 📋 Prerequisites
You need the following dependencies installed:
- **MongoDB** - Document database for comic metadata
- **Elasticsearch** - Full-text search and analytics
- **Redis** - Caching and job queue backend
- **System Binaries**: `unrar` and `p7zip` for archive extraction
## 🚀 Local Development
1. **Clone and Install**
```bash
git clone <repository-url>
cd threetwo-core-service
npm install
```
2. **Environment Setup**
```bash
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
```
3. **Service Access**
- **Main API**: `http://localhost:3000/api/<serviceName>/*`
- **GraphQL Playground**: `http://localhost:4000/graphql`
- **Admin Interface**: `http://localhost:3000/` (Moleculer dashboard)
## 🐳 Docker Deployment
```bash
# Build the image
docker build . -t threetwo-core-service
# Run with docker-compose (recommended)
docker-compose up -d
# Or run standalone
docker run -it threetwo-core-service
```
## 📊 Performance Features
- **Smart Debouncing** - 200ms file system event debouncing prevents overload
- **Batch Processing** - Efficient handling of bulk import operations
- **Multi-level Caching** - Memory + Redis caching for optimal performance
- **Job Queues** - Background processing prevents UI blocking
- **Connection Pooling** - Efficient database connection management
## 🔧 Core Services
| Service | Purpose | Key Features |
|---------|---------|--------------|
| **API Gateway** | REST endpoints + file watching | CORS, rate limiting, static serving |
| **GraphQL** | Modern query interface | Flexible queries, pagination |
| **Library** | Core CRUD operations | Comic management, metadata handling |
| **Search** | ElasticSearch integration | Multi-field search, aggregations |
| **Job Queue** | Background processing | Import jobs, progress tracking |
| **Socket** | Real-time communication | Live updates, session management |
## 📈 Use Cases
- **Personal Collections** - Organize digital comic libraries (hundreds to thousands)
- **Digital Libraries** - Professional-grade comic archive management
- **Developer Integration** - API access for custom comic applications
- **Bulk Processing** - Large-scale comic digitization projects
## 🛡️ Security & Reliability
- **Input Validation** - Comprehensive parameter validation
- **File Type Verification** - Magic number verification for security
- **Error Handling** - Graceful degradation and recovery
- **Health Monitoring** - Service health checks and diagnostics
## 🧩 Recent Enhancements
### Canonical Metadata System
A comprehensive **canonical metadata model** with full provenance tracking has been implemented to unify metadata from multiple sources:
- **Multi-Source Integration**: ComicVine, Metron, GCD, ComicInfo.XML, local files, and user manual entries
- **Source Ranking System**: Prioritized confidence scoring with USER_MANUAL (1) → COMICINFO_XML (2) → COMICVINE (3) → METRON (4) → GCD (5) → LOCG (6) → LOCAL_FILE (7)
- **Conflict Resolution**: Automatic metadata merging with confidence scoring and source attribution
- **Performance Optimized**: Proper indexing, batch processing, and caching strategies
### Complete Service Architecture Analysis
Comprehensive analysis of all **12 Moleculer services** with detailed endpoint documentation:
| Service | Endpoints | Primary Function |
|---------|-----------|------------------|
| [`api`](services/api.service.ts:1) | Gateway | REST API + file watching with 200ms debouncing |
| [`library`](services/library.service.ts:1) | 21 endpoints | Core CRUD operations and metadata management |
| [`search`](services/search.service.ts:1) | 8 endpoints | Elasticsearch integration and multi-search |
| [`jobqueue`](services/jobqueue.service.ts:1) | Queue mgmt | BullMQ job processing with Redis backend |
| [`graphql`](services/graphql.service.ts:1) | GraphQL API | Modern query interface with resolvers |
| [`socket`](services/socket.service.ts:1) | Real-time | Socket.IO communication with session management |
| [`canonicalMetadata`](services/canonical-metadata.service.ts:1) | 6 endpoints | **NEW**: Metadata provenance and conflict resolution |
| `airdcpp` | Integration | AirDC++ connectivity for P2P operations |
| `imagetransformation` | Processing | Image optimization and thumbnail generation |
| `opds` | Protocol | Open Publication Distribution System support |
| `settings` | Configuration | System-wide configuration management |
| `torrentjobs` | Downloads | Torrent-based comic acquisition |
### Performance Optimizations Identified
- **Debouncing**: 200ms file system event debouncing prevents overload
- **Job Queues**: Background processing with BullMQ prevents UI blocking
- **Caching Strategy**: Multi-level caching (Memory + Redis) for optimal performance
- **Batch Operations**: Efficient bulk import handling with pagination
- **Index Optimization**: MongoDB compound indexes for metadata queries
### Files Created
- [`models/canonical-comic.types.ts`](models/canonical-comic.types.ts:1) - TypeScript definitions for canonical metadata
- [`utils/metadata-resolver.utils.ts`](utils/metadata-resolver.utils.ts:1) - Conflict resolution and confidence scoring
- [`models/canonical-comic.model.ts`](models/canonical-comic.model.ts:1) - Mongoose schema with performance indexes
- [`services/canonical-metadata.service.ts`](services/canonical-metadata.service.ts:1) - REST endpoints for metadata import
- [`models/graphql/canonical-typedef.ts`](models/graphql/canonical-typedef.ts:1) - GraphQL schema with backward compatibility
- [`CANONICAL_METADATA_GUIDE.md`](CANONICAL_METADATA_GUIDE.md:1) - Complete implementation guide
---
**ThreeTwo Core Service** provides enterprise-grade comic book library management with modern microservices architecture, real-time capabilities, and intelligent automation.

View File

@@ -1,176 +0,0 @@
/**
* @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;
}

View File

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

View File

@@ -17,17 +17,23 @@ services:
hostname: kafka1 hostname: kafka1
container_name: kafka1 container_name: kafka1
ports: ports:
- "127.0.0.1:9092:9092" # exposed ONLY to host localhost - "9092:9092"
- "29092:29092"
- "9999:9999"
environment: environment:
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_INTERNAL://0.0.0.0:29092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka1:29092 KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181" KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
KAFKA_BROKER_ID: 1 KAFKA_BROKER_ID: 1
KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO"
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_JMX_PORT: 9999
KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1}
KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer
KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
depends_on: depends_on:
- zoo1 - zoo1
networks: networks:
@@ -57,7 +63,7 @@ services:
- "27017:27017" - "27017:27017"
volumes: volumes:
- "mongodb_data:/bitnami/mongodb" - "mongodb_data:/bitnami/mongodb"
redis: redis:
image: "bitnami/redis:latest" image: "bitnami/redis:latest"
container_name: queue container_name: queue

View File

@@ -1,95 +0,0 @@
# 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

@@ -1,330 +0,0 @@
# 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

47
graphql-server.ts Normal file
View File

@@ -0,0 +1,47 @@
import express from "express";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express4";
import { typeDefs } from "./models/graphql/typedef";
import { resolvers } from "./models/graphql/resolvers";
import { ServiceBroker } from "moleculer";
import cors from "cors";
// Boot Moleculer broker in parallel
const broker = new ServiceBroker({ transporter: null }); // or your actual transporter config
async function startGraphQLServer() {
const app = express();
const apollo = new ApolloServer({
typeDefs,
resolvers,
});
await apollo.start();
app.use(
"/graphql",
cors(),
express.json(),
expressMiddleware(apollo, {
context: async ({ req }) => ({
authToken: req.headers.authorization || null,
broker,
}),
})
);
const PORT = 4000;
app.listen(PORT, () =>
console.log(`🚀 GraphQL server running at http://localhost:${PORT}/graphql`)
);
}
async function bootstrap() {
await broker.start(); // make sure Moleculer is up
await startGraphQLServer();
}
bootstrap().catch((err) => {
console.error("❌ Failed to start GraphQL server:", err);
process.exit(1);
});

View File

@@ -1,137 +0,0 @@
/**
* 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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,164 +0,0 @@
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", serializer: "JSON",
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0 // Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
requestTimeout: 60 * 1000, requestTimeout: 10 * 1000,
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry // Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
retryPolicy: { retryPolicy: {

3538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,7 @@
"lint": "eslint --ext .js,.ts .", "lint": "eslint --ext .js,.ts .",
"dc:up": "docker-compose up --build -d", "dc:up": "docker-compose up --build -d",
"dc:logs": "docker-compose logs -f", "dc:logs": "docker-compose logs -f",
"dc:down": "docker-compose down", "dc:down": "docker-compose down"
"migrate:indexes": "ts-node migrations/add-import-indexes.ts"
}, },
"keywords": [ "keywords": [
"microservices", "microservices",
@@ -24,6 +23,7 @@
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0", "@typescript-eslint/parser": "^5.56.0",
"concurrently": "^9.2.0",
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-prefer-arrow": "^1.2.2", "eslint-plugin-prefer-arrow": "^1.2.2",
@@ -39,20 +39,18 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.12.2",
"@as-integrations/express4": "^1.1.1",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@elastic/elasticsearch": "^8.13.1", "@elastic/elasticsearch": "^8.13.1",
"@graphql-tools/delegate": "^12.0.8",
"@graphql-tools/schema": "^10.0.31",
"@graphql-tools/stitch": "^10.1.12",
"@graphql-tools/utils": "^11.0.0",
"@graphql-tools/wrap": "^11.1.8",
"@jorgeferrero/stream-to-buffer": "^2.0.6", "@jorgeferrero/stream-to-buffer": "^2.0.6",
"@ltv/moleculer-apollo-server-mixin": "^0.1.30",
"@npcz/magic": "^1.3.14", "@npcz/magic": "^1.3.14",
"@root/walk": "^1.1.0", "@root/walk": "^1.1.0",
"@socket.io/redis-adapter": "^8.1.0", "@socket.io/redis-adapter": "^8.1.0",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"@types/mkdirp": "^1.0.0", "@types/mkdirp": "^1.0.0",
"@types/node": "^13.9.8", "@types/node": "^24.0.13",
"@types/string-similarity": "^4.0.0", "@types/string-similarity": "^4.0.0",
"airdcpp-apisocket": "^3.0.0-beta.8", "airdcpp-apisocket": "^3.0.0-beta.8",
"axios": "^1.6.8", "axios": "^1.6.8",
@@ -60,6 +58,7 @@
"bree": "^7.1.5", "bree": "^7.1.5",
"calibre-opds": "^1.0.7", "calibre-opds": "^1.0.7",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"cors": "^2.8.5",
"delay": "^5.0.0", "delay": "^5.0.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"filename-parser": "^1.0.4", "filename-parser": "^1.0.4",
@@ -74,12 +73,11 @@
"leven": "^3.1.0", "leven": "^3.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mkdirp": "^0.5.5", "mkdirp": "^0.5.5",
"moleculer-apollo-server": "^0.4.0",
"moleculer-bullmq": "^3.0.0", "moleculer-bullmq": "^3.0.0",
"moleculer-db": "^0.8.23", "moleculer-db": "^0.8.23",
"moleculer-db-adapter-mongoose": "^0.9.2", "moleculer-db-adapter-mongoose": "^0.9.2",
"moleculer-io": "^2.2.0", "moleculer-io": "^2.2.0",
"moleculer-web": "^0.10.5", "moleculer-web": "^0.10.8",
"mongoosastic-ts": "^6.0.3", "mongoosastic-ts": "^6.0.3",
"mongoose": "^6.10.4", "mongoose": "^6.10.4",
"mongoose-paginate-v2": "^1.3.18", "mongoose-paginate-v2": "^1.3.18",
@@ -91,17 +89,15 @@
"sharp": "^0.33.3", "sharp": "^0.33.3",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"through2": "^4.0.2", "through2": "^4.0.2",
"undici": "^7.22.0",
"unrar": "^0.2.0", "unrar": "^0.2.0",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"engines": { "engines": {
"node": ">= 18.x.x" "node": ">= 22.x.x"
}, },
"jest": { "jest": {
"coverageDirectory": "<rootDir>/coverage", "coverageDirectory": "<rootDir>/coverage",
"testEnvironment": "node", "testEnvironment": "node",
"testTimeout": 30000,
"moduleFileExtensions": [ "moduleFileExtensions": [
"ts", "ts",
"tsx", "tsx",
@@ -113,16 +109,9 @@
"testMatch": [ "testMatch": [
"**/*.spec.(ts|js)" "**/*.spec.(ts|js)"
], ],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.ts"
],
"globals": { "globals": {
"ts-jest": { "ts-jest": {
"tsconfig": "tsconfig.json" "tsConfig": "tsconfig.json"
} }
} }
} }

View File

@@ -1,9 +1,10 @@
import chokidar, { FSWatcher } from "chokidar"; import chokidar, { FSWatcher } from "chokidar";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { Service, ServiceBroker, ServiceSchema, Context } from "moleculer"; import { Service, ServiceBroker, ServiceSchema } from "moleculer";
import ApiGateway from "moleculer-web"; import ApiGateway from "moleculer-web";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { IFolderData } from "threetwo-ui-typings";
/** /**
* ApiService exposes REST endpoints and watches the comics directory for changes. * ApiService exposes REST endpoints and watches the comics directory for changes.
@@ -11,323 +12,207 @@ import debounce from "lodash/debounce";
* @extends Service * @extends Service
*/ */
export default class ApiService extends Service { export default class ApiService extends Service {
/** /**
* The chokidar file system watcher instance. * The chokidar file system watcher instance.
* @private * @private
*/ */
private fileWatcher?: any; private fileWatcher?: any;
/** /**
* Per-path debounced handlers for add/change events, keyed by file path. * Creates an instance of ApiService.
* @private * @param {ServiceBroker} broker - The Moleculer service broker instance.
*/ */
private debouncedHandlers: Map<string, ReturnType<typeof debounce>> = new Map(); public constructor(broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "api",
mixins: [ApiGateway],
settings: {
port: process.env.PORT || 3000,
routes: [
{
path: "/graphql",
whitelist: ["graphql.*"],
bodyParsers: {
json: true,
urlencoded: { extended: true },
},
aliases: {
"POST /": "graphql.wantedComics",
},
cors: {
origin: "*",
methods: ["GET", "OPTIONS", "POST"],
allowedHeaders: ["*"],
credentials: false,
},
},
{
path: "/api",
whitelist: ["**"],
cors: {
origin: "*",
methods: [
"GET",
"OPTIONS",
"POST",
"PUT",
"DELETE",
],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
use: [],
mergeParams: true,
authentication: false,
authorization: false,
autoAliases: true,
aliases: {},
callingOptions: {},
bodyParsers: {
json: { strict: false, limit: "1MB" },
urlencoded: { extended: true, limit: "1MB" },
},
mappingPolicy: "all",
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: {} },
},
events: {},
methods: {},
started: this.startWatcher,
stopped: this.stopWatcher,
});
}
/** /**
* Creates an instance of ApiService. * Initializes and starts the chokidar watcher on the COMICS_DIRECTORY.
* @param {ServiceBroker} broker - The Moleculer service broker instance. * Debounces rapid events and logs initial scan completion.
*/ * @private
public constructor(broker: ServiceBroker) { */
super(broker); private startWatcher(): void {
this.parseServiceSchema({ const rawDir = process.env.COMICS_DIRECTORY;
name: "api", if (!rawDir) {
mixins: [ApiGateway], this.logger.error("COMICS_DIRECTORY not set; cannot start watcher");
settings: { return;
port: process.env.PORT || 3000, }
routes: [ const watchDir = path.resolve(rawDir);
{ this.logger.info(`Watching comics folder at: ${watchDir}`);
path: "/api", if (!fs.existsSync(watchDir)) {
whitelist: ["**"], this.logger.error(`✖ Comics folder does not exist: ${watchDir}`);
cors: { return;
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,
}],
});
},
},
/** this.fileWatcher = chokidar.watch(watchDir, {
* Listen for watcher enable events persistent: true,
*/ ignoreInitial: true,
"IMPORT_WATCHER_ENABLED": { followSymlinks: true,
async handler(ctx: Context<{ sessionId: string }>) { depth: 10,
const { sessionId } = ctx.params; usePolling: true,
this.logger.info(`[Watcher] Re-enabled after session: ${sessionId}`); interval: 5000,
atomic: true,
// Broadcast to frontend awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
await this.broker.call("socket.broadcast", { ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"),
namespace: "/", });
event: "IMPORT_WATCHER_STATUS",
args: [{
enabled: true,
sessionId,
}],
});
},
},
},
actions: {},
methods: {},
started: this.startWatcher,
stopped: this.stopWatcher,
});
}
/** /**
* Initializes and starts the chokidar watcher on the COMICS_DIRECTORY. * Debounced handler for file system events, batching rapid triggers
* Debounces rapid events and logs initial scan completion. * into a 200ms window. Leading and trailing calls invoked.
* @private * @param {string} event - Type of file event (add, change, etc.).
*/ * @param {string} p - Path of the file or directory.
private async startWatcher(): Promise<void> { * @param {fs.Stats} [stats] - Optional file stats for add/change events.
const rawDir = process.env.COMICS_DIRECTORY; */
if (!rawDir) { const debouncedEvent = debounce(
this.logger.error("COMICS_DIRECTORY not set; cannot start watcher"); (event: string, p: string, stats?: fs.Stats) => {
return; try {
} this.handleFileEvent(event, p, stats);
const watchDir = path.resolve(rawDir); } catch (err) {
this.logger.info(`Watching comics folder at: ${watchDir}`); this.logger.error(
if (!fs.existsSync(watchDir)) { `Error handling file event [${event}] for ${p}:`,
this.logger.error(`✖ Comics folder does not exist: ${watchDir}`); err
return; );
} }
},
200,
{ leading: true, trailing: true }
);
// Chokidar uses the best native watcher per platform: this.fileWatcher
// - macOS: FSEvents .on("ready", () => this.logger.info("Initial scan complete."))
// - Linux: inotify .on("error", (err) => this.logger.error("Watcher error:", err))
// - Windows: ReadDirectoryChangesW .on("add", (p, stats) => debouncedEvent("add", p, stats))
// Only use polling when explicitly requested (Docker, network mounts, etc.) .on("change", (p, stats) => debouncedEvent("change", p, stats))
const forcePolling = process.env.USE_POLLING === "true"; .on("unlink", (p) => debouncedEvent("unlink", p))
const platform = process.platform; .on("addDir", (p) => debouncedEvent("addDir", p))
const watchMode = forcePolling ? "polling" : `native (${platform})`; .on("unlinkDir", (p) => debouncedEvent("unlinkDir", p));
}
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}`);
/** /**
* Returns a debounced handler for a specific path, creating one if needed. * Stops and closes the chokidar watcher, freeing resources.
* Debouncing per-path prevents duplicate events for the same file while * @private
* ensuring each distinct path is always processed. */
*/ private async stopWatcher(): Promise<void> {
const getDebouncedForPath = (p: string) => { if (this.fileWatcher) {
if (!this.debouncedHandlers.has(p)) { this.logger.info("Stopping file watcher...");
const fn = debounce( await this.fileWatcher.close();
(event: string, filePath: string, stats?: fs.Stats) => { this.fileWatcher = undefined;
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)!;
};
this.fileWatcher /**
.on("ready", () => this.logger.info("Initial scan complete.")) * Handles a filesystem event by logging and optionally importing new files.
.on("error", (err) => this.logger.error("Watcher error:", err)) * @param event - The type of chokidar event ('add', 'change', 'unlink', etc.).
.on("add", (p, stats) => getDebouncedForPath(p)("add", p, stats)) * @param filePath - The full path of the file or directory that triggered the event.
.on("change", (p, stats) => getDebouncedForPath(p)("change", p, stats)) * @param stats - Optional fs.Stats data for 'add' or 'change' events.
// unlink/unlinkDir fire once per path — handle immediately, no debounce needed * @private
.on("unlink", (p) => this.handleFileEvent("unlink", p)) */
.on("addDir", (p) => getDebouncedForPath(p)("addDir", p)) private async handleFileEvent(
.on("unlinkDir", (p) => this.handleFileEvent("unlinkDir", p)); event: string,
} filePath: string,
stats?: fs.Stats
/** ): Promise<void> {
* Stops and closes the chokidar watcher, freeing resources. this.logger.info(`File event [${event}]: ${filePath}`);
* @private if (event === "add" && stats) {
*/ setTimeout(async () => {
private async stopWatcher(): Promise<void> { const newStats = await fs.promises.stat(filePath);
if (this.fileWatcher) { if (newStats.mtime.getTime() === stats.mtime.getTime()) {
this.logger.info("Stopping file watcher..."); this.logger.info(
await this.fileWatcher.close(); `Stable file detected: ${filePath}, importing.`
this.fileWatcher = undefined; );
} const folderData: IFolderData = await this.broker.call(
} "library.walkFolders",
{ basePathToWalk: filePath }
/** );
* Handles a filesystem event by logging and optionally importing new files. // this would have to be a call to importDownloadedComic
* @param event - The type of chokidar event ('add', 'change', 'unlink', etc.). await this.broker.call("importqueue.processImport", {
* @param filePath - The full path of the file or directory that triggered the event. fileObject: {
* @param stats - Optional fs.Stats data for 'add' or 'change' events. filePath,
* @private fileSize: folderData[0].fileSize,
*/ },
private async handleFileEvent( });
event: string, }
filePath: string, }, 3000);
stats?: fs.Stats }
): Promise<void> { this.broker.broadcast(event, { path: filePath });
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);
}
}
} }

View File

@@ -1,285 +1,116 @@
/** // services/graphql.service.ts
* @fileoverview GraphQL service for schema stitching and query execution import { gql as ApolloMixin } from "@ltv/moleculer-apollo-server-mixin";
* @module services/graphql.service import { print } from "graphql";
* @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 { typeDefs } from "../models/graphql/typedef";
import { resolvers } from "../models/graphql/resolvers"; import { ServiceSchema } from "moleculer";
/** /**
* Fetch remote GraphQL schema via introspection with timeout handling * Interface representing the structure of an ElasticSearch result.
* @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> { interface SearchResult {
const controller = new AbortController(); hits: {
const timeoutId = setTimeout(() => controller.abort(), timeout); total: { value: number };
hits: any[];
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 * GraphQL Moleculer Service exposing typed resolvers via @ltv/moleculer-apollo-server-mixin.
* @param broker - Moleculer broker instance * Includes resolver for fetching comics marked as "wanted".
* @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) { const GraphQLService: ServiceSchema = {
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", name: "graphql",
mixins: [ApolloMixin],
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: { actions: {
/** /**
* Check remote schema health and availability * Resolver for fetching comics marked as "wanted" in ElasticSearch.
* @returns Status of remote schema connection with appropriate HTTP status *
* Queries the `search.issue` Moleculer action using a filtered ES query
* that matches issues or volumes with a `wanted` flag.
*
* @param {number} [limit=25] - Maximum number of results to return.
* @param {number} [offset=0] - Starting index for paginated results.
* @returns {Promise<{ total: number, comics: any[] }>} - Total number of matches and result set.
*
* @example
* query {
* wantedComics(limit: 10, offset: 0) {
* total
* comics {
* _id
* _source {
* title
* }
* }
* }
* }
*/ */
checkRemoteSchema: { wantedComics: {
async handler(ctx: Context<any>) { params: {
const status: any = { limit: {
remoteSchemaAvailable: this.remoteSchemaAvailable || false, type: "number",
remoteUrl: this.settings.metadataGraphqlUrl, integer: true,
localSchemaOnly: !this.remoteSchemaAvailable, min: 1,
optional: true,
},
offset: {
type: "number",
integer: true,
min: 0,
optional: true,
},
},
async handler(ctx) {
const { limit = 25, offset = 0 } = ctx.params;
const eSQuery = {
bool: {
should: [
{ exists: { field: "wanted.issues" } },
{ exists: { field: "wanted.volume" } },
],
minimum_should_match: 1,
},
}; };
if (this.remoteSchemaAvailable && this.schema) { const result = (await ctx.broker.call("search.issue", {
const queryType = this.schema.getQueryType(); query: eSQuery,
if (queryType) { pagination: { size: limit, from: offset },
const fields = Object.keys(queryType.getFields()); type: "wanted",
status.availableQueryFields = fields; trigger: "wantedComicsGraphQL",
status.hasWeeklyPullList = fields.includes('getWeeklyPullList'); })) as SearchResult;
}
}
// Set HTTP status code based on schema stitching status return {
// 200 = Schema stitching complete (remote available) data: {
// 503 = Service degraded (local only, remote unavailable) wantedComics: {
(ctx.meta as any).$statusCode = this.remoteSchemaAvailable ? 200 : 503; total: result?.hits?.total?.value || 0,
comics:
return status; result?.hits?.hits.map((hit) => hit._source) ||
}, [],
}, },
},
/** };
* 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: { settings: {
/** apolloServer: {
* Handle metadata imported event - triggers auto-resolution if enabled typeDefs: print(typeDefs), // If typeDefs is AST; remove print if it's raw SDL string
*/ resolvers: {
"metadata.imported": { Query: {
async handler(ctx: any) { wantedComics: "graphql.wantedComics",
const { comicId, source } = ctx.params; },
this.logger.info(`Metadata imported for comic ${comicId} from ${source}`);
await autoResolveMetadata(this.broker, this.logger, comicId, "onMetadataUpdate");
}, },
path: "/graphql",
playground: true,
introspection: true,
context: ({ ctx }: any) => ({
broker: ctx.broker,
}),
}, },
/**
* 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");
}, },
}; };
export default GraphQLService;

View File

@@ -1,390 +0,0 @@
/**
* 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

@@ -74,7 +74,7 @@ export default class JobQueueService extends Service {
}, },
}, },
// Comic Book Import Job Queue // Comic Book Import Job Queue - Enhanced for better metadata handling
"enqueue.async": { "enqueue.async": {
handler: async ( handler: async (
ctx: Context<{ ctx: Context<{
@@ -83,7 +83,7 @@ export default class JobQueueService extends Service {
) => { ) => {
try { try {
console.log( console.log(
`Recieved Job ID ${ctx.locals.job.id}, processing...` `Received Job ID ${ctx.locals.job.id}, processing...`
); );
// 1. De-structure the job params // 1. De-structure the job params
const { fileObject } = ctx.locals.job.data.params; const { fileObject } = ctx.locals.job.data.params;
@@ -112,15 +112,43 @@ export default class JobQueueService extends Service {
JSON.stringify(inferredIssueDetails, null, 2) JSON.stringify(inferredIssueDetails, null, 2)
); );
// 3b. Orchestrate the payload // 3b. Prepare sourced metadata from various sources
const payload = { let sourcedMetadata = {
importStatus: { comicInfo: comicInfoJSON || {},
isImported: true, comicvine: {},
tagged: false, metron: {},
matchedResult: { gcd: {},
score: "0", locg: {}
}, };
// Include any external metadata if provided
if (!isNil(ctx.locals.job.data.params.sourcedMetadata)) {
const providedMetadata = ctx.locals.job.data.params.sourcedMetadata;
sourcedMetadata = {
...sourcedMetadata,
...providedMetadata
};
}
// 3c. Prepare inferred metadata matching Comic model structure
const inferredMetadata = {
series: inferredIssueDetails?.name || "Unknown Series",
issue: {
name: inferredIssueDetails?.name || "Unknown Series",
number: inferredIssueDetails?.number || 1,
subtitle: inferredIssueDetails?.subtitle || "",
year: inferredIssueDetails?.year || new Date().getFullYear().toString()
}, },
volume: 1, // Default volume since not available in inferredIssueDetails
title: inferredIssueDetails?.name || path.basename(filePath, path.extname(filePath))
};
// 3d. Create canonical metadata - user-curated values with source attribution
const canonicalMetadata = this.createCanonicalMetadata(sourcedMetadata, inferredMetadata);
// 3e. Create comic payload with canonical metadata structure
const comicPayload = {
// File details
rawFileDetails: { rawFileDetails: {
name, name,
filePath, filePath,
@@ -130,58 +158,37 @@ export default class JobQueueService extends Service {
containedIn, containedIn,
cover, cover,
}, },
inferredMetadata: {
issue: inferredIssueDetails, // Enhanced sourced metadata (now supports more sources)
}, sourcedMetadata,
sourcedMetadata: {
// except for ComicInfo.xml, everything else should be copied over from the // Original inferred metadata
// parent comic inferredMetadata,
comicInfo: comicInfoJSON,
}, // New canonical metadata - user-curated values with source attribution
// since we already have at least 1 copy canonicalMetadata,
// mark it as not wanted by default
// Import status
"acquisition.source.wanted": false, "acquisition.source.wanted": false,
"acquisition.source.name": ctx.locals.job.data.params.sourcedFrom,
// 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 // 3f. Add bundleId if present
let bundleId = null; let bundleId = null;
if (!isNil(ctx.locals.job.data.params.bundleId)) { if (!isNil(ctx.locals.job.data.params.bundleId)) {
bundleId = ctx.locals.job.data.params.bundleId; bundleId = ctx.locals.job.data.params.bundleId;
} }
// 3d. Add the sourcedMetadata, if present // 4. Use library service to import with enhanced metadata
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( const importResult = await this.broker.call(
"library.rawImportToDB", "library.importFromJob",
{ {
importType: importType: ctx.locals.job.data.params.importType,
ctx.locals.job.data.params.importType,
bundleId, bundleId,
payload, payload: comicPayload,
} }
); );
return { return {
data: { data: {
importResult, importResult,
@@ -191,14 +198,12 @@ export default class JobQueueService extends Service {
}; };
} catch (error) { } catch (error) {
console.error( console.error(
`An error occurred processing Job ID ${ctx.locals.job.id}:`, `An error occurred processing Job ID ${ctx.locals.job.id}`
error instanceof Error ? error.message : error,
error instanceof Error ? error.stack : ""
); );
throw new MoleculerError( throw new MoleculerError(
error, error,
500, 500,
"IMPORT_JOB_ERROR", "ENHANCED_IMPORT_JOB_ERROR",
{ {
data: ctx.params.sessionId, data: ctx.params.sessionId,
} }
@@ -305,7 +310,7 @@ export default class JobQueueService extends Service {
}> }>
) => { ) => {
console.log( console.log(
`Recieved Job ID ${JSON.stringify( `Received Job ID ${JSON.stringify(
ctx.locals ctx.locals
)}, processing...` )}, processing...`
); );
@@ -379,37 +384,16 @@ export default class JobQueueService extends Service {
}, },
], ],
}); });
// Complete the active import session now that the queue is empty
try {
const activeSession = await this.broker.call("importstate.getActiveSession");
if (activeSession) {
await this.broker.call("importstate.completeSession", {
sessionId: activeSession.sessionId,
success: true,
});
}
} catch (err) {
console.error("Failed to complete import session after queue drained:", err);
}
// Emit final library statistics when queue is drained
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after queue drained:", err);
}
}, },
async "enqueue.async.completed"(ctx: Context<{ id: Number }>) { async "enqueue.async.completed"(ctx: Context<{ id: Number }>) {
// 1. Fetch the job result using the job Id // 1. Fetch the job result using the job Id
const job = await this.job(ctx.params.id); const job = await this.job(ctx.params.id);
// 2. Increment the completed job counter // 2. Increment the completed job counter
await pubClient.incr("completedJobCount"); await pubClient.incr("completedJobCount");
// 3. Fetch the completed and total job counts for the progress payload // 3. Fetch the completed job count for the final payload to be sent to the client
const [completedJobCount, totalJobCount] = await Promise.all([ const completedJobCount = await pubClient.get(
pubClient.get("completedJobCount"), "completedJobCount"
pubClient.get("totalJobCount"), );
]);
// 4. Emit the LS_COVER_EXTRACTED event with the necessary details // 4. Emit the LS_COVER_EXTRACTED event with the necessary details
await this.broker.call("socket.broadcast", { await this.broker.call("socket.broadcast", {
namespace: "/", namespace: "/",
@@ -417,7 +401,6 @@ export default class JobQueueService extends Service {
args: [ args: [
{ {
completedJobCount, completedJobCount,
totalJobCount,
importResult: job.returnvalue.data.importResult, importResult: job.returnvalue.data.importResult,
}, },
], ],
@@ -432,13 +415,6 @@ export default class JobQueueService extends Service {
}); });
console.log(`Job ID ${ctx.params.id} completed.`); console.log(`Job ID ${ctx.params.id} completed.`);
// 6. Emit updated library statistics after each import
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after import:", err);
}
}, },
async "enqueue.async.failed"(ctx) { async "enqueue.async.failed"(ctx) {
@@ -469,7 +445,239 @@ export default class JobQueueService extends Service {
}); });
}, },
}, },
methods: {}, methods: {
/**
* Create canonical metadata structure with source attribution for user-driven curation
* @param sourcedMetadata - Metadata from various external sources
* @param inferredMetadata - Metadata inferred from filename/file analysis
*/
createCanonicalMetadata(sourcedMetadata: any, inferredMetadata: any) {
const currentTime = new Date();
// Priority order: comicInfo -> comicvine -> metron -> gcd -> locg -> inferred
const sourcePriority = ['comicInfo', 'comicvine', 'metron', 'gcd', 'locg'];
// Helper function to extract actual value from metadata (handle arrays, etc.)
const extractValue = (value: any) => {
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : null;
}
return value;
};
// Helper function to find the best value and its source
const findBestValue = (fieldName: string, defaultValue: any = null, defaultSource: string = 'inferred') => {
for (const source of sourcePriority) {
const rawValue = sourcedMetadata[source]?.[fieldName];
if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
const extractedValue = extractValue(rawValue);
if (extractedValue !== null && extractedValue !== '') {
return {
value: extractedValue,
source: source,
userSelected: false,
lastModified: currentTime
};
}
}
}
return {
value: defaultValue,
source: defaultSource,
userSelected: false,
lastModified: currentTime
};
};
// Helper function for series-specific field resolution
const findSeriesValue = (fieldNames: string[], defaultValue: any = null) => {
for (const source of sourcePriority) {
const metadata = sourcedMetadata[source];
if (metadata) {
for (const fieldName of fieldNames) {
const rawValue = metadata[fieldName];
if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
const extractedValue = extractValue(rawValue);
if (extractedValue !== null && extractedValue !== '') {
return {
value: extractedValue,
source: source,
userSelected: false,
lastModified: currentTime
};
}
}
}
}
}
return {
value: defaultValue,
source: 'inferred',
userSelected: false,
lastModified: currentTime
};
};
const canonical: any = {
// Core identifying information
title: findBestValue('title', inferredMetadata.title),
// Series information
series: {
name: findSeriesValue(['series', 'seriesName', 'name'], inferredMetadata.series),
volume: findBestValue('volume', inferredMetadata.volume || 1),
startYear: findBestValue('startYear', inferredMetadata.issue?.year ? parseInt(inferredMetadata.issue.year) : new Date().getFullYear())
},
// Issue information
issueNumber: findBestValue('issueNumber', inferredMetadata.issue?.number?.toString() || "1"),
// Publishing information
publisher: findBestValue('publisher', null),
publicationDate: findBestValue('publicationDate', null),
coverDate: findBestValue('coverDate', null),
// Content information
pageCount: findBestValue('pageCount', null),
summary: findBestValue('summary', null),
// Creator information - collect from all sources for richer data
creators: [],
// Character and genre arrays with source tracking
characters: {
values: [],
source: 'inferred',
userSelected: false,
lastModified: currentTime
},
genres: {
values: [],
source: 'inferred',
userSelected: false,
lastModified: currentTime
},
// Canonical metadata tracking
lastCanonicalUpdate: currentTime,
hasUserModifications: false,
// Quality and completeness tracking
completeness: {
score: 0,
missingFields: [],
lastCalculated: currentTime
}
};
// Handle creators - combine from all sources but track source attribution
const allCreators: any[] = [];
for (const source of sourcePriority) {
const metadata = sourcedMetadata[source];
if (metadata?.creators) {
metadata.creators.forEach((creator: any) => {
allCreators.push({
name: extractValue(creator.name),
role: extractValue(creator.role),
source: source,
userSelected: false,
lastModified: currentTime
});
});
} else {
// Handle legacy writer/artist fields
if (metadata?.writer) {
allCreators.push({
name: extractValue(metadata.writer),
role: 'Writer',
source: source,
userSelected: false,
lastModified: currentTime
});
}
if (metadata?.artist) {
allCreators.push({
name: extractValue(metadata.artist),
role: 'Artist',
source: source,
userSelected: false,
lastModified: currentTime
});
}
}
}
canonical.creators = allCreators;
// Handle characters - combine from all sources
const allCharacters = new Set();
let characterSource = 'inferred';
for (const source of sourcePriority) {
if (sourcedMetadata[source]?.characters && sourcedMetadata[source].characters.length > 0) {
sourcedMetadata[source].characters.forEach((char: string) => allCharacters.add(char));
if (characterSource === 'inferred') characterSource = source; // Use the first source found
}
}
canonical.characters = {
values: Array.from(allCharacters),
source: characterSource,
userSelected: false,
lastModified: currentTime
};
// Handle genres - combine from all sources
const allGenres = new Set();
let genreSource = 'inferred';
for (const source of sourcePriority) {
if (sourcedMetadata[source]?.genres && sourcedMetadata[source].genres.length > 0) {
sourcedMetadata[source].genres.forEach((genre: string) => allGenres.add(genre));
if (genreSource === 'inferred') genreSource = source; // Use the first source found
}
}
canonical.genres = {
values: Array.from(allGenres),
source: genreSource,
userSelected: false,
lastModified: currentTime
};
// Calculate completeness score
const requiredFields = ['title', 'series.name', 'issueNumber', 'publisher'];
const optionalFields = ['publicationDate', 'coverDate', 'pageCount', 'summary'];
const missingFields = [];
let filledCount = 0;
// Check required fields
requiredFields.forEach(field => {
const fieldPath = field.split('.');
let value = canonical;
for (const path of fieldPath) {
value = value?.[path];
}
if (value?.value) {
filledCount++;
} else {
missingFields.push(field);
}
});
// Check optional fields
optionalFields.forEach(field => {
if (canonical[field]?.value) {
filledCount++;
}
});
const totalFields = requiredFields.length + optionalFields.length;
canonical.completeness = {
score: Math.round((filledCount / totalFields) * 100),
missingFields: missingFields,
lastCalculated: currentTime
};
return canonical;
}
},
}); });
} }
} }

View File

@@ -58,8 +58,6 @@ import klaw from "klaw";
import path from "path"; import path from "path";
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories"; import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
import AirDCPPSocket from "../shared/airdcpp.socket"; import AirDCPPSocket from "../shared/airdcpp.socket";
import { importComicViaGraphQL } from "../utils/import.graphql.utils";
import { getImportStatistics as getImportStats } from "../utils/import.utils";
console.log(`MONGO -> ${process.env.MONGO_URI}`); console.log(`MONGO -> ${process.env.MONGO_URI}`);
export default class ImportService extends Service { export default class ImportService extends Service {
@@ -87,7 +85,7 @@ export default class ImportService extends Service {
async handler( async handler(
ctx: Context<{ ctx: Context<{
basePathToWalk: string; basePathToWalk: string;
extensions?: string[]; extensions: string[];
}> }>
) { ) {
console.log(ctx.params); console.log(ctx.params);
@@ -95,7 +93,7 @@ export default class ImportService extends Service {
".cbz", ".cbz",
".cbr", ".cbr",
".cb7", ".cb7",
...(ctx.params.extensions || []), ...ctx.params.extensions,
]); ]);
}, },
}, },
@@ -176,42 +174,20 @@ export default class ImportService extends Service {
try { try {
// Get params to be passed to the import jobs // Get params to be passed to the import jobs
const { sessionId } = ctx.params; const { sessionId } = ctx.params;
const resolvedPath = path.resolve(COMICS_DIRECTORY);
// Start import session
await this.broker.call("importstate.startSession", {
sessionId,
type: "full",
directoryPath: resolvedPath,
});
console.log(`Walking comics directory: ${resolvedPath}`);
// Update session status
await this.broker.call("importstate.updateSession", {
sessionId,
status: "scanning",
});
// 1. Walk the Source folder // 1. Walk the Source folder
klaw(resolvedPath) klaw(path.resolve(COMICS_DIRECTORY))
.on("error", (err) => {
console.error(`Error walking directory ${resolvedPath}:`, err);
})
// 1.1 Filter on .cb* extensions // 1.1 Filter on .cb* extensions
.pipe( .pipe(
through2.obj(function (item, enc, next) { through2.obj(function (item, enc, next) {
// Only process files, not directories let fileExtension = path.extname(
if (item.stats.isFile()) { item.path
let fileExtension = path.extname( );
item.path if (
); [".cbz", ".cbr", ".cb7"].includes(
if ( fileExtension
[".cbz", ".cbr", ".cb7"].includes( )
fileExtension ) {
) this.push(item);
) {
this.push(item);
}
} }
next(); next();
}) })
@@ -230,7 +206,16 @@ export default class ImportService extends Service {
)}`, )}`,
}); });
if (!comicExists) { if (!comicExists) {
// Send the extraction job to the queue // 2.1 Reset the job counters in Redis
await pubClient.set(
"completedJobCount",
0
);
await pubClient.set(
"failedJobCount",
0
);
// 2.2 Send the extraction job to the queue
this.broker.call("jobqueue.enqueue", { this.broker.call("jobqueue.enqueue", {
fileObject: { fileObject: {
filePath: item.path, filePath: item.path,
@@ -246,322 +231,11 @@ export default class ImportService extends Service {
); );
} }
}) })
.on("end", async () => { .on("end", () => {
console.log("All files traversed."); console.log("All files traversed.");
// Update session to active (jobs are now being processed)
await this.broker.call("importstate.updateSession", {
sessionId,
status: "active",
});
// Emit library statistics after scanning
try {
await this.broker.call("socket.broadcastLibraryStatistics", {
directoryPath: resolvedPath,
});
} catch (err) {
console.error("Failed to emit library statistics:", err);
}
}); });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
// Mark session as failed
const { sessionId } = ctx.params;
if (sessionId) {
await this.broker.call("importstate.completeSession", {
sessionId,
success: false,
});
}
}
},
},
getImportStatistics: {
rest: "POST /getImportStatistics",
timeout: 300000, // 5 minute timeout for large libraries
async handler(
ctx: Context<{
directoryPath?: string;
}>
) {
try {
const { directoryPath } = ctx.params;
const resolvedPath = path.resolve(directoryPath || COMICS_DIRECTORY);
console.log(`[Import Statistics] Analyzing directory: ${resolvedPath}`);
// Collect all comic files from the directory
const localFiles: string[] = [];
await new Promise<void>((resolve, reject) => {
klaw(resolvedPath)
.on("error", (err) => {
console.error(`Error walking directory ${resolvedPath}:`, err);
reject(err);
})
.pipe(
through2.obj(function (item, enc, next) {
// Only process files, not directories
if (item.stats.isFile()) {
const fileExtension = path.extname(item.path);
if ([".cbz", ".cbr", ".cb7"].includes(fileExtension)) {
localFiles.push(item.path);
}
}
next();
})
)
.on("data", () => {}) // Required for stream to work
.on("end", () => {
console.log(`[Import Statistics] Found ${localFiles.length} comic files`);
resolve();
});
});
// Get statistics by comparing with database
const stats = await getImportStats(localFiles);
const percentageImported = stats.total > 0
? ((stats.alreadyImported / stats.total) * 100).toFixed(2)
: "0.00";
// Count all comics in DB (true imported count, regardless of file presence on disk)
const alreadyImported = await Comic.countDocuments({});
// Count comics marked as missing (in DB but no longer on disk)
const missingFiles = await Comic.countDocuments({
"importStatus.isRawFileMissing": true,
});
return {
success: true,
directory: resolvedPath,
stats: {
totalLocalFiles: stats.total,
alreadyImported,
newFiles: stats.newFiles,
missingFiles,
percentageImported: `${percentageImported}%`,
},
};
} catch (error) {
console.error("[Import Statistics] Error:", error);
throw new Errors.MoleculerError(
"Failed to calculate import statistics",
500,
"IMPORT_STATS_ERROR",
{ error: error.message }
);
}
},
},
incrementalImport: {
rest: "POST /incrementalImport",
timeout: 60000, // 60 second timeout
async handler(
ctx: Context<{
sessionId: string;
directoryPath?: string;
}>
) {
try {
const { sessionId, directoryPath } = ctx.params;
const resolvedPath = path.resolve(directoryPath || COMICS_DIRECTORY);
console.log(`[Incremental Import] Starting for directory: ${resolvedPath}`);
// Start import session
await this.broker.call("importstate.startSession", {
sessionId,
type: "incremental",
directoryPath: resolvedPath,
});
// Emit start event
this.broker.broadcast("LS_INCREMENTAL_IMPORT_STARTED", {
message: "Starting incremental import analysis...",
directory: resolvedPath,
});
// Step 1: Fetch imported files from database
await this.broker.call("importstate.updateSession", {
sessionId,
status: "scanning",
});
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
message: "Fetching imported files from database...",
});
const importedFileNames = new Set<string>();
const comics = await Comic.find(
{ "rawFileDetails.name": { $exists: true, $ne: null } },
{ "rawFileDetails.name": 1, _id: 0 }
).lean();
for (const comic of comics) {
if (comic.rawFileDetails?.name) {
importedFileNames.add(comic.rawFileDetails.name);
}
}
console.log(`[Incremental Import] Found ${importedFileNames.size} imported files in database`);
// Step 2: Scan directory for comic files
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
message: "Scanning directory for comic files...",
});
const localFiles: Array<{ path: string; name: string; size: number }> = [];
await new Promise<void>((resolve, reject) => {
klaw(resolvedPath)
.on("error", (err) => {
console.error(`Error walking directory ${resolvedPath}:`, err);
reject(err);
})
.pipe(
through2.obj(function (item, enc, next) {
// Only process files, not directories
if (item.stats.isFile()) {
const fileExtension = path.extname(item.path);
if ([".cbz", ".cbr", ".cb7"].includes(fileExtension)) {
const fileName = path.basename(item.path, fileExtension);
localFiles.push({
path: item.path,
name: fileName,
size: item.stats.size,
});
}
}
next();
})
)
.on("data", () => {}) // Required for stream to work
.on("end", () => {
console.log(`[Incremental Import] Found ${localFiles.length} comic files in directory`);
resolve();
});
});
// Step 3: Filter to only new files
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
message: `Found ${localFiles.length} comic files, filtering...`,
});
const newFiles = localFiles.filter(file => !importedFileNames.has(file.name));
console.log(`[Incremental Import] ${newFiles.length} new files to import`);
// Step 4: Queue new files
if (newFiles.length > 0) {
await this.broker.call("importstate.updateSession", {
sessionId,
status: "queueing",
stats: {
totalFiles: localFiles.length,
filesQueued: newFiles.length,
},
});
this.broker.broadcast("LS_INCREMENTAL_IMPORT_PROGRESS", {
message: `Queueing ${newFiles.length} new files for import...`,
});
// Reset counters and set total so the UI can show a progress bar
await pubClient.set("completedJobCount", 0);
await pubClient.set("failedJobCount", 0);
await pubClient.set("totalJobCount", newFiles.length);
// Queue all new files
for (const file of newFiles) {
await this.broker.call("jobqueue.enqueue", {
fileObject: {
filePath: file.path,
fileSize: file.size,
},
sessionId,
importType: "new",
sourcedFrom: "library",
action: "enqueue.async",
});
}
// Update session to active
await this.broker.call("importstate.updateSession", {
sessionId,
status: "active",
});
// Emit library statistics after queueing
try {
await this.broker.call("socket.broadcastLibraryStatistics", {
directoryPath: resolvedPath,
});
} catch (err) {
console.error("Failed to emit library statistics:", err);
}
} else {
// No files to import, complete immediately
await this.broker.call("importstate.completeSession", {
sessionId,
success: true,
});
// Emit library statistics even when no new files
try {
await this.broker.call("socket.broadcastLibraryStatistics", {
directoryPath: resolvedPath,
});
} catch (err) {
console.error("Failed to emit library statistics:", err);
}
}
// Emit completion event (queueing complete, not import complete)
this.broker.broadcast("LS_INCREMENTAL_IMPORT_COMPLETE", {
message: `Successfully queued ${newFiles.length} files for import`,
stats: {
total: localFiles.length,
alreadyImported: localFiles.length - newFiles.length,
newFiles: newFiles.length,
queued: newFiles.length,
},
});
return {
success: true,
message: newFiles.length > 0
? `Incremental import started: ${newFiles.length} new files queued`
: "No new files to import",
stats: {
total: localFiles.length,
alreadyImported: localFiles.length - newFiles.length,
newFiles: newFiles.length,
queued: newFiles.length,
},
};
} catch (error) {
console.error("[Incremental Import] Error:", error);
// Mark session as failed
const { sessionId } = ctx.params;
if (sessionId) {
await this.broker.call("importstate.completeSession", {
sessionId,
success: false,
});
}
// Emit error event
this.broker.broadcast("LS_INCREMENTAL_IMPORT_ERROR", {
message: error.message || "Unknown error during incremental import",
error: error,
});
throw new Errors.MoleculerError(
"Failed to perform incremental import",
500,
"INCREMENTAL_IMPORT_ERROR",
{ error: error.message }
);
} }
}, },
}, },
@@ -577,119 +251,61 @@ export default class ImportService extends Service {
sourcedMetadata: { sourcedMetadata: {
comicvine?: any; comicvine?: any;
locg?: {}; locg?: {};
comicInfo?: any;
metron?: any;
gcd?: any;
}; };
inferredMetadata: { inferredMetadata: {
issue: Object; issue: Object;
}; };
rawFileDetails: { rawFileDetails: {
name: string; name: string;
filePath: string;
fileSize?: number;
extension?: string;
mimeType?: string;
containedIn?: string;
cover?: any;
}; };
wanted?: { wanted: {
issues: []; issues: [];
volume: { id: number }; volume: { id: number };
source: string; source: string;
markEntireVolumeWanted: Boolean; markEntireVolumeWanted: Boolean;
}; };
acquisition?: { acquisition: {
source?: { directconnect: {
wanted?: boolean;
name?: string;
};
directconnect?: {
downloads: []; downloads: [];
}; };
}; };
importStatus?: {
isImported: boolean;
tagged: boolean;
matchedResult?: {
score: string;
};
};
}; };
}> }>
) { ) {
try { try {
console.log(
"[GraphQL Import] Processing import via GraphQL..."
);
console.log( console.log(
JSON.stringify(ctx.params.payload, null, 4) JSON.stringify(ctx.params.payload, null, 4)
); );
const { payload } = ctx.params; const { payload } = ctx.params;
const { wanted } = payload; const { wanted } = payload;
// Use GraphQL import for new comics console.log("Saving to Mongo...");
if ( if (
!wanted || !wanted ||
!wanted.volume || !wanted.volume ||
!wanted.volume.id !wanted.volume.id
) { ) {
console.log( console.log(
"[GraphQL Import] No valid identifier - creating new comic via GraphQL" "No valid identifier for upsert. Attempting to create a new document with minimal data..."
); );
const newDocument = new Comic(payload); // Using the entire payload for the new document
// Import via GraphQL await newDocument.save();
const result = await importComicViaGraphQL( return {
this.broker, success: true,
{ message:
filePath: payload.rawFileDetails.filePath, "New document created due to lack of valid identifiers.",
fileSize: payload.rawFileDetails.fileSize, data: newDocument,
rawFileDetails: payload.rawFileDetails, };
inferredMetadata: payload.inferredMetadata,
sourcedMetadata: payload.sourcedMetadata,
wanted: payload.wanted ? {
...payload.wanted,
markEntireVolumeWanted: Boolean(payload.wanted.markEntireVolumeWanted)
} : undefined,
acquisition: payload.acquisition,
}
);
if (result.success) {
console.log(
`[GraphQL Import] Comic imported successfully: ${result.comic.id}`
);
console.log(
`[GraphQL Import] Canonical metadata resolved: ${result.canonicalMetadataResolved}`
);
return {
success: true,
message: result.message,
data: result.comic,
};
} else {
console.log(
`[GraphQL Import] Import returned success=false: ${result.message}`
);
return {
success: false,
message: result.message,
data: result.comic,
};
}
} }
// For comics with wanted.volume.id, use upsert logic
console.log(
"[GraphQL Import] Comic has wanted.volume.id - using upsert logic"
);
let condition = { let condition = {
"wanted.volume.id": wanted.volume.id, "wanted.volume.id": wanted.volume.id,
}; };
let update: any = { let update: any = {
// Using 'any' to bypass strict type checks; alternatively, define a more accurate type
$set: { $set: {
rawFileDetails: payload.rawFileDetails, rawFileDetails: payload.rawFileDetails,
inferredMetadata: payload.inferredMetadata, inferredMetadata: payload.inferredMetadata,
@@ -719,45 +335,18 @@ export default class ImportService extends Service {
update, update,
options options
); );
console.log( console.log(
"[GraphQL Import] Document upserted:", "Operation completed. Document updated or inserted:",
result._id result
); );
// Trigger canonical metadata resolution via GraphQL
try {
console.log(
"[GraphQL Import] Triggering metadata resolution..."
);
await this.broker.call("graphql.graphql", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId: result._id.toString() },
});
console.log(
"[GraphQL Import] Metadata resolution triggered"
);
} catch (resolveError) {
console.error(
"[GraphQL Import] Error resolving metadata:",
resolveError
);
// Don't fail the import if resolution fails
}
return { return {
success: true, success: true,
message: "Document successfully upserted.", message: "Document successfully upserted.",
data: result, data: result,
}; };
} catch (error) { } catch (error) {
console.error("[GraphQL Import] Error:", error); console.log(error);
throw new Errors.MoleculerError( throw new Errors.MoleculerError(
"Operation failed.", "Operation failed.",
500 500
@@ -765,154 +354,7 @@ export default class ImportService extends Service {
} }
}, },
}, },
markFileAsMissing: {
rest: "POST /markFileAsMissing",
params: {
filePath: "string",
},
async handler(ctx: Context<{ filePath: string }>) {
const { filePath } = ctx.params;
// Prefix-regex match: covers both single file and entire directory subtree
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pathRegex = new RegExp(`^${escapedPath}`);
const affectedComics = await Comic.find(
{ "rawFileDetails.filePath": pathRegex },
{
_id: 1,
"rawFileDetails.name": 1,
"rawFileDetails.filePath": 1,
"rawFileDetails.cover": 1,
"inferredMetadata.issue.name": 1,
"inferredMetadata.issue.number": 1,
}
).lean();
if (affectedComics.length === 0) {
return { marked: 0, missingComics: [] };
}
const affectedIds = affectedComics.map((c: any) => c._id);
await Comic.updateMany(
{ _id: { $in: affectedIds } },
{ $set: { "importStatus.isRawFileMissing": true } }
);
return {
marked: affectedComics.length,
missingComics: affectedComics,
};
},
},
clearFileMissingFlag: {
rest: "POST /clearFileMissingFlag",
params: {
filePath: "string",
},
async handler(ctx: Context<{ filePath: string }>) {
const { filePath } = ctx.params;
// First try exact path match
const byPath = await Comic.findOneAndUpdate(
{ "rawFileDetails.filePath": filePath },
{ $set: { "importStatus.isRawFileMissing": false } }
);
if (!byPath) {
// File was moved — match by filename and update the stored path too
const fileName = path.basename(filePath, path.extname(filePath));
await Comic.findOneAndUpdate(
{
"rawFileDetails.name": fileName,
"importStatus.isRawFileMissing": true,
},
{
$set: {
"importStatus.isRawFileMissing": false,
"rawFileDetails.filePath": filePath,
},
}
);
}
},
},
reconcileLibrary: {
rest: "POST /reconcileLibrary",
timeout: 120000,
async handler(ctx: Context<{ directoryPath?: string }>) {
const resolvedPath = path.resolve(
ctx.params.directoryPath || COMICS_DIRECTORY
);
// 1. Collect all comic file paths currently on disk
const localPaths = new Set<string>();
await new Promise<void>((resolve, reject) => {
klaw(resolvedPath)
.on("error", reject)
.pipe(
through2.obj(function (item, enc, next) {
if (
item.stats.isFile() &&
[".cbz", ".cbr", ".cb7"].includes(
path.extname(item.path)
)
) {
this.push(item.path);
}
next();
})
)
.on("data", (p: string) => localPaths.add(p))
.on("end", resolve);
});
// 2. Get every DB record that has a stored filePath
const comics = await Comic.find(
{ "rawFileDetails.filePath": { $exists: true, $ne: null } },
{ _id: 1, "rawFileDetails.filePath": 1 }
).lean();
const nowMissing: any[] = [];
const nowPresent: any[] = [];
for (const comic of comics) {
const stored = comic.rawFileDetails?.filePath;
if (!stored) continue;
if (localPaths.has(stored)) {
nowPresent.push(comic._id);
} else {
nowMissing.push(comic._id);
}
}
// 3. Apply updates in bulk
if (nowMissing.length > 0) {
await Comic.updateMany(
{ _id: { $in: nowMissing } },
{ $set: { "importStatus.isRawFileMissing": true } }
);
}
if (nowPresent.length > 0) {
await Comic.updateMany(
{ _id: { $in: nowPresent } },
{ $set: { "importStatus.isRawFileMissing": false } }
);
}
return {
scanned: localPaths.size,
markedMissing: nowMissing.length,
cleared: nowPresent.length,
};
},
},
getComicsMarkedAsWanted: { getComicsMarkedAsWanted: {
rest: "GET /getComicsMarkedAsWanted", rest: "GET /getComicsMarkedAsWanted",
handler: async (ctx: Context<{}>) => { handler: async (ctx: Context<{}>) => {
try { try {
@@ -1377,8 +819,6 @@ export default class ImportService extends Service {
rest: "POST /flushDB", rest: "POST /flushDB",
params: {}, params: {},
handler: async (ctx: Context<{}>) => { handler: async (ctx: Context<{}>) => {
// Clear any stale import sessions so subsequent imports are not blocked
await ctx.broker.call("importstate.clearActiveSessions", {});
return await Comic.collection return await Comic.collection
.drop() .drop()
.then(async (data) => { .then(async (data) => {
@@ -1400,8 +840,6 @@ export default class ImportService extends Service {
"search.deleteElasticSearchIndices", "search.deleteElasticSearchIndices",
{} {}
); );
return { return {
data, data,
coversFolderDeleteResult, coversFolderDeleteResult,
@@ -1425,8 +863,57 @@ export default class ImportService extends Service {
console.log(ctx.params); console.log(ctx.params);
}, },
}, },
/**
* Enhanced import from job queue - works with enhanced Comic model
*/
importFromJob: {
params: {
importType: "string",
bundleId: { type: "string", optional: true },
payload: "object"
},
async handler(ctx: Context<{
importType: string;
bundleId?: string;
payload: any;
}>) {
try {
const { importType, bundleId, payload } = ctx.params;
console.log(`Importing comic with enhanced metadata processing...`);
// Create comic with enhanced metadata structure
const comic = new Comic({
...payload,
importStatus: {
isImported: true,
tagged: false,
lastProcessed: new Date()
}
});
await comic.save();
console.log(`Successfully imported comic: ${comic._id}`);
console.log(`Resolved metadata: ${JSON.stringify(comic.resolvedMetadata)}`);
return {
success: true,
comic: comic._id,
metadata: {
sources: Object.keys(comic.sourcedMetadata || {}),
resolvedFields: Object.keys(comic.resolvedMetadata || {}),
primarySource: comic.resolvedMetadata?.primarySource || 'inferred'
}
};
} catch (error) {
console.error("Error importing comic:", error);
throw error;
}
}
}
}, },
methods: {}, methods: {}
}); });
} }
} }

View File

@@ -123,11 +123,6 @@ export default class SettingsService extends Service {
}, },
}); });
break; break;
case "missingFiles":
Object.assign(eSQuery, {
term: query,
});
break;
} }
console.log( console.log(
"Searching ElasticSearch index with this query -> " "Searching ElasticSearch index with this query -> "

View File

@@ -9,9 +9,6 @@ import {
import { DbMixin } from "../mixins/db.mixin"; import { DbMixin } from "../mixins/db.mixin";
import Settings from "../models/settings.model"; import Settings from "../models/settings.model";
import { isEmpty, pickBy, identity, map, isNil } from "lodash"; import { isEmpty, pickBy, identity, map, isNil } from "lodash";
import fs from "fs";
import path from "path";
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
const ObjectId = require("mongoose").Types.ObjectId; const ObjectId = require("mongoose").Types.ObjectId;
export default class SettingsService extends Service { export default class SettingsService extends Service {
@@ -28,82 +25,22 @@ export default class SettingsService extends Service {
hooks: {}, hooks: {},
actions: { actions: {
getEnvironmentVariables: { getEnvironmentVariables: {
rest: "GET /getEnvironmentVariables", rest: "GET /getEnvironmentVariables",
params: {}, params: {},
handler: async (ctx: Context<{}>) => { handler: async (ctx: Context<{}>) => {
return { return {
comicsDirectory: process.env.COMICS_DIRECTORY, comicsDirectory: process.env.COMICS_DIRECTORY,
userdataDirectory: process.env.USERDATA_DIRECTORY, userdataDirectory: process.env.USERDATA_DIRECTORY,
redisURI: process.env.REDIS_URI, redisURI: process.env.REDIS_URI,
elasticsearchURI: process.env.ELASTICSEARCH_URI, elasticsearchURI: process.env.ELASTICSEARCH_URI,
mongoURI: process.env.MONGO_URI, mongoURI: process.env.MONGO_URI,
kafkaBroker: process.env.KAFKA_BROKER, kafkaBroker: process.env.KAFKA_BROKER,
unrarBinPath: process.env.UNRAR_BIN_PATH, unrarBinPath: process.env.UNRAR_BIN_PATH,
sevenzBinPath: process.env.SEVENZ_BINARY_PATH, sevenzBinPath: process.env.SEVENZ_BINARY_PATH,
comicvineAPIKey: process.env.COMICVINE_API_KEY, comicvineAPIKey: process.env.COMICVINE_API_KEY,
}
} }
}, }
getDirectoryStatus: { },
rest: "GET /getDirectoryStatus",
params: {},
handler: async (ctx: Context<{}>) => {
const comicsDirectoryEnvSet = !!process.env.COMICS_DIRECTORY;
const userdataDirectoryEnvSet = !!process.env.USERDATA_DIRECTORY;
const resolvedComicsDirectory = path.resolve(COMICS_DIRECTORY);
const resolvedUserdataDirectory = path.resolve(USERDATA_DIRECTORY);
let comicsDirectoryExists = false;
let userdataDirectoryExists = false;
try {
await fs.promises.access(resolvedComicsDirectory, fs.constants.F_OK);
comicsDirectoryExists = true;
} catch {
comicsDirectoryExists = false;
}
try {
await fs.promises.access(resolvedUserdataDirectory, fs.constants.F_OK);
userdataDirectoryExists = true;
} catch {
userdataDirectoryExists = false;
}
const issues: string[] = [];
if (!comicsDirectoryEnvSet) {
issues.push("COMICS_DIRECTORY environment variable is not set");
}
if (!userdataDirectoryEnvSet) {
issues.push("USERDATA_DIRECTORY environment variable is not set");
}
if (!comicsDirectoryExists) {
issues.push(`Comics directory does not exist: ${resolvedComicsDirectory}`);
}
if (!userdataDirectoryExists) {
issues.push(`Userdata directory does not exist: ${resolvedUserdataDirectory}`);
}
return {
comicsDirectory: {
path: resolvedComicsDirectory,
envSet: comicsDirectoryEnvSet,
exists: comicsDirectoryExists,
isValid: comicsDirectoryEnvSet && comicsDirectoryExists,
},
userdataDirectory: {
path: resolvedUserdataDirectory,
envSet: userdataDirectoryEnvSet,
exists: userdataDirectoryExists,
isValid: userdataDirectoryEnvSet && userdataDirectoryExists,
},
isValid: comicsDirectoryEnvSet && userdataDirectoryEnvSet && comicsDirectoryExists && userdataDirectoryExists,
issues,
};
}
},
getSettings: { getSettings: {
rest: "GET /getAllSettings", rest: "GET /getAllSettings",
params: {}, params: {},

View File

@@ -301,27 +301,6 @@ export default class SocketService extends Service {
}, },
}, },
/**
* 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: { listenFileProgress: {
params: { config: "object", namespace: "string" }, params: { config: "object", namespace: "string" },
async handler( async handler(
@@ -347,106 +326,6 @@ export default class SocketService extends Service {
}, },
}, },
}, },
events: {
// File watcher events - forward to Socket.IO clients
async "add"(ctx: Context<{ path: string }>) {
console.log(`[File Watcher] File added: ${ctx.params.path}`);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_FILE_ADDED",
args: [
{
path: ctx.params.path,
timestamp: new Date().toISOString(),
},
],
});
// Emit updated statistics after file addition
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after file add:", err);
}
},
async "unlink"(ctx: Context<{ path: string }>) {
console.log(`[File Watcher] File removed: ${ctx.params.path}`);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_FILE_REMOVED",
args: [
{
path: ctx.params.path,
timestamp: new Date().toISOString(),
},
],
});
// Emit updated statistics after file removal
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after file remove:", err);
}
},
async "addDir"(ctx: Context<{ path: string }>) {
console.log(`[File Watcher] Directory added: ${ctx.params.path}`);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_DIRECTORY_ADDED",
args: [
{
path: ctx.params.path,
timestamp: new Date().toISOString(),
},
],
});
// Emit updated statistics after directory addition
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after directory add:", err);
}
},
async "unlinkDir"(ctx: Context<{ path: string }>) {
console.log(`[File Watcher] Directory removed: ${ctx.params.path}`);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_DIRECTORY_REMOVED",
args: [
{
path: ctx.params.path,
timestamp: new Date().toISOString(),
},
],
});
// Emit updated statistics after directory removal
try {
await this.broker.call("socket.broadcastLibraryStatistics", {});
} catch (err) {
console.error("Failed to emit library statistics after directory remove:", err);
}
},
async "change"(ctx: Context<{ path: string }>) {
console.log(`[File Watcher] File changed: ${ctx.params.path}`);
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "LS_FILE_CHANGED",
args: [
{
path: ctx.params.path,
timestamp: new Date().toISOString(),
},
],
});
},
},
methods: { methods: {
sleep: (ms: number): Promise<NodeJS.Timeout> => { sleep: (ms: number): Promise<NodeJS.Timeout> => {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -1,6 +1,4 @@
import WebSocket from "ws"; import WebSocket from "ws";
// const { Socket } = require("airdcpp-apisocket");
import { Socket } from "airdcpp-apisocket";
/** /**
* Wrapper around the AirDC++ WebSocket API socket. * Wrapper around the AirDC++ WebSocket API socket.
@@ -21,12 +19,18 @@ class AirDCPPSocket {
password: string; password: string;
}; };
/** /**
* Instance of the AirDC++ API socket. * Instance of the AirDC++ API socket.
* @private * @private
*/ */
private socketInstance: any; private socketInstance: any;
/**
* Promise that resolves when the Socket module is loaded
* @private
*/
private socketModulePromise: Promise<any>;
/** /**
* Constructs a new AirDCPPSocket wrapper. * Constructs a new AirDCPPSocket wrapper.
* @param {{ protocol: string; hostname: string; username: string; password: string }} configuration * @param {{ protocol: string; hostname: string; username: string; password: string }} configuration
@@ -53,8 +57,13 @@ class AirDCPPSocket {
username: configuration.username, username: configuration.username,
password: configuration.password, password: configuration.password,
}; };
// Initialize the AirDC++ socket instance
this.socketInstance = Socket(this.options, WebSocket); // Use dynamic import to load the ES module
this.socketModulePromise = import("airdcpp-apisocket").then(module => {
const { Socket } = module;
this.socketInstance = Socket(this.options, WebSocket);
return this.socketInstance;
});
} }
/** /**
@@ -63,6 +72,7 @@ class AirDCPPSocket {
* @returns {Promise<any>} Session information returned by the server. * @returns {Promise<any>} Session information returned by the server.
*/ */
async connect(): Promise<any> { async connect(): Promise<any> {
await this.socketModulePromise;
if ( if (
this.socketInstance && this.socketInstance &&
typeof this.socketInstance.connect === "function" typeof this.socketInstance.connect === "function"
@@ -80,6 +90,7 @@ class AirDCPPSocket {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
await this.socketModulePromise;
if ( if (
this.socketInstance && this.socketInstance &&
typeof this.socketInstance.disconnect === "function" typeof this.socketInstance.disconnect === "function"
@@ -96,6 +107,7 @@ class AirDCPPSocket {
* @returns {Promise<any>} Response from the AirDC++ server. * @returns {Promise<any>} Response from the AirDC++ server.
*/ */
async post(endpoint: string, data: object = {}): Promise<any> { async post(endpoint: string, data: object = {}): Promise<any> {
await this.socketModulePromise;
return await this.socketInstance.post(endpoint, data); return await this.socketInstance.post(endpoint, data);
} }
@@ -107,6 +119,7 @@ class AirDCPPSocket {
* @returns {Promise<any>} Response from the AirDC++ server. * @returns {Promise<any>} Response from the AirDC++ server.
*/ */
async get(endpoint: string, data: object = {}): Promise<any> { async get(endpoint: string, data: object = {}): Promise<any> {
await this.socketModulePromise;
return await this.socketInstance.get(endpoint, data); return await this.socketInstance.get(endpoint, data);
} }
@@ -125,6 +138,7 @@ class AirDCPPSocket {
callback: (...args: any[]) => void, callback: (...args: any[]) => void,
id?: string | number id?: string | number
): Promise<any> { ): Promise<any> {
await this.socketModulePromise;
return await this.socketInstance.addListener( return await this.socketInstance.addListener(
event, event,
handlerName, handlerName,

View File

@@ -1,560 +0,0 @@
/**
* 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);
});
});

View File

@@ -1,24 +0,0 @@
/**
* 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

@@ -1,227 +0,0 @@
/**
* 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();
}

View File

@@ -1,267 +0,0 @@
/**
* 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

@@ -1,24 +0,0 @@
<?xml version="1.0"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Title>Title of the Book</Title>
<Summary>A description of the book</Summary>
<Number>1</Number>
<Count>3</Count>
<Year>2010</Year>
<Month>4</Month>
<Writer>Author name</Writer>
<Publisher>self</Publisher>
<Genre>educational</Genre>
<BlackAndWhite>No</BlackAndWhite>
<Manga>No</Manga>
<Characters>Superman</Characters>
<PageCount>5</PageCount>
<Pages>
<Page Image="0" Type="FrontCover" ImageSize="139382" ImageWidth="774" ImageHeight="1024" />
<Page Image="2" ImageSize="125736" ImageWidth="797" ImageHeight="1024" />
<Page Image="1" ImageSize="127937" ImageWidth="797" ImageHeight="1024" />
<Page Image="4" ImageSize="160902" ImageWidth="804" ImageHeight="1024" />
<Page Image="3" ImageSize="211181" ImageWidth="804" ImageHeight="1024" />
</Pages>
</ComicInfo>

View File

@@ -4,7 +4,7 @@ const fse = require("fs-extra");
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { FileMagic, MagicFlags } from "@npcz/magic"; import { FileMagic, MagicFlags } from "@npcz/magic";
const { stat } = require("fs/promises"); const { readdir, stat } = require("fs/promises");
import { import {
IExplodedPathResponse, IExplodedPathResponse,
IExtractComicBookCoverErrorResponse, IExtractComicBookCoverErrorResponse,
@@ -95,24 +95,13 @@ export const getSizeOfDirectory = async (
directoryPath: string, directoryPath: string,
extensions: string[] extensions: string[]
) => { ) => {
let totalSizeInBytes = 0; const files = await readdir(directoryPath);
let fileCount = 0; const stats = files.map((file) => stat(path.join(directoryPath, file)));
await Walk.walk(directoryPath, async (err, pathname, dirent) => { return (await Promise.all(stats)).reduce(
if (err) return false; (accumulator, { size }) => accumulator + size,
if (dirent.isFile() && extensions.includes(path.extname(dirent.name))) { 0
const fileStat = await stat(pathname); );
totalSizeInBytes += fileStat.size;
fileCount++;
}
});
return {
totalSize: totalSizeInBytes,
totalSizeInMB: totalSizeInBytes / (1024 * 1024),
totalSizeInGB: totalSizeInBytes / (1024 * 1024 * 1024),
fileCount,
};
}; };
export const isValidImageFileExtension = (fileName: string): boolean => { export const isValidImageFileExtension = (fileName: string): boolean => {
@@ -185,16 +174,14 @@ export const getMimeType = async (filePath: string) => {
*/ */
export const createDirectory = async (options: any, directoryPath: string) => { export const createDirectory = async (options: any, directoryPath: string) => {
try { try {
await fse.ensureDir(directoryPath); await fse.ensureDir(directoryPath, options);
console.info(`Directory [ %s ] was created.`, directoryPath); console.info(`Directory [ %s ] was created.`, directoryPath);
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.error(`Failed to create directory [ ${directoryPath} ]:`, error);
throw new Errors.MoleculerError( throw new Errors.MoleculerError(
`Failed to create directory: ${directoryPath} - ${errMsg}`, "Failed to create directory",
500, 500,
"FileOpsError", "FileOpsError",
{ directoryPath, originalError: errMsg } error
); );
} }
}; };

View File

@@ -1,331 +0,0 @@
/**
* @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

@@ -1,302 +0,0 @@
/**
* @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

@@ -1,436 +0,0 @@
/**
* @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

@@ -1,391 +0,0 @@
/**
* 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;
}
}

View File

@@ -1,304 +0,0 @@
/**
* 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

@@ -1,436 +0,0 @@
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,7 +32,6 @@ SOFTWARE.
*/ */
import { createReadStream, createWriteStream, existsSync, statSync } from "fs"; import { createReadStream, createWriteStream, existsSync, statSync } from "fs";
import { execFile } from "child_process";
import { isEmpty, isNil, isUndefined, remove, each, map, reject } from "lodash"; import { isEmpty, isNil, isUndefined, remove, each, map, reject } from "lodash";
import * as p7zip from "p7zip-threetwo"; import * as p7zip from "p7zip-threetwo";
import path from "path"; import path from "path";
@@ -89,335 +88,124 @@ export const extractComicInfoXMLFromRar = async (
)}`; )}`;
await createDirectory(directoryOptions, targetDirectory); await createDirectory(directoryOptions, targetDirectory);
// Try unrar-based extraction first, fall back to p7zip if it fails const archive = new Unrar({
let unrarError: Error | null = null; path: path.resolve(filePath),
try { bin: `${UNRAR_BIN_PATH}`, // this will change depending on Docker base OS
const result = await extractComicInfoXMLFromRarUsingUnrar( arguments: ["-v"],
filePath, });
mimeType, const filesInArchive: [RarFile] = await new Promise(
targetDirectory, (resolve, reject) => {
fileNameWithoutExtension, return archive.list((err, entries) => {
extension if (err) {
); console.log(`DEBUG: ${JSON.stringify(err, null, 2)}`);
return result; reject(err);
} catch (err) { }
unrarError = err; resolve(entries);
console.warn(
`unrar-based extraction failed for ${filePath}: ${err.message}. Falling back to p7zip.`
);
}
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;
}
};
/**
* 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);
}
);
});
};
/**
* 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 remove(filesInArchive, ({ type }) => type === "Directory");
const coverBaseName = sanitize(path.basename( const comicInfoXML = remove(
filesToWriteToDisk.coverFile, filesInArchive,
path.extname(filesToWriteToDisk.coverFile) ({ name }) => path.basename(name).toLowerCase() === "comicinfo.xml"
)); );
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`;
const coverInputFile = `${targetDirectory}/${filesToWriteToDisk.coverFile}`;
await sharp(coverInputFile) remove(
.resize(275) filesInArchive,
.toFormat("png") ({ name }) =>
.toFile(coverOutputFile); !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}`
);
const comicInfoResult = await comicInfoXMLPromise; 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 coverResult = { const coverFilePromise = new Promise((resolve, reject) => {
filePath, const coverFile = path.basename(files[0].name);
name: fileNameWithoutExtension, const sharpStream = sharp().resize(275).toFormat("png");
extension, const coverExtractionStream = archive.stream(files[0].name);
mimeType, const resizeStream = coverExtractionStream.pipe(sharpStream);
containedIn: targetDirectory, resizeStream.toFile(
fileSize: fse.statSync(filePath).size, `${targetDirectory}/${coverFile}`,
cover: { (err, info) => {
filePath: path.relative(process.cwd(), coverOutputFile), 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}`
),
},
});
}
);
}
);
});
return [comicInfoResult, coverResult]; return Promise.all([comicInfoXMLFilePromise, coverFilePromise]);
} catch (err) {
reject(err);
}
}; };
export const extractComicInfoXMLFromZip = async ( export const extractComicInfoXMLFromZip = async (
@@ -464,11 +252,6 @@ export const extractComicInfoXMLFromZip = async (
.localeCompare(path.basename(b.name).toLowerCase()); .localeCompare(path.basename(b.name).toLowerCase());
} }
}); });
if (files.length === 0) {
throw new Error(`No image files found in ZIP archive: ${filePath}`);
}
// Push the first file (cover) to our extraction target // Push the first file (cover) to our extraction target
extractionTargets.push(files[0].name); extractionTargets.push(files[0].name);
filesToWriteToDisk.coverFile = path.basename(files[0].name); filesToWriteToDisk.coverFile = path.basename(files[0].name);
@@ -523,32 +306,45 @@ export const extractComicInfoXMLFromZip = async (
} }
}); });
// Write the cover to disk // Write the cover to disk
const coverBaseName = sanitize(path.basename(filesToWriteToDisk.coverFile, path.extname(filesToWriteToDisk.coverFile))); const coverFilePromise = new Promise((resolve, reject) => {
const coverOutputFile = `${targetDirectory}/${coverBaseName}.png`; const sharpStream = sharp().resize(275).toFormat("png");
const coverInputFile = `${targetDirectory}/${filesToWriteToDisk.coverFile}`; 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
)}`
),
},
});
}
);
});
await sharp(coverInputFile) return Promise.all([comicInfoXMLPromise, coverFilePromise]);
.resize(275)
.toFormat("png")
.toFile(coverOutputFile);
const comicInfoResult = await comicInfoXMLPromise;
const coverResult = {
filePath,
name: fileNameWithoutExtension,
extension,
mimeType,
containedIn: targetDirectory,
fileSize: fse.statSync(filePath).size,
cover: {
filePath: path.relative(process.cwd(), coverOutputFile),
},
};
return [comicInfoResult, coverResult];
} catch (err) { } catch (err) {
throw err; reject(err);
} }
}; };
@@ -565,9 +361,6 @@ export const extractFromArchive = async (filePath: string) => {
filePath, filePath,
mimeType mimeType
); );
if (!Array.isArray(cbzResult)) {
throw new Error(`extractComicInfoXMLFromZip returned a non-iterable result for: ${filePath}`);
}
return Object.assign({}, ...cbzResult); return Object.assign({}, ...cbzResult);
case "application/x-rar; charset=binary": case "application/x-rar; charset=binary":
@@ -575,9 +368,6 @@ export const extractFromArchive = async (filePath: string) => {
filePath, filePath,
mimeType mimeType
); );
if (!Array.isArray(cbrResult)) {
throw new Error(`extractComicInfoXMLFromRar returned a non-iterable result for: ${filePath}`);
}
return Object.assign({}, ...cbrResult); return Object.assign({}, ...cbrResult);
default: default:
@@ -629,7 +419,7 @@ export const uncompressZipArchive = async (filePath: string, options: any) => {
mode: 0o2775, mode: 0o2775,
}; };
const { fileNameWithoutExtension } = getFileConstituents(filePath); const { fileNameWithoutExtension } = getFileConstituents(filePath);
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${sanitize(fileNameWithoutExtension)}`; const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${fileNameWithoutExtension}`;
await createDirectory(directoryOptions, targetDirectory); await createDirectory(directoryOptions, targetDirectory);
await p7zip.extract(filePath, targetDirectory, [], "", false); await p7zip.extract(filePath, targetDirectory, [], "", false);
@@ -643,7 +433,7 @@ export const uncompressRarArchive = async (filePath: string, options: any) => {
}; };
const { fileNameWithoutExtension, extension } = const { fileNameWithoutExtension, extension } =
getFileConstituents(filePath); getFileConstituents(filePath);
const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${sanitize(fileNameWithoutExtension)}`; const targetDirectory = `${USERDATA_DIRECTORY}/expanded/${options.purpose}/${fileNameWithoutExtension}`;
await createDirectory(directoryOptions, targetDirectory); await createDirectory(directoryOptions, targetDirectory);
const archive = new Unrar({ const archive = new Unrar({