mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b15a472bab | |||
| 3c680f2f38 | |||
| 16cd606421 | |||
| 40d5363dc4 | |||
| c138d8e642 | |||
| 3d590b38cc | |||
| aa7ba8a76d | |||
| 328fd8392b | |||
| 9a460f808d | |||
| c60b6214ce | |||
| aff5faaa58 | |||
| c43ce7ba8f | |||
| f570b87343 | |||
| dfa7a11674 |
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
||||
- India (`in`) - `audible.in` (English)
|
||||
- Germany (`de`) - `audible.de` (non-English)
|
||||
- Spain (`es`) - `audible.es` (non-English)
|
||||
- French (`fr`) - `audible.fr` (non-English)
|
||||
|
||||
**`isEnglish` Flag:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
|
||||
@@ -271,7 +271,7 @@ src/app/admin/settings/
|
||||
|
||||
**PUT /api/admin/settings/audible**
|
||||
- Updates Audible region
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es, fr)
|
||||
- No validation required
|
||||
|
||||
**PUT /api/admin/settings/prowlarr/indexers**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Component: Configuration API Routes (by category)
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Config.Category');
|
||||
|
||||
// GET /api/config/:category - Get all config for a category
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ category: string }> }
|
||||
) {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
const { category } = await params;
|
||||
const configService = getConfigService();
|
||||
|
||||
const config = await configService.getCategory(category);
|
||||
|
||||
return NextResponse.json({
|
||||
category,
|
||||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get config for category', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Component: Configuration API Routes
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService, ConfigUpdate } from '@/lib/services/config.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Config');
|
||||
|
||||
const ConfigUpdateSchema = z.object({
|
||||
updates: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
encrypted: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// PUT /api/config - Update multiple configuration values
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
|
||||
const body = await request.json();
|
||||
const { updates } = ConfigUpdateSchema.parse(body);
|
||||
|
||||
const configService = getConfigService();
|
||||
await configService.setMany(updates as ConfigUpdate[]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: updates.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update configuration', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation error',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to update configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/config - Get all configuration (masked sensitive values)
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
|
||||
const configService = getConfigService();
|
||||
const allConfig = await configService.getAll();
|
||||
|
||||
return NextResponse.json({
|
||||
config: allConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all configuration', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Component: Download Client Timeout Constants
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Some indexers (e.g. YGGtorrent) enforce a ~30s wait before allowing
|
||||
* .torrent file downloads. 60s gives sufficient headroom.
|
||||
*/
|
||||
|
||||
/** Timeout for download client API calls and .torrent file fetches (ms) */
|
||||
export const DOWNLOAD_CLIENT_TIMEOUT = 60000;
|
||||
@@ -16,7 +16,7 @@ import type { AudibleRegion } from '../types/audible';
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportedLanguage = 'en' | 'de' | 'es';
|
||||
export type SupportedLanguage = 'en' | 'de' | 'es' | 'fr';
|
||||
|
||||
export interface ScrapingConfig {
|
||||
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
|
||||
@@ -170,6 +170,38 @@ const SPANISH_CONFIG: LanguageConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const FRENCH_CONFIG: LanguageConfig = {
|
||||
code: 'fr',
|
||||
annasArchiveLang: 'fr',
|
||||
epubCode: 'fr',
|
||||
stopWords: ['le', 'la', 'les', 'un', 'une', 'de', 'des', 'sur', 'dans', '\u00e0', 'et', 'par', 'pour'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'français',
|
||||
authorPrefixes: ['De :', '\u00c9crit par :', 'Auteur :'],
|
||||
narratorPrefixes: ['Lu par :'],
|
||||
lengthLabels: ['Dur\u00e9e :'],
|
||||
languageLabels: ['Langue :'],
|
||||
releaseDateLabels: ['Date de publication :'],
|
||||
seriesLabels: ['S\u00e9rie :'],
|
||||
acceptedLanguageValues: ['français', 'french'],
|
||||
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*heures?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutes?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
|
||||
releaseDatePatterns: [/Date de publication:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/Essayer pour/i,
|
||||
/R\u00e9siliez \u00e0 tout moment/i,
|
||||
/Acheter pour/i,
|
||||
/^\s*de\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(h|heures?)\s*\d*\s*(min|minutes?)?/i,
|
||||
ratingTextSelector: 'sur 5 étoiles',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup Maps
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -178,6 +210,7 @@ export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
|
||||
en: ENGLISH_CONFIG,
|
||||
de: GERMAN_CONFIG,
|
||||
es: SPANISH_CONFIG,
|
||||
fr: FRENCH_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -192,6 +225,7 @@ export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
|
||||
in: 'en',
|
||||
de: 'de',
|
||||
es: 'es',
|
||||
fr: 'fr',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
@@ -49,7 +50,7 @@ export class DelugeService implements IDownloadClient {
|
||||
? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
|
||||
|
||||
this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, httpsAgent });
|
||||
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
|
||||
}
|
||||
|
||||
/** JSON-RPC call with automatic re-authentication on auth failure */
|
||||
@@ -190,7 +191,7 @@ export class DelugeService implements IDownloadClient {
|
||||
try {
|
||||
torrentResponse = await axios.get(torrentUrl, {
|
||||
responseType: 'arraybuffer', maxRedirects: 0,
|
||||
validateStatus: (s) => s >= 200 && s < 300, timeout: 30000,
|
||||
validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
if (torrentResponse.data.length > 0) {
|
||||
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
|
||||
@@ -203,7 +204,7 @@ export class DelugeService implements IDownloadClient {
|
||||
const loc = error.response.headers['location'];
|
||||
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
|
||||
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
|
||||
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 5 }); }
|
||||
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); }
|
||||
catch { throw new Error('Failed to download torrent file after redirect'); }
|
||||
} else { throw new Error(`Invalid redirect location: ${loc}`); }
|
||||
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
@@ -87,7 +88,7 @@ export class ProwlarrService {
|
||||
headers: {
|
||||
'X-Api-Key': this.apiKey,
|
||||
},
|
||||
timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
paramsSerializer: {
|
||||
serialize: (params) => {
|
||||
// Custom serializer to handle arrays correctly for Prowlarr API
|
||||
@@ -314,7 +315,7 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
timeout: 60000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
@@ -140,7 +141,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `${this.baseUrl}/api/v2`,
|
||||
timeout: 30000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
httpsAgent: this.httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
@@ -352,7 +353,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
responseType: 'arraybuffer',
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
|
||||
timeout: 30000, // 30 seconds - public indexers can be slow
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
|
||||
logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`);
|
||||
@@ -394,7 +395,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
try {
|
||||
torrentResponse = await axios.get(location, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
@@ -106,7 +107,7 @@ export class TransmissionService implements IDownloadClient {
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
}
|
||||
@@ -274,7 +275,7 @@ export class TransmissionService implements IDownloadClient {
|
||||
responseType: 'arraybuffer',
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
timeout: 30000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
|
||||
// Check if response body is a magnet link
|
||||
@@ -302,7 +303,7 @@ export class TransmissionService implements IDownloadClient {
|
||||
try {
|
||||
torrentResponse = await axios.get(location, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { SupportedLanguage } from '../constants/language-config';
|
||||
|
||||
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es';
|
||||
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es' | 'fr';
|
||||
|
||||
export interface AudibleRegionConfig {
|
||||
code: AudibleRegion;
|
||||
@@ -64,6 +64,13 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
baseUrl: 'https://www.audible.es',
|
||||
audnexusParam: 'es',
|
||||
language: 'es',
|
||||
},
|
||||
fr: {
|
||||
code: 'fr',
|
||||
name: 'France',
|
||||
baseUrl: 'https://www.audible.fr',
|
||||
audnexusParam: 'fr',
|
||||
language: 'fr',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Component: Config API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
setMany: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
getCategory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Config API routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns full configuration', async () => {
|
||||
configServiceMock.getAll.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
|
||||
it('updates configuration values', async () => {
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
updates: [{ key: 'plex_url', value: 'http://plex' }],
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.updated).toBe(1);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when configuration update payload is invalid', async () => {
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Validation error/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration update fails', async () => {
|
||||
configServiceMock.setMany.mockRejectedValueOnce(new Error('db down'));
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
updates: [{ key: 'plex_url', value: 'http://plex' }],
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to update configuration/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration lookup fails', async () => {
|
||||
configServiceMock.getAll.mockRejectedValueOnce(new Error('db down'));
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
|
||||
it('returns category configuration', async () => {
|
||||
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/[category]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.category).toBe('plex');
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
|
||||
it('returns 500 when category configuration lookup fails', async () => {
|
||||
configServiceMock.getCategory.mockRejectedValueOnce(new Error('db down'));
|
||||
const { GET } = await import('@/app/api/config/[category]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user