Compare commits

...

12 Commits

Author SHA1 Message Date
kikootwo b15a472bab Centralize download client timeout constant
Add DOWNLOAD_CLIENT_TIMEOUT (60000ms) in src/lib/constants/download-timeouts.ts and replace hardcoded 60000ms timeouts across Deluge, Prowlarr, qBittorrent and Transmission integrations. This centralizes the download/API timeout (gives headroom for indexers that enforce ~30s waits) and makes future adjustments easier without changing behavior.
2026-02-24 01:09:58 -05:00
kikootwo 3c680f2f38 Merge pull request #102 from Kikipeuk/ygg_timeout2
Extend the default timeout to add a torrent (Qbit, Transmission, Deluge)
2026-02-24 00:56:37 -05:00
kikootwo 16cd606421 Merge pull request #107 from kikootwo/feature-france-region
Feature france region
2026-02-24 00:53:01 -05:00
kikootwo 40d5363dc4 Fix French stopWords spacing and region name
Trim whitespace in the French stopWords array (add missing space after comma) to keep formatting consistent, and rename AUDIBLE_REGIONS.fr.name from "French" to "France" to better reflect the region label used for the Audible configuration.
2026-02-24 00:51:55 -05:00
kikootwo c138d8e642 Merge pull request #100 from Kikipeuk/french-traduction
Add French as Audible region
2026-02-24 00:40:50 -05:00
root 328fd8392b ygg_timeout2 2026-02-21 14:30:51 +01:00
root 9a460f808d french-Traduction 2026-02-21 13:57:47 +01:00
root c60b6214ce French Traduction 2026-02-21 12:44:56 +01:00
root aff5faaa58 French Traduction 2026-02-21 11:43:06 +01:00
root c43ce7ba8f French Traduction 2026-02-21 11:40:48 +01:00
root f570b87343 French Traduction 2026-02-21 10:48:24 +01:00
root dfa7a11674 French Traduction 2026-02-21 10:43:49 +01:00
9 changed files with 70 additions and 14 deletions
+1
View File
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
- India (`in`) - `audible.in` (English) - India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English) - Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English) - Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:** **`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` - Each region has `isEnglish: boolean` in `AudibleRegionConfig`
+1 -1
View File
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible** **PUT /api/admin/settings/audible**
- Updates Audible region - 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 - No validation required
**PUT /api/admin/settings/prowlarr/indexers** **PUT /api/admin/settings/prowlarr/indexers**
+10
View File
@@ -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;
+35 -1
View File
@@ -16,7 +16,7 @@ import type { AudibleRegion } from '../types/audible';
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type SupportedLanguage = 'en' | 'de' | 'es'; export type SupportedLanguage = 'en' | 'de' | 'es' | 'fr';
export interface ScrapingConfig { export interface ScrapingConfig {
/** Audible locale query-param value (e.g. 'english', 'deutsch') */ /** 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 // Lookup Maps
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -178,6 +210,7 @@ export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
en: ENGLISH_CONFIG, en: ENGLISH_CONFIG,
de: GERMAN_CONFIG, de: GERMAN_CONFIG,
es: SPANISH_CONFIG, es: SPANISH_CONFIG,
fr: FRENCH_CONFIG,
}; };
/** /**
@@ -192,6 +225,7 @@ export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
in: 'en', in: 'en',
de: 'de', de: 'de',
es: 'es', es: 'es',
fr: 'fr',
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
@@ -49,7 +50,7 @@ export class DelugeService implements IDownloadClient {
? new https.Agent({ rejectUnauthorized: false }) : undefined; ? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled'); 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 */ /** JSON-RPC call with automatic re-authentication on auth failure */
@@ -190,7 +191,7 @@ export class DelugeService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(torrentUrl, { torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0, 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) { if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/); const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
@@ -203,7 +204,7 @@ export class DelugeService implements IDownloadClient {
const loc = error.response.headers['location']; const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options); if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) { 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'); } catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); } } else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); } } else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
+3 -2
View File
@@ -5,6 +5,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import { TorrentResult } from '../utils/ranking-algorithm'; import { TorrentResult } from '../utils/ranking-algorithm';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -87,7 +88,7 @@ export class ProwlarrService {
headers: { headers: {
'X-Api-Key': this.apiKey, '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: { paramsSerializer: {
serialize: (params) => { serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API // Custom serializer to handle arrays correctly for Prowlarr API
@@ -314,7 +315,7 @@ export class ProwlarrService {
limit: 100, limit: 100,
extended: 1, extended: 1,
}, },
timeout: 60000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
responseType: 'text', // Get XML as text responseType: 'text', // Get XML as text
}); });
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data'; import FormData from 'form-data';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -140,7 +141,7 @@ export class QBittorrentService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`, baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth // Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: { auth: {
@@ -352,7 +353,7 @@ export class QBittorrentService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success 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`); logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`);
@@ -394,7 +395,7 @@ export class QBittorrentService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`); logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`);
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
@@ -106,7 +107,7 @@ export class TransmissionService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
}); });
} }
@@ -274,7 +275,7 @@ export class TransmissionService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
// Check if response body is a magnet link // Check if response body is a magnet link
@@ -302,7 +303,7 @@ export class TransmissionService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
} catch { } catch {
+8 -1
View File
@@ -5,7 +5,7 @@
import type { SupportedLanguage } from '../constants/language-config'; 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 { export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
@@ -64,6 +64,13 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
baseUrl: 'https://www.audible.es', baseUrl: 'https://www.audible.es',
audnexusParam: 'es', audnexusParam: 'es',
language: 'es', language: 'es',
},
fr: {
code: 'fr',
name: 'France',
baseUrl: 'https://www.audible.fr',
audnexusParam: 'fr',
language: 'fr',
} }
}; };