Compare commits
3 Commits
master
...
graphql-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9ea15550 | |||
| 755381021d | |||
| a9bfa479c4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
|
||||||
|
|||||||
356
CANONICAL_METADATA_GUIDE.md
Normal file
356
CANONICAL_METADATA_GUIDE.md
Normal 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.
|
||||||
423
MOLECULER_DEPENDENCY_ANALYSIS.md
Normal file
423
MOLECULER_DEPENDENCY_ANALYSIS.md
Normal 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
195
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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 ~937–990 (applyComicVineMetadata handler)
|
|
||||||
**Change**: Replace direct `Comic.findByIdAndUpdate` with `broker.call("graphql.graphql", { query: updateSourcedMetadataMutation, ... })`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Source Ranking + AutoResolve (design — not implementing yet)
|
|
||||||
|
|
||||||
The infrastructure already exists:
|
|
||||||
- `UserPreferences.sourcePriorities[]` with per-source `priority` (1=highest)
|
|
||||||
- `conflictResolution` strategy enum (PRIORITY, CONFIDENCE, RECENCY, HYBRID, MANUAL)
|
|
||||||
- `autoMerge.enabled / onImport / onMetadataUpdate`
|
|
||||||
- `updateUserPreferences` resolver
|
|
||||||
|
|
||||||
When this phase is implemented, the additions will be:
|
|
||||||
1. A "re-resolve all comics" action triggered when source priorities change (`POST /reResolveAllWithPreferences`)
|
|
||||||
2. `autoResolveMetadata` in graphql.service.ts wired to call `resolveMetadata` on save rather than only on import/update hooks
|
|
||||||
3. Field-specific source overrides UI (the `fieldOverrides` Map in `SourcePrioritySchema` is already modeled)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TDD Approach
|
|
||||||
|
|
||||||
Each step follows Red → Green → Refactor:
|
|
||||||
1. Write failing spec(s) for the unit being built
|
|
||||||
2. Implement the minimum code to make them pass
|
|
||||||
3. Refactor if needed
|
|
||||||
|
|
||||||
**Test framework:** Jest + ts-jest (configured in `package.json`, zero existing tests — these will be the first)
|
|
||||||
**File convention:** `*.spec.ts` alongside the source file (e.g., `utils/metadata.resolution.utils.spec.ts`)
|
|
||||||
**No DB needed for unit tests** — mock `Comic.findById` etc. with `jest.spyOn` / `jest.mock`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
### Step 1 — Utility layer (prerequisite for everything)
|
|
||||||
**Write first:** `utils/metadata.resolution.utils.spec.ts`
|
|
||||||
- `SOURCE_FIELD_PATHS` has entries for all canonical fields
|
|
||||||
- `extractAllSourceValues("title", { comicvine: { name: "A" }, metron: { name: "B" } })` returns 2 entries with correct source + value
|
|
||||||
- `extractAllSourceValues` returns empty array when no source has the field
|
|
||||||
- `buildCanonicalMetadata()` covers all fields in `SOURCE_FIELD_PATHS` (not just 7)
|
|
||||||
- `buildCanonicalMetadata()` never overwrites fields with `userOverride: true`
|
|
||||||
|
|
||||||
**Then implement:**
|
|
||||||
- `models/comic.model.ts` — add `SHORTBOXED`, `MARVEL`, `DC` to `MetadataSource` enum; add 3 new `sourcedMetadata` fields
|
|
||||||
- `models/userpreferences.model.ts` — add SHORTBOXED (priority 7), MARVEL (8), DC (9) to default `sourcePriorities`
|
|
||||||
- `utils/metadata.resolution.utils.ts` — add `SOURCE_FIELD_PATHS`, `extractAllSourceValues()`, rewrite `buildCanonicalMetadata()`
|
|
||||||
|
|
||||||
### Step 2 — GraphQL schema (no tests — type definitions only)
|
|
||||||
**`models/graphql/typedef.ts`**
|
|
||||||
- Expand `MetadataSource` enum (add SHORTBOXED, MARVEL, DC)
|
|
||||||
- Add `SourceFieldValue`, `MetadataFieldComparison`, `MetadataComparisonView`, `FieldSourcePick` types
|
|
||||||
- Add `getMetadataComparisonView` to `Query`
|
|
||||||
- Add `pickFieldFromSource`, `batchPickFieldsFromSources` to `Mutation`
|
|
||||||
|
|
||||||
### Step 3 — GraphQL resolvers
|
|
||||||
**Write first:** `models/graphql/resolvers.spec.ts`
|
|
||||||
- `getMetadataComparisonView`: returns one entry per field in `SOURCE_FIELD_PATHS`; `hasConflict` true when sources disagree; `currentCanonical` reflects DB state
|
|
||||||
- `pickFieldFromSource`: sets field with source provenance + `userOverride: true`; throws when source has no value
|
|
||||||
- `batchPickFieldsFromSources`: applies all picks in a single save
|
|
||||||
- `applyComicVineMetadata` fix: calls `updateSourcedMetadata` mutation (not direct DB write)
|
|
||||||
|
|
||||||
**Then implement:** `models/graphql/resolvers.ts`
|
|
||||||
|
|
||||||
### Step 4 — GraphQL util helpers
|
|
||||||
**Write first:** `utils/import.graphql.utils.spec.ts`
|
|
||||||
- Each helper calls `broker.call("graphql.graphql", ...)` with correct query/variables
|
|
||||||
- GraphQL errors are propagated
|
|
||||||
|
|
||||||
**Then implement:** `utils/import.graphql.utils.ts`
|
|
||||||
|
|
||||||
### Step 5 — REST surface
|
|
||||||
**Write first:** `services/library.service.spec.ts`
|
|
||||||
- Each action delegates to the correct GraphQL util helper
|
|
||||||
- Context params pass through correctly
|
|
||||||
|
|
||||||
**Then implement:** `services/library.service.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Files
|
|
||||||
|
|
||||||
| File | Step | Change |
|
|
||||||
|---|---|---|
|
|
||||||
| `models/comic.model.ts` | 1 | Add `SHORTBOXED`, `MARVEL`, `DC` to `MetadataSource` enum; add 3 new `sourcedMetadata` fields |
|
|
||||||
| `models/userpreferences.model.ts` | 1 | Add SHORTBOXED (priority 7), MARVEL (8), DC (9) to default `sourcePriorities` |
|
|
||||||
| `utils/metadata.resolution.utils.ts` | 1 | Add `SOURCE_FIELD_PATHS`, `extractAllSourceValues()`; rewrite `buildCanonicalMetadata()` |
|
|
||||||
| `models/graphql/typedef.ts` | 2 | Expand `MetadataSource` enum; add 4 new types + query + 2 mutations |
|
|
||||||
| `models/graphql/resolvers.ts` | 3 | Implement 3 resolvers + fix `applyComicVineMetadata` |
|
|
||||||
| `utils/import.graphql.utils.ts` | 4 | Add 3 GraphQL util functions |
|
|
||||||
| `services/library.service.ts` | 5 | Add 3 Moleculer REST actions |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reusable Existing Code
|
|
||||||
|
|
||||||
- `resolveMetadataField()` in `utils/metadata.resolution.utils.ts` — reused inside `buildCanonicalMetadata()`
|
|
||||||
- `getNestedValue()` in same file — reused in `extractAllSourceValues()`
|
|
||||||
- `convertPreferences()` in `models/graphql/resolvers.ts` — reused in `getMetadataComparisonView`
|
|
||||||
- `autoResolveMetadata()` in `services/graphql.service.ts` — called after `pickFieldFromSource` if `autoMerge.onMetadataUpdate` is true
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. **Unit**: `extractAllSourceValues("title", { comicvine: { name: "A" }, metron: { name: "B" } })` → 2 entries with correct provenance
|
|
||||||
2. **GraphQL**: `getMetadataComparisonView(comicId)` on a comic with comicvine + comicInfo data → all fields populated
|
|
||||||
3. **Cherry-pick**: `pickFieldFromSource(comicId, "title", COMICVINE)` → `canonicalMetadata.title.provenance.source == "comicvine"` and `userOverride == true`
|
|
||||||
4. **Batch**: `batchPickFieldsFromSources` with 3 fields → single DB write, all 3 updated
|
|
||||||
5. **Lock**: After cherry-picking, `resolveMetadata(comicId)` must NOT overwrite picked fields (`userOverride: true` takes priority)
|
|
||||||
6. **REST**: `POST /api/library/getMetadataComparisonView` returns expected JSON
|
|
||||||
47
graphql-server.ts
Normal file
47
graphql-server.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
@@ -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
3538
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -> "
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 {};
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user