Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+719
View File
@@ -0,0 +1,719 @@
/**
* Component: Audible Integration Service (Web Scraping)
* Documentation: documentation/integrations/audible.md
*/
import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio';
export interface AudibleAudiobook {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
durationMinutes?: number;
releaseDate?: string;
rating?: number;
genres?: string[];
}
export interface AudibleSearchResult {
query: string;
results: AudibleAudiobook[];
totalResults: number;
page: number;
hasMore: boolean;
}
export class AudibleService {
private client: AxiosInstance;
private readonly baseUrl = 'https://www.audible.com';
constructor() {
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
});
}
/**
* Get popular audiobooks from best sellers (with pagination support)
*/
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
try {
console.log(`[Audible] Fetching popular audiobooks (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
while (audiobooks.length < limit && page <= maxPages) {
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/adblbestsellers', {
params: page > 1 ? { page } : {},
});
const $ = cheerio.load(response.data);
let foundOnPage = 0;
// Parse audiobook items from best sellers page
$('.productListItem').each((index, element) => {
if (audiobooks.length >= limit) return false;
const $el = $(element);
// Extract ASIN from data attribute or link
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
// Skip duplicates
if (audiobooks.some(book => book.asin === asin)) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
});
foundOnPage++;
});
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
console.log(`[Audible] Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
}
}
console.log(`[Audible] Found ${audiobooks.length} popular audiobooks across ${page} pages`);
return audiobooks;
} catch (error) {
console.error('[Audible] Failed to fetch popular audiobooks:', error);
return [];
}
}
/**
* Get new release audiobooks (with pagination support)
*/
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
try {
console.log(`[Audible] Fetching new releases (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
while (audiobooks.length < limit && page <= maxPages) {
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/newreleases', {
params: page > 1 ? { page } : {},
});
const $ = cheerio.load(response.data);
let foundOnPage = 0;
// Parse audiobook items from new releases page
$('.productListItem').each((index, element) => {
if (audiobooks.length >= limit) return false;
const $el = $(element);
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
// Skip duplicates
if (audiobooks.some(book => book.asin === asin)) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
});
foundOnPage++;
});
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
console.log(`[Audible] Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
}
}
console.log(`[Audible] Found ${audiobooks.length} new releases across ${page} pages`);
return audiobooks;
} catch (error) {
console.error('[Audible] Failed to fetch new releases:', error);
return [];
}
}
/**
* Search for audiobooks
*/
async search(query: string, page: number = 1): Promise<AudibleSearchResult> {
try {
console.log(`[Audible] Searching for "${query}"...`);
const response = await this.client.get('/search', {
params: {
keywords: query,
page,
},
});
const $ = cheerio.load(response.data);
const audiobooks: AudibleAudiobook[] = [];
// Parse search results
$('.productListItem').each((index, element) => {
const $el = $(element);
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
});
});
// Try to extract total results count
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
console.log(`[Audible] Found ${audiobooks.length} results for "${query}"`);
return {
query,
results: audiobooks,
totalResults,
page,
hasMore: audiobooks.length > 0 && totalResults > page * 20,
};
} catch (error) {
console.error('[Audible] Search failed:', error);
return {
query,
results: [],
totalResults: 0,
page,
hasMore: false,
};
}
}
/**
* Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data)
* Fallback: Audible scraping
*/
async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
console.log(`[Audible] Fetching details for ASIN ${asin}...`);
// Try Audnexus first (more reliable)
const audnexusData = await this.fetchFromAudnexus(asin);
if (audnexusData) {
console.log(`[Audible] Successfully fetched from Audnexus for "${audnexusData.title}"`);
return audnexusData;
}
console.log(`[Audible] Audnexus failed, falling back to Audible scraping...`);
// Fallback to Audible scraping
return await this.scrapeAudibleDetails(asin);
} catch (error) {
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
return null;
}
}
/**
* Fetch audiobook details from Audnexus API
*/
private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> {
try {
console.log(`[Audnexus] Fetching ASIN ${asin}...`);
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
timeout: 10000,
headers: {
'User-Agent': 'ReadMeABook/1.0',
},
});
const data = response.data;
// Build result from Audnexus data
const result: AudibleAudiobook = {
asin,
title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '',
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '',
coverArtUrl: data.image || '',
durationMinutes: data.runtimeLengthMin ? parseInt(data.runtimeLengthMin) : undefined,
releaseDate: data.releaseDate || undefined,
rating: data.rating ? parseFloat(data.rating) : undefined,
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
};
// Ensure cover art URL is high quality
if (result.coverArtUrl && !result.coverArtUrl.includes('_SL500_')) {
result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.');
}
console.log(`[Audnexus] Success:`, JSON.stringify({
title: result.title,
author: result.author,
narrator: result.narrator,
descLength: result.description?.length || 0,
duration: result.durationMinutes,
rating: result.rating,
genres: result.genres?.length || 0
}));
return result;
} catch (error: any) {
if (error.response?.status === 404) {
console.log(`[Audnexus] Book not found (404) for ASIN ${asin}`);
} else {
console.log(`[Audnexus] Error fetching ASIN ${asin}:`, error.message);
}
return null;
}
}
/**
* Scrape audiobook details from Audible (fallback method)
*/
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
const response = await this.client.get(`/pd/${asin}`);
const $ = cheerio.load(response.data);
// Initialize result object
let result: AudibleAudiobook = {
asin,
title: '',
author: '',
narrator: '',
description: '',
coverArtUrl: '',
};
// Debug: Save HTML in development
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
const fs = require('fs');
const path = require('path');
const debugPath = path.join('/tmp', `audible-${asin}.html`);
fs.writeFileSync(debugPath, response.data);
console.log(`[Audible] Saved HTML to ${debugPath} for debugging`);
}
// Try to extract JSON-LD structured data first
const jsonLdScripts = $('script[type="application/ld+json"]');
console.log(`[Audible] Found ${jsonLdScripts.length} JSON-LD script tags`);
jsonLdScripts.each((i, elem) => {
try {
const jsonData = JSON.parse($(elem).html() || '{}');
console.log(`[Audible] JSON-LD ${i} type:`, jsonData['@type']);
if (jsonData['@type'] === 'Book' || jsonData['@type'] === 'Audiobook' || jsonData['@type'] === 'Product') {
console.log('[Audible] Found valid JSON-LD structured data');
if (jsonData.name) result.title = jsonData.name;
if (jsonData.author) {
result.author = Array.isArray(jsonData.author)
? jsonData.author.map((a: any) => a.name || a).join(', ')
: jsonData.author?.name || jsonData.author || '';
}
if (jsonData.readBy) {
result.narrator = Array.isArray(jsonData.readBy)
? jsonData.readBy.map((n: any) => n.name || n).join(', ')
: jsonData.readBy?.name || jsonData.readBy || '';
}
if (jsonData.description) result.description = jsonData.description;
if (jsonData.image) result.coverArtUrl = jsonData.image;
if (jsonData.aggregateRating?.ratingValue) result.rating = jsonData.aggregateRating.ratingValue;
if (jsonData.datePublished) result.releaseDate = jsonData.datePublished;
if (jsonData.duration) {
const durationMatch = jsonData.duration.match(/PT(\d+)H(\d+)M/);
if (durationMatch) {
result.durationMinutes = parseInt(durationMatch[1]) * 60 + parseInt(durationMatch[2]);
}
}
}
} catch (e) {
console.log(`[Audible] JSON-LD ${i} parsing failed:`, e);
}
});
// Fallback to HTML parsing for any missing fields
// Title - try multiple selectors
if (!result.title) {
result.title = $('h1.bc-heading').first().text().trim() ||
$('h1[class*="heading"]').first().text().trim() ||
$('.bc-container h1').first().text().trim() ||
$('h1').first().text().trim();
console.log(`[Audible] Title from HTML: "${result.title}"`);
}
// Author - try multiple approaches (only in product details area)
if (!result.author) {
// Look specifically in the product details section, not the whole page
const productSection = $('.bc-section, .product-top-section, [class*="product"]').first();
const authors: string[] = [];
// First try labeled author sections
productSection.find('li.authorLabel a, span.authorLabel a, .authorLabel a').each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 0 && text.length < 80) {
authors.push(text);
}
});
// If no labeled authors, look for author links near the title (first 3 only to avoid recommendations)
if (authors.length === 0) {
$('a[href*="/author/"]').slice(0, 3).each((_, elem) => {
const text = $(elem).text().trim();
// Filter out navigation breadcrumbs and promotional text
if (text && text.length > 1 && text.length < 80 &&
!text.includes('') && !text.includes('...') &&
!text.toLowerCase().includes('more') && !text.toLowerCase().includes('see all')) {
authors.push(text);
}
});
}
if (authors.length > 0) {
// Deduplicate and limit to max 3 authors
result.author = [...new Set(authors)].slice(0, 3).join(', ');
}
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim();
console.log(`[Audible] Author from HTML: "${result.author}"`);
}
// Narrator - try multiple approaches (only in product details area)
if (!result.narrator) {
// Look specifically in the product details section
const productSection = $('.bc-section, .product-top-section, [class*="product"]').first();
const narrators: string[] = [];
// First try labeled narrator sections
productSection.find('li.narratorLabel a, span.narratorLabel a, .narratorLabel a').each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 0 && text.length < 80) {
narrators.push(text);
}
});
// If no labeled narrators, look for narrator links (first 5 only)
if (narrators.length === 0) {
$('a[href*="/narrator/"]').slice(0, 5).each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 1 && text.length < 80 &&
!text.includes('') && !text.includes('...')) {
narrators.push(text);
}
});
}
if (narrators.length > 0) {
// Deduplicate and limit to reasonable count
result.narrator = [...new Set(narrators)].slice(0, 5).join(', ');
}
if (result.narrator) {
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim();
}
console.log(`[Audible] Narrator from HTML: "${result.narrator || ''}"`);
}
// Description - try multiple approaches with strict filtering
if (!result.description) {
const excludePatterns = [
/\$\d+\.\d+/, // Price patterns
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i, // Just author names
];
const isValidDescription = (text: string): boolean => {
if (!text || text.length < 50 || text.length > 5000) return false;
// Reject if it contains promotional patterns
for (const pattern of excludePatterns) {
if (pattern.test(text)) return false;
}
return true;
};
// Try specific description selectors first
const candidates = [
$('.bc-expander-content').first().text().trim(),
$('[class*="productPublisherSummary"]').first().text().trim(),
$('[data-widget="publisherSummary"]').first().text().trim(),
$('.bc-section p').first().text().trim(),
];
// Find first valid candidate
for (const candidate of candidates) {
if (isValidDescription(candidate)) {
result.description = candidate;
break;
}
}
// If still no description, search for valid paragraphs
if (!result.description) {
$('p, div[class*="description"]').each((_, elem) => {
const text = $(elem).text().trim();
if (isValidDescription(text) && text.length > (result.description?.length || 0)) {
result.description = text;
}
});
}
console.log(`[Audible] Description length: ${result.description?.length || 0} chars`);
}
// Cover art - try multiple selectors
if (!result.coverArtUrl) {
result.coverArtUrl = $('img.bc-image-inset-border').attr('src') ||
$('img[class*="product-image"]').first().attr('src') ||
$('img[class*="cover"]').first().attr('src') ||
$('.bc-pub-detail-image img').attr('src') ||
$('img[src*="images-na.ssl-images-amazon.com"]').first().attr('src') ||
$('img[src*="m.media-amazon.com"]').first().attr('src') ||
'';
if (result.coverArtUrl) {
result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.');
}
}
// Runtime/Duration - try multiple approaches
if (!result.durationMinutes) {
// Look for runtime text in various places
const runtimeText =
$('li.runtimeLabel span').text().trim() ||
$('.runtimeLabel').text().trim() ||
$('span:contains("Length:")').parent().text().trim() ||
$('li:contains("Length:")').text().trim() ||
(() => {
// Look for any text matching duration pattern
let found = '';
$('li, span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) {
found = text;
return false; // break
}
});
return found;
})();
result.durationMinutes = this.parseRuntime(runtimeText);
console.log(`[Audible] Duration from "${runtimeText}": ${result.durationMinutes} minutes`);
}
// Rating - try multiple approaches
if (!result.rating) {
const ratingText =
$('.ratingsLabel').text().trim() ||
$('[class*="rating"]').first().text().trim() ||
$('span:contains("out of 5 stars")').parent().text().trim() ||
(() => {
// Look for rating pattern
let found = '';
$('span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) {
found = text;
return false;
}
});
return found;
})();
if (ratingText) {
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i);
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
}
console.log(`[Audible] Rating from "${ratingText}": ${result.rating}`);
}
// Release date - try multiple selectors
if (!result.releaseDate) {
const releaseDateText =
$('li:contains("Release date:")').text().trim() ||
$('span:contains("Release date:")').parent().text().trim() ||
$('[class*="release"]').text().trim();
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/);
if (dateMatch) {
result.releaseDate = dateMatch[1].trim();
}
console.log(`[Audible] Release date from "${releaseDateText}": ${result.releaseDate}`);
}
// Genres - try to extract categories
const genres: string[] = [];
$('a[href*="/cat/"]').each((_, el) => {
const genre = $(el).text().trim();
if (genre && !genres.includes(genre) && genre.length < 50 && genre.length > 2) {
genres.push(genre);
}
});
if (genres.length > 0) {
result.genres = genres.slice(0, 5); // Limit to 5 genres
console.log(`[Audible] Genres: ${result.genres.join(', ')}`);
}
console.log(`[Audible] Successfully fetched details for "${result.title}"`);
console.log(`[Audible] Final result:`, JSON.stringify({
title: result.title,
author: result.author,
narrator: result.narrator,
descLength: result.description?.length || 0,
duration: result.durationMinutes,
rating: result.rating,
genres: result.genres?.length || 0
}));
return result;
} catch (error) {
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
return null;
}
}
/**
* Parse runtime text to minutes
*/
private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined;
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i);
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
let totalMinutes = 0;
if (hoursMatch) {
totalMinutes += parseInt(hoursMatch[1]) * 60;
}
if (minutesMatch) {
totalMinutes += parseInt(minutesMatch[1]);
}
return totalMinutes > 0 ? totalMinutes : undefined;
}
/**
* Add delay between requests to respect rate limits
*/
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Singleton instance
let audibleService: AudibleService | null = null;
export function getAudibleService(): AudibleService {
if (!audibleService) {
audibleService = new AudibleService();
}
return audibleService;
}
+986
View File
@@ -0,0 +1,986 @@
/**
* Component: Plex Media Server Integration Service
* Documentation: documentation/integrations/plex.md
*/
import axios, { AxiosInstance } from 'axios';
import { parseStringPromise } from 'xml2js';
const PLEX_TV_API_BASE = 'https://plex.tv/api/v2';
const PLEX_CLIENT_IDENTIFIER = process.env.PLEX_CLIENT_IDENTIFIER || 'readmeabook-unique-client-id';
const PLEX_PRODUCT_NAME = process.env.PLEX_PRODUCT_NAME || 'ReadMeABook';
export interface PlexPin {
id: number;
code: string;
authToken?: string;
}
export interface PlexUser {
id: number;
username: string;
email?: string;
thumb?: string;
authToken: string;
}
export interface PlexLibrary {
id: string;
title: string;
type: string;
language: string;
scanner: string;
agent: string;
locations: string[];
itemCount?: number;
}
export interface PlexAudiobook {
ratingKey: string;
guid: string;
title: string;
author?: string;
narrator?: string;
duration?: number;
year?: number;
userRating?: number;
summary?: string;
thumb?: string;
addedAt: number;
updatedAt: number;
filePath?: string;
}
export interface PlexServerInfo {
machineIdentifier: string;
version: string;
platform: string;
platformVersion?: string;
}
export interface PlexHomeUser {
id: string;
uuid: string;
title: string;
friendlyName: string;
username: string;
email: string;
thumb: string;
hasPassword: boolean;
restricted: boolean;
admin: boolean;
guest: boolean;
protected: boolean;
}
export class PlexService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 10000,
});
}
/**
* Request a new PIN for OAuth authentication
*/
async requestPin(): Promise<PlexPin> {
try {
const response = await this.client.post(
`${PLEX_TV_API_BASE}/pins`,
{
strong: true,
},
{
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'X-Plex-Product': PLEX_PRODUCT_NAME,
},
}
);
return {
id: response.data.id,
code: response.data.code,
};
} catch (error) {
console.error('Failed to request Plex PIN:', error);
throw new Error('Failed to request authentication PIN from Plex');
}
}
/**
* Check PIN status (poll until user authorizes)
*/
async checkPin(pinId: number): Promise<string | null> {
try {
const response = await this.client.get(`${PLEX_TV_API_BASE}/pins/${pinId}`, {
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
});
return response.data.authToken || null;
} catch (error) {
console.error('Failed to check Plex PIN:', error);
return null;
}
}
/**
* Get user information using auth token
*/
async getUserInfo(authToken: string): Promise<PlexUser> {
try {
const response = await this.client.get('https://plex.tv/users/account', {
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
},
});
let userData: any;
// Handle different response formats from Plex
if (typeof response.data === 'string') {
// XML response - parse it
console.log('[Plex] Received XML response, parsing...');
const parsed = await parseStringPromise(response.data);
// XML attributes are in user.$
if (parsed.user && parsed.user.$) {
userData = parsed.user.$;
} else {
console.error('[Plex] Unexpected XML structure:', parsed);
throw new Error('Unexpected XML structure in Plex response');
}
} else if (response.data && typeof response.data === 'object') {
// JSON response
console.log('[Plex] Received JSON response');
userData = response.data;
} else {
console.error('[Plex] Unexpected response type:', typeof response.data);
throw new Error('Unexpected response format from Plex');
}
console.log('[Plex] Parsed user data:', JSON.stringify(userData, null, 2));
// Validate required fields
if (!userData.id) {
console.error('[Plex] User ID missing from parsed data:', userData);
throw new Error('User ID missing from Plex response');
}
const username = userData.username || userData.title;
if (!username) {
console.error('[Plex] Username missing from parsed data:', userData);
throw new Error('Username missing from Plex response');
}
return {
id: parseInt(userData.id, 10),
username,
email: userData.email || undefined,
thumb: userData.thumb || undefined,
authToken,
};
} catch (error) {
console.error('Failed to get Plex user info:', error);
if (error instanceof Error) {
throw error; // Re-throw our custom errors
}
throw new Error('Failed to retrieve user information from Plex');
}
}
/**
* Generate Plex OAuth URL
*/
getOAuthUrl(pinCode: string, pinId: number, baseCallbackUrl?: string): string {
// Use provided callback URL, or fall back to env var, or localhost
const callbackBase = baseCallbackUrl || process.env.PLEX_OAUTH_CALLBACK_URL || 'http://localhost:3030/api/auth/plex/callback';
const callbackUrl = encodeURIComponent(`${callbackBase}?pinId=${pinId}`);
return `https://app.plex.tv/auth#?clientID=${PLEX_CLIENT_IDENTIFIER}&code=${pinCode}&context[device][product]=${PLEX_PRODUCT_NAME}&forwardUrl=${callbackUrl}`;
}
/**
* Test connection to Plex server
*/
async testConnection(serverUrl: string, authToken: string): Promise<{ success: boolean; message: string; info?: PlexServerInfo }> {
try {
const response = await this.client.get(`${serverUrl}/identity`, {
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
});
let data = response.data;
// Handle different response formats from Plex
if (typeof data === 'string') {
// XML response - parse it
const parsed = await parseStringPromise(data);
// XML attributes are in MediaContainer.$
data = parsed.MediaContainer && parsed.MediaContainer.$
? parsed.MediaContainer.$
: parsed.MediaContainer || {};
} else if (data && typeof data === 'object') {
// JSON response - could be direct object or wrapped in MediaContainer
if (data.MediaContainer) {
// If wrapped, extract the MediaContainer object
data = data.MediaContainer;
}
// else data is already the right format
}
console.log('[Plex] Identity response:', JSON.stringify(data, null, 2));
const info: PlexServerInfo = {
machineIdentifier: data.machineIdentifier || 'unknown',
version: data.version || 'unknown',
platform: data.platform || 'Plex Server',
platformVersion: data.platformVersion,
};
return {
success: true,
message: `Connected to Plex server (${info.platform} v${info.version})`,
info,
};
} catch (error) {
console.error('Plex connection test failed:', error);
return {
success: false,
message: 'Could not connect to Plex server. Check server URL and token.',
};
}
}
/**
* Get server-specific access token for a user
*
* Per Plex API docs: plex.tv OAuth tokens are for talking to plex.tv,
* but you need server-specific access tokens from /api/v2/resources to talk to PMS.
*
* @param serverMachineId - The machine identifier of the PMS
* @param userPlexToken - The user's plex.tv OAuth token
* @returns The server-specific access token, or null if not found/no access
*/
async getServerAccessToken(
serverMachineId: string,
userPlexToken: string
): Promise<string | null> {
try {
console.log('[Plex] Fetching server access token for machineId:', serverMachineId);
// Get the list of servers/resources the user has access to
const response = await this.client.get('https://plex.tv/api/v2/resources', {
headers: {
'X-Plex-Token': userPlexToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'Accept': 'application/json',
},
params: {
includeHttps: 1,
includeRelay: 1,
},
timeout: 10000,
});
const resources = response.data || [];
// Find the server resource matching the machine ID
const serverResource = resources.find((r: any) => {
const resourceId = r.clientIdentifier || r.machineIdentifier;
return resourceId === serverMachineId;
});
if (!serverResource) {
console.warn('[Plex] User does not have access to server:', serverMachineId);
return null;
}
if (!serverResource.accessToken) {
console.error('[Plex] Server resource found but no accessToken provided');
return null;
}
console.log('[Plex] Found server access token for:', serverResource.name);
return serverResource.accessToken;
} catch (error) {
console.error('[Plex] Failed to fetch server access token:', error);
return null;
}
}
/**
* Verify user has access to the configured Plex server
* Returns true if user can access the server, false otherwise
*
* This checks if the server appears in the user's list of accessible servers
* from plex.tv, which properly validates shared access permissions.
*/
async verifyServerAccess(serverUrl: string, serverMachineId: string, userToken: string): Promise<boolean> {
try {
console.log('[Plex] Verifying server access for machineId:', serverMachineId);
// Get the list of servers/resources the user has access to
const response = await this.client.get('https://plex.tv/api/v2/resources', {
headers: {
'X-Plex-Token': userToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'Accept': 'application/json',
},
params: {
includeHttps: 1,
includeRelay: 1,
},
timeout: 10000,
});
const resources = response.data || [];
console.log('[Plex] User has access to', resources.length, 'resources');
// Log all resources for debugging
console.log('[Plex] User accessible resources:', JSON.stringify(
resources.map((r: any) => ({
name: r.name,
product: r.product,
provides: r.provides,
clientIdentifier: r.clientIdentifier,
machineIdentifier: r.machineIdentifier,
owned: r.owned,
})),
null,
2
));
// Filter to only server resources (not clients like apps)
const servers = resources.filter((r: any) =>
r.provides === 'server' ||
r.product === 'Plex Media Server' ||
(r.provides && r.provides.includes && r.provides.includes('server'))
);
console.log('[Plex] Found', servers.length, 'server resources');
// Check if our server is in the list of accessible resources
const hasAccess = servers.some((resource: any) => {
const resourceId = resource.clientIdentifier || resource.machineIdentifier;
const match = resourceId === serverMachineId;
console.log('[Plex] Comparing:', {
resourceId,
serverMachineId,
match,
name: resource.name,
});
if (match) {
console.log('[Plex] ✓ Found matching server:', {
name: resource.name,
machineId: resourceId,
owned: resource.owned,
});
}
return match;
});
if (!hasAccess) {
console.warn('[Plex] ✗ Server not found in user\'s accessible resources');
console.warn('[Plex] Looking for machineId:', serverMachineId);
console.warn('[Plex] User has access to servers:',
servers.map((r: any) => ({
name: r.name,
clientId: r.clientIdentifier,
machineId: r.machineIdentifier,
}))
);
}
return hasAccess;
} catch (error: any) {
console.error('[Plex] Failed to verify server access:', error.response?.status || error.message);
if (error.response?.data) {
console.error('[Plex] Error response:', error.response.data);
}
return false;
}
}
/**
* Get all libraries from Plex server
*/
async getLibraries(serverUrl: string, authToken: string): Promise<PlexLibrary[]> {
try {
const response = await this.client.get(`${serverUrl}/library/sections`, {
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
});
let data = response.data;
// Handle different response formats from Plex
if (typeof data === 'string') {
// XML response - parse it
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
data = data.MediaContainer;
}
}
const directories = data.Directory || [];
const libraries = directories.map((dir: any) => ({
id: (dir.key || dir.$?.key || '').toString(),
title: dir.title || dir.$?.title || 'Unknown Library',
type: dir.type || dir.$?.type || 'unknown',
language: dir.language || dir.$?.language || 'en',
scanner: dir.scanner || dir.$?.scanner || '',
agent: dir.agent || dir.$?.agent || '',
locations: Array.isArray(dir.Location)
? dir.Location.map((loc: any) => loc.path || loc.$?.path || '')
: [],
}));
return libraries;
} catch (error) {
console.error('Failed to get Plex libraries:', error);
throw new Error('Failed to retrieve libraries from Plex server');
}
}
/**
* Get recently added items from a library (lightweight polling method)
* Uses sort by addedAt descending with pagination
*/
async getRecentlyAdded(
serverUrl: string,
authToken: string,
libraryId: string,
limit: number = 10
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/all`,
{
params: {
type: 9, // Type 9 = Albums (books in audiobook context)
sort: 'addedAt:desc',
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': limit,
},
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
console.log('[Plex] Recently added response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Parsing XML response...');
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
console.log('[Plex] Extracting from MediaContainer wrapper');
data = data.MediaContainer;
}
}
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'recently added items');
if (!Array.isArray(tracks)) {
console.warn('[Plex] tracks is not an array:', tracks);
return [];
}
return tracks.map((item: any) => ({
ratingKey: item.ratingKey || item.$?.ratingKey,
guid: item.guid || item.$?.guid || '',
title: item.title || item.$?.title, // Album title (book name)
author: item.parentTitle || item.$?.parentTitle || item.originalTitle, // Artist name (author)
narrator: item.writer || item.$?.writer,
duration: item.duration ? parseInt(item.duration) : undefined,
year: item.year ? parseInt(item.year) : undefined,
summary: item.summary || item.$?.summary,
thumb: item.thumb || item.$?.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
userRating: item.userRating ? parseFloat(item.userRating) : (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
}));
} catch (error) {
console.error('Failed to get recently added content:', error);
throw new Error('Failed to retrieve recently added content from Plex library');
}
}
/**
* Get all items from a library
*/
async getLibraryContent(
serverUrl: string,
authToken: string,
libraryId: string
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/all`,
{
params: {
type: 9, // Type 9 = Albums (books in audiobook context)
},
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
console.log('[Plex] Library content response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Parsing XML response...');
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
console.log('[Plex] Extracting from MediaContainer wrapper');
data = data.MediaContainer;
}
}
console.log('[Plex] Data structure keys:', Object.keys(data || {}));
console.log('[Plex] Looking for content in: Metadata, Track, Directory, Album');
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'items');
if (!Array.isArray(tracks)) {
console.warn('[Plex] tracks is not an array:', tracks);
return [];
}
return tracks.map((item: any) => ({
ratingKey: item.ratingKey || item.$?.ratingKey,
guid: item.guid || item.$?.guid || '',
title: item.title || item.$?.title, // Album title (book name)
author: item.parentTitle || item.$?.parentTitle || item.originalTitle, // Artist name (author)
narrator: item.writer || item.$?.writer,
duration: item.duration ? parseInt(item.duration) : undefined,
year: item.year ? parseInt(item.year) : undefined,
summary: item.summary || item.$?.summary,
thumb: item.thumb || item.$?.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
userRating: item.userRating ? parseFloat(item.userRating) : (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
}));
} catch (error: any) {
if (error?.response?.status === 401) {
console.error('[Plex] 401 Unauthorized when fetching library content - token may not have server access permissions');
} else {
console.error('[Plex] Failed to get library content:', error);
}
throw new Error('Failed to retrieve content from Plex library');
}
}
/**
* Trigger library scan
*/
async scanLibrary(serverUrl: string, authToken: string, libraryId: string): Promise<void> {
try {
await this.client.get(`${serverUrl}/library/sections/${libraryId}/refresh`, {
headers: {
'X-Plex-Token': authToken,
},
});
console.log(`Triggered Plex library scan for library ${libraryId}`);
} catch (error) {
console.error('Failed to trigger Plex scan:', error);
throw new Error('Failed to trigger Plex library scan');
}
}
/**
* Search library for specific title
*/
async searchLibrary(
serverUrl: string,
authToken: string,
libraryId: string,
query: string
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/search`,
{
params: { title: query },
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
}
const items = data.Metadata || [];
return items.map((item: any) => ({
ratingKey: item.ratingKey || item.$.ratingKey,
guid: item.guid || item.$.guid || '',
title: item.title || item.$.title,
author: item.grandparentTitle || item.$.grandparentTitle,
duration: item.duration ? parseInt(item.duration) : undefined,
summary: item.summary || item.$.summary,
thumb: item.thumb || item.$.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
}));
} catch (error) {
console.error('Failed to search Plex library:', error);
return [];
}
}
/**
* Get metadata for a specific item (by ratingKey) with user's personal rating
* This fetches the item with the user's auth token, which includes their personal rating
*/
async getItemMetadata(
serverUrl: string,
authToken: string,
ratingKey: string
): Promise<{ userRating?: number } | null> {
try {
const response = await this.client.get(
`${serverUrl}/library/metadata/${ratingKey}`,
{
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
let data = response.data;
// Handle different response formats
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
if (data.MediaContainer) {
data = data.MediaContainer;
}
}
// Extract first metadata item
const items = data.Metadata || [];
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const item = items[0];
return {
userRating: item.userRating
? parseFloat(item.userRating)
: (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
};
} catch (error: any) {
// Handle 401 specifically (expired or invalid token)
if (error.response?.status === 401) {
console.warn(`[Plex] User token unauthorized for ratingKey ${ratingKey} (token may be expired or invalid)`);
return null;
}
// Handle 404 (item not found or user doesn't have access)
if (error.response?.status === 404) {
console.warn(`[Plex] Item not found or no access: ratingKey ${ratingKey}`);
return null;
}
console.error(`[Plex] Failed to get metadata for ratingKey ${ratingKey}:`, error.message || error);
return null;
}
}
/**
* Batch fetch ratings for multiple items using user's token
* Returns a map of ratingKey -> userRating
*/
async batchGetUserRatings(
serverUrl: string,
authToken: string,
ratingKeys: string[]
): Promise<Map<string, number>> {
const ratingsMap = new Map<string, number>();
let unauthorizedCount = 0;
// Fetch ratings in parallel (limit concurrency to avoid overwhelming Plex)
const BATCH_SIZE = 10;
for (let i = 0; i < ratingKeys.length; i += BATCH_SIZE) {
const batch = ratingKeys.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(ratingKey => this.getItemMetadata(serverUrl, authToken, ratingKey))
);
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value?.userRating) {
const ratingKey = batch[index];
ratingsMap.set(ratingKey, result.value.userRating);
} else if (result.status === 'rejected') {
// Count authorization failures
if (result.reason?.response?.status === 401) {
unauthorizedCount++;
}
}
});
}
// If we got many 401s, log a warning about token issues
if (unauthorizedCount > 0) {
console.warn(`[Plex] ${unauthorizedCount} of ${ratingKeys.length} items returned 401 (user token may be expired or invalid)`);
if (unauthorizedCount === ratingKeys.length) {
console.error('[Plex] All rating requests failed with 401 - user needs to re-authenticate with Plex');
}
}
return ratingsMap;
}
/**
* Get list of Plex Home users/profiles
* Returns all managed users and home members for the authenticated account
*/
async getHomeUsers(authToken: string): Promise<PlexHomeUser[]> {
try {
console.log('[Plex] Fetching home users from plex.tv/api/home/users');
const response = await this.client.get(
'https://plex.tv/api/home/users',
{
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
}
);
console.log('[Plex] Home users API response status:', response.status);
console.log('[Plex] Home users API response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Response is XML string, parsing...');
const parsed = await parseStringPromise(data);
data = parsed;
console.log('[Plex] Parsed XML structure:', JSON.stringify(data, null, 2));
} else {
console.log('[Plex] Response is JSON, structure:', JSON.stringify(data, null, 2));
}
// Extract users from response
// Response structure: { home: { users: [{ user: {...} }] } } or similar
const users: any[] = [];
console.log('[Plex] Checking for users in response...');
console.log('[Plex] data.MediaContainer exists?', !!data.MediaContainer);
console.log('[Plex] data.MediaContainer?.User exists?', !!data.MediaContainer?.User);
console.log('[Plex] data.home exists?', !!data.home);
console.log('[Plex] data.home?.users exists?', !!data.home?.users);
console.log('[Plex] data.users exists?', !!data.users);
// Check for users in MediaContainer.User (XML response structure)
if (data.MediaContainer?.User) {
console.log('[Plex] Found users in data.MediaContainer.User');
const usersList = Array.isArray(data.MediaContainer.User) ? data.MediaContainer.User : [data.MediaContainer.User];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
// XML parsed data has attributes in the $ property
if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else if (data.home?.users) {
console.log('[Plex] Found users in data.home.users');
const usersList = Array.isArray(data.home.users) ? data.home.users : [data.home.users];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
if (item.user) {
users.push(item.user);
} else if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else if (data.users) {
console.log('[Plex] Found users in data.users');
const usersList = Array.isArray(data.users) ? data.users : [data.users];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
if (item.user) {
users.push(item.user);
} else if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else {
console.log('[Plex] No users found in expected locations. Full data structure:');
console.log(JSON.stringify(data, null, 2));
}
console.log('[Plex] Extracted', users.length, 'users from response');
if (users.length === 0) {
console.warn('[Plex] No home users found - this account may not have a Plex Home setup');
return [];
}
return users.map((user: any) => {
// Handle both direct properties and $ properties (from XML parsing)
const id = user.id || '';
const uuid = user.uuid || '';
const title = user.title || '';
const username = user.username || '';
const email = user.email || '';
const thumb = user.thumb || '';
const hasPassword = user.hasPassword === '1' || user.hasPassword === 'true' || user.hasPassword === true;
const restricted = user.restricted === '1' || user.restricted === 'true' || user.restricted === true;
const admin = user.admin === '1' || user.admin === 'true' || user.admin === true;
const guest = user.guest === '1' || user.guest === 'true' || user.guest === true;
const protectedUser = user.protected === '1' || user.protected === 'true' || user.protected === true;
return {
id,
uuid,
title,
friendlyName: title, // In Plex Home API, 'title' is the friendly display name
username,
email,
thumb,
hasPassword,
restricted,
admin,
guest,
protected: protectedUser,
};
});
} catch (error: any) {
console.error('[Plex] Failed to get home users:', error.message || error);
if (error.response) {
console.error('[Plex] Error response status:', error.response.status);
console.error('[Plex] Error response data:', error.response.data);
}
// Return empty array if no home users (not an error condition)
return [];
}
}
/**
* Switch to a specific Plex Home user/profile
* Returns the authentication token for the selected profile
*/
async switchHomeUser(
userId: string,
authToken: string,
pin?: string
): Promise<string | null> {
try {
const params: any = {};
if (pin) {
params.pin = pin;
}
const response = await this.client.post(
`https://plex.tv/api/home/users/${userId}/switch`,
null,
{
params,
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
}
);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed;
}
// Extract authenticationToken from response
// Response structure varies: could be in root, in user object, or in attributes
let authenticationToken: string | null = null;
if (data.authenticationToken) {
authenticationToken = data.authenticationToken;
} else if (data.user?.authenticationToken) {
authenticationToken = data.user.authenticationToken;
} else if (data.$?.authenticationToken) {
authenticationToken = data.$?.authenticationToken;
} else if (data.user?.$?.authenticationToken) {
authenticationToken = data.user.$?.authenticationToken;
}
if (!authenticationToken) {
console.error('[Plex] No authenticationToken found in switch response:', JSON.stringify(data, null, 2));
return null;
}
return authenticationToken;
} catch (error: any) {
// Handle PIN errors specifically
if (error.response?.status === 401) {
console.error('[Plex] Invalid PIN for profile');
throw new Error('Invalid PIN');
}
console.error('[Plex] Failed to switch home user:', error);
throw new Error('Failed to switch to selected profile');
}
}
}
// Singleton instance
let plexService: PlexService | null = null;
export function getPlexService(): PlexService {
if (!plexService) {
plexService = new PlexService();
}
return plexService;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Component: Prowlarr Integration Service
* Documentation: documentation/phase3/prowlarr.md
*/
import axios, { AxiosInstance } from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { TorrentResult } from '../utils/ranking-algorithm';
export interface SearchFilters {
category?: number;
minSeeders?: number;
maxResults?: number;
}
export interface Indexer {
id: number;
name: string;
enable: boolean;
protocol: string;
priority: number;
capabilities?: {
supportsRss?: boolean;
};
fields?: Array<{
name: string;
value: any;
}>;
}
export interface IndexerStats {
indexers: Array<{
indexerId: number;
indexerName: string;
numberOfQueries: number;
numberOfGrabs: number;
numberOfFailedQueries: number;
averageResponseTime: number;
}>;
}
interface ProwlarrSearchResult {
guid: string;
indexer: string;
title: string;
size: number;
seeders: number;
leechers: number;
publishDate: string;
downloadUrl: string;
infoHash?: string;
categories?: number[];
}
export class ProwlarrService {
private client: AxiosInstance;
private baseUrl: string;
private apiKey: string;
private defaultCategory = 3030; // Audiobooks category
constructor(baseUrl: string, apiKey: string) {
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.apiKey = apiKey;
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
headers: {
'X-Api-Key': this.apiKey,
},
timeout: 30000, // 30 seconds
});
}
/**
* Search for audiobooks across all configured indexers
*/
async search(
query: string,
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
const params: Record<string, any> = {
query,
categories: filters?.category?.toString() || this.defaultCategory.toString(),
type: 'search',
extended: 1, // Enable searching in tags, labels, and metadata
};
const response = await this.client.get('/search', { params });
// Transform Prowlarr results to our format
const results = response.data
.map((result: ProwlarrSearchResult) => this.transformResult(result))
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
// Apply filters
let filtered = results;
if (filters?.minSeeders) {
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
}
if (filters?.maxResults) {
filtered = filtered.slice(0, filters.maxResults);
}
console.log(`Prowlarr search for "${query}" returned ${filtered.length} results`);
return filtered;
} catch (error) {
console.error('Prowlarr search failed:', error);
throw new Error(
`Failed to search Prowlarr: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Get list of configured indexers
*/
async getIndexers(): Promise<Indexer[]> {
try {
const response = await this.client.get('/indexer');
return response.data;
} catch (error) {
console.error('Failed to get Prowlarr indexers:', error);
throw new Error('Failed to get indexers from Prowlarr');
}
}
/**
* Test connection to Prowlarr
*/
async testConnection(): Promise<boolean> {
try {
await this.client.get('/health');
return true;
} catch (error) {
console.error('Prowlarr connection test failed:', error);
return false;
}
}
/**
* Get indexer statistics
*/
async getStats(): Promise<IndexerStats> {
try {
const response = await this.client.get('/indexerstats');
return response.data;
} catch (error) {
console.error('Failed to get Prowlarr stats:', error);
throw new Error('Failed to get indexer statistics');
}
}
/**
* Get RSS feed for a specific indexer
* Returns recent releases from the indexer's RSS feed
* Uses true RSS feed endpoint to avoid burdening indexers with searches
*/
async getRssFeed(indexerId: number): Promise<TorrentResult[]> {
try {
// Prowlarr RSS endpoint: /{indexerId}/api?apikey={key}&t=search&cat=3030
const rssUrl = `${this.baseUrl}/${indexerId}/api`;
const response = await axios.get(rssUrl, {
params: {
apikey: this.apiKey,
t: 'search',
cat: this.defaultCategory.toString(),
limit: 100,
extended: 1,
},
timeout: 30000,
responseType: 'text', // Get XML as text
});
// Parse XML RSS feed
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(response.data);
// Extract items from RSS feed
const items = parsed?.rss?.channel?.item || [];
const itemsArray = Array.isArray(items) ? items : [items];
// Transform RSS items to TorrentResult format
const results: TorrentResult[] = [];
for (const item of itemsArray) {
if (!item) continue;
try {
// Extract torznab attributes
const attrs = Array.isArray(item['torznab:attr']) ? item['torznab:attr'] : [item['torznab:attr']];
const getAttr = (name: string) => {
const attr = attrs.find((a: any) => a?.['@_name'] === name);
return attr?.['@_value'];
};
const seeders = parseInt(getAttr('seeders') || '0', 10);
const peers = parseInt(getAttr('peers') || '0', 10);
const leechers = Math.max(0, peers - seeders);
// Extract metadata from title
const metadata = this.extractMetadata(item.title || '');
const result: TorrentResult = {
indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown',
title: item.title || '',
size: parseInt(item.size || '0', 10),
seeders,
leechers,
publishDate: item.pubDate ? new Date(item.pubDate) : new Date(),
downloadUrl: item.link || item.enclosure?.['@_url'] || '',
infoHash: getAttr('infohash'),
guid: item.guid || '',
format: metadata.format,
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
};
results.push(result);
} catch (error) {
console.error('Failed to parse RSS item:', error);
// Continue with other items
}
}
console.log(`RSS feed for indexer ${indexerId} returned ${results.length} results`);
return results;
} catch (error) {
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
throw new Error(`Failed to get RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get RSS feeds from all enabled indexers
*/
async getAllRssFeeds(indexerIds: number[]): Promise<TorrentResult[]> {
const allResults: TorrentResult[] = [];
for (const indexerId of indexerIds) {
try {
const results = await this.getRssFeed(indexerId);
allResults.push(...results);
} catch (error) {
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
// Continue with other indexers even if one fails
}
}
console.log(`RSS feeds from ${indexerIds.length} indexers returned ${allResults.length} total results`);
return allResults;
}
/**
* Transform Prowlarr result to our TorrentResult format
*/
private transformResult(result: ProwlarrSearchResult): TorrentResult | null {
try {
// Extract metadata from title
const metadata = this.extractMetadata(result.title);
return {
indexer: result.indexer,
title: result.title,
size: result.size,
seeders: result.seeders,
leechers: result.leechers,
publishDate: new Date(result.publishDate),
downloadUrl: result.downloadUrl,
infoHash: result.infoHash,
guid: result.guid,
format: metadata.format,
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
};
} catch (error) {
console.error('Failed to transform result:', result, error);
return null;
}
}
/**
* Extract audiobook metadata from torrent title
*/
private extractMetadata(title: string): {
format?: 'M4B' | 'M4A' | 'MP3';
bitrate?: string;
hasChapters?: boolean;
} {
const upperTitle = title.toUpperCase();
// Detect format
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
if (upperTitle.includes('M4B')) {
format = 'M4B';
} else if (upperTitle.includes('M4A')) {
format = 'M4A';
} else if (upperTitle.includes('MP3')) {
format = 'MP3';
}
// Detect bitrate (e.g., "64kbps", "128 KBPS")
const bitrateMatch = title.match(/(\d+)\s*kbps/i);
const bitrate = bitrateMatch ? `${bitrateMatch[1]}kbps` : undefined;
// M4B typically has chapters
const hasChapters = format === 'M4B' ? true : undefined;
return {
format,
bitrate,
hasChapters,
};
}
}
// Singleton instance
let prowlarrService: ProwlarrService | null = null;
export async function getProwlarrService(): Promise<ProwlarrService> {
if (!prowlarrService) {
// Get configuration from database
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const config = await configService.getMany(['prowlarr_url', 'prowlarr_api_key']);
const baseUrl = config.prowlarr_url || process.env.PROWLARR_URL || 'http://prowlarr:9696';
const apiKey = config.prowlarr_api_key || process.env.PROWLARR_API_KEY;
if (!apiKey) {
throw new Error('Prowlarr API key not configured');
}
prowlarrService = new ProwlarrService(baseUrl, apiKey);
// Test connection
const isConnected = await prowlarrService.testConnection();
if (!isConnected) {
console.warn('Warning: Prowlarr connection test failed');
}
}
return prowlarrService;
}
+774
View File
@@ -0,0 +1,774 @@
/**
* Component: qBittorrent Integration Service
* Documentation: documentation/phase3/qbittorrent.md
*/
import axios, { AxiosInstance } from 'axios';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
export interface AddTorrentOptions {
savePath?: string;
category?: string;
tags?: string[];
paused?: boolean;
skipChecking?: boolean;
sequentialDownload?: boolean;
}
export interface TorrentInfo {
hash: string;
name: string;
size: number;
progress: number; // 0.0 to 1.0
dlspeed: number; // Bytes per second
upspeed: number;
downloaded: number;
uploaded: number;
eta: number; // Seconds remaining
state: TorrentState;
category: string;
tags: string;
save_path: string;
completion_on: number; // Unix timestamp
added_on: number;
seeding_time?: number; // Seconds spent seeding
ratio?: number; // Upload/download ratio
}
export type TorrentState =
| 'downloading'
| 'uploading'
| 'stalledDL'
| 'stalledUP'
| 'pausedDL'
| 'pausedUP'
| 'queuedDL'
| 'queuedUP'
| 'checkingDL'
| 'checkingUP'
| 'error'
| 'missingFiles'
| 'allocating';
export interface TorrentFile {
name: string;
size: number;
progress: number;
priority: number;
index: number;
}
export interface DownloadProgress {
percent: number;
bytesDownloaded: number;
bytesTotal: number;
speed: number;
eta: number;
state: string;
}
export class QBittorrentService {
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private cookie?: string;
private defaultSavePath: string;
private defaultCategory: string;
constructor(
baseUrl: string,
username: string,
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook'
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000,
});
}
/**
* Authenticate and establish session
*/
async login(): Promise<void> {
try {
const response = await axios.post(
`${this.baseUrl}/api/v2/auth/login`,
new URLSearchParams({
username: this.username,
password: this.password,
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
// Extract cookie from response
const cookies = response.headers['set-cookie'];
if (cookies && cookies.length > 0) {
this.cookie = cookies[0].split(';')[0];
}
if (!this.cookie) {
throw new Error('Failed to authenticate with qBittorrent');
}
console.log('Successfully authenticated with qBittorrent');
} catch (error) {
console.error('qBittorrent login failed:', error);
throw new Error('Failed to authenticate with qBittorrent');
}
}
/**
* Add torrent (magnet link or file URL) - Enterprise Implementation
*/
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
// Ensure we're authenticated
if (!this.cookie) {
await this.login();
}
try {
const category = options?.category || this.defaultCategory;
// Ensure category exists
await this.ensureCategory(category);
// Determine if this is a magnet link or .torrent file URL
if (url.startsWith('magnet:')) {
console.log('[qBittorrent] Detected magnet link');
return await this.addMagnetLink(url, category, options);
} else {
console.log('[qBittorrent] Detected .torrent file URL');
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating if we get a 403
if (axios.isAxiosError(error) && error.response?.status === 403) {
console.log('[qBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options); // Retry once
}
console.error('[qBittorrent] Failed to add torrent:', error);
throw new Error('Failed to add torrent to qBittorrent');
}
}
/**
* Add magnet link - hash is extractable from URI (deterministic)
*/
private async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddTorrentOptions
): Promise<string> {
// Extract info_hash from magnet link (deterministic)
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) {
throw new Error('Invalid magnet link - could not extract info_hash');
}
console.log(`[qBittorrent] Extracted info_hash from magnet: ${infoHash}`);
// Check for duplicates
try {
const existing = await this.getTorrent(infoHash);
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue with adding
}
// Upload via 'urls' parameter
const form = new URLSearchParams({
urls: magnetUrl,
savepath: options?.savePath || this.defaultSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
});
if (options?.tags) {
form.append('tags', options.tags.join(','));
}
console.log('[qBittorrent] Uploading magnet link...');
const response = await this.client.post('/torrents/add', form, {
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (response.data !== 'Ok.') {
throw new Error(`qBittorrent rejected magnet link: ${response.data}`);
}
console.log(`[qBittorrent] Successfully added magnet link: ${infoHash}`);
return infoHash;
}
/**
* Add .torrent file - download, parse, extract hash, upload content (deterministic)
*/
private async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddTorrentOptions
): Promise<string> {
console.log(`[qBittorrent] Downloading .torrent file from: ${torrentUrl}`);
// Make initial request with maxRedirects: 0 to intercept redirects
// Some Prowlarr indexers return HTTP URLs that redirect to magnet: links
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer',
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
timeout: 10000,
});
console.log(`[qBittorrent] Got 2xx response, size=${torrentResponse.data.length} bytes`);
// Check if response body contains a magnet link
if (torrentResponse.data.length > 0) {
const responseText = torrentResponse.data.toString();
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
if (magnetMatch) {
console.log(`[qBittorrent] Response body is a magnet link`);
return await this.addMagnetLink(magnetMatch[0], category, options);
}
}
// Got valid torrent data (or will be validated below)
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) {
// Not an axios error or no response - re-throw
console.error(`[qBittorrent] Request failed:`, error);
throw error;
}
const status = error.response.status;
// Handle 3xx redirects
if (status >= 300 && status < 400) {
const location = error.response.headers['location'];
console.log(`[qBittorrent] Got ${status} redirect to: ${location}`);
// Check if redirect target is a magnet link
if (location && location.startsWith('magnet:')) {
console.log(`[qBittorrent] Redirect target is magnet link`);
return await this.addMagnetLink(location, category, options);
}
// Regular HTTP redirect - follow it manually
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
console.log(`[qBittorrent] Following HTTP redirect...`);
try {
torrentResponse = await axios.get(location, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
});
console.log(`[qBittorrent] After following redirect: size=${torrentResponse.data.length} bytes`);
} catch (redirectError) {
console.error(`[qBittorrent] Failed to follow redirect:`, redirectError);
throw new Error('Failed to download torrent file after redirect');
}
} else {
throw new Error(`Invalid redirect location: ${location}`);
}
} else {
// Non-redirect error (4xx, 5xx)
console.error(`[qBittorrent] HTTP error ${status}:`, error.message);
throw new Error(`Failed to download torrent: HTTP ${status}`);
}
}
const torrentBuffer = Buffer.from(torrentResponse.data);
console.log(`[qBittorrent] Processing torrent file: ${torrentBuffer.length} bytes`);
// Parse .torrent file to extract info_hash (deterministic)
let parsedTorrent: any;
try {
parsedTorrent = await parseTorrent(torrentBuffer);
} catch (error) {
console.error('[qBittorrent] Failed to parse .torrent file:', error);
throw new Error('Invalid .torrent file - failed to parse');
}
const infoHash = parsedTorrent.infoHash;
if (!infoHash) {
throw new Error('Failed to extract info_hash from .torrent file');
}
console.log(`[qBittorrent] Extracted info_hash: ${infoHash}`);
console.log(`[qBittorrent] Torrent name: ${parsedTorrent.name || 'Unknown'}`);
// Check for duplicates
try {
const existing = await this.getTorrent(infoHash);
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue with adding
}
// Upload .torrent file content via multipart/form-data
const formData = new FormData();
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
formData.append('torrents', torrentBuffer, {
filename,
contentType: 'application/x-bittorrent',
});
formData.append('savepath', options?.savePath || this.defaultSavePath);
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
if (options?.tags) {
formData.append('tags', options.tags.join(','));
}
console.log('[qBittorrent] Uploading .torrent file content...');
const response = await this.client.post('/torrents/add', formData, {
headers: {
Cookie: this.cookie,
...formData.getHeaders(),
},
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
if (response.data !== 'Ok.') {
throw new Error(`qBittorrent rejected .torrent file: ${response.data}`);
}
console.log(`[qBittorrent] Successfully added torrent: ${infoHash}`);
return infoHash;
}
/**
* Ensure category exists in qBittorrent
*/
private async ensureCategory(category: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
// Create category (this is idempotent - won't fail if it already exists)
await this.client.post(
'/torrents/createCategory',
new URLSearchParams({
category,
savePath: this.defaultSavePath,
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`[qBittorrent] Category "${category}" ensured`);
} catch (error) {
// Ignore errors - category might already exist
console.log(`[qBittorrent] Category creation returned:`, error);
}
}
/**
* Get torrent status and progress
*/
async getTorrent(hash: string): Promise<TorrentInfo> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
params: { hashes: hash },
});
const torrents = response.data;
if (!torrents || torrents.length === 0) {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
} catch (error) {
// Don't log error here - caller handles it (e.g., duplicate checking)
throw error;
}
}
/**
* Get all torrents (optionally filtered by category)
*/
async getTorrents(category?: string): Promise<TorrentInfo[]> {
if (!this.cookie) {
await this.login();
}
try {
const params: Record<string, string> = {};
if (category) {
params.category = category;
}
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
params,
});
return response.data;
} catch (error) {
console.error('Failed to get torrents:', error);
throw new Error('Failed to get torrents from qBittorrent');
}
}
/**
* Pause torrent
*/
async pauseTorrent(hash: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/pause',
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Paused torrent: ${hash}`);
} catch (error) {
console.error('Failed to pause torrent:', error);
throw new Error('Failed to pause torrent');
}
}
/**
* Resume torrent
*/
async resumeTorrent(hash: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/resume',
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Resumed torrent: ${hash}`);
} catch (error) {
console.error('Failed to resume torrent:', error);
throw new Error('Failed to resume torrent');
}
}
/**
* Delete torrent
*/
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/delete',
new URLSearchParams({
hashes: hash,
deleteFiles: deleteFiles.toString(),
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Deleted torrent: ${hash}`);
} catch (error) {
console.error('Failed to delete torrent:', error);
throw new Error('Failed to delete torrent');
}
}
/**
* Get files in torrent
*/
async getFiles(hash: string): Promise<TorrentFile[]> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/files', {
headers: { Cookie: this.cookie },
params: { hash },
});
return response.data;
} catch (error) {
console.error('Failed to get torrent files:', error);
throw new Error('Failed to get torrent files');
}
}
/**
* Set category for torrent
*/
async setCategory(hash: string, category: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/setCategory',
new URLSearchParams({
hashes: hash,
category,
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Set category for torrent ${hash}: ${category}`);
} catch (error) {
console.error('Failed to set category:', error);
throw new Error('Failed to set torrent category');
}
}
/**
* Test connection to qBittorrent
*/
async testConnection(): Promise<boolean> {
try {
await this.login();
return true;
} catch (error) {
console.error('qBittorrent connection test failed:', error);
return false;
}
}
/**
* Static method to test connection with custom credentials (for setup wizard)
*/
static async testConnectionWithCredentials(
url: string,
username: string,
password: string
): Promise<string> {
const baseUrl = url.replace(/\/$/, '');
try {
const response = await axios.post(
`${baseUrl}/api/v2/auth/login`,
new URLSearchParams({ username, password }),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
// Get version to confirm connection
const cookies = response.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
throw new Error('Failed to authenticate');
}
const cookie = cookies[0].split(';')[0];
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
headers: { Cookie: cookie },
});
return versionResponse.data || 'Connected';
} catch (error) {
console.error('qBittorrent connection test failed:', error);
throw new Error('Failed to connect to qBittorrent');
}
}
/**
* Get download progress details
*/
getDownloadProgress(torrent: TorrentInfo): DownloadProgress {
return {
percent: Math.round(torrent.progress * 100),
bytesDownloaded: torrent.downloaded,
bytesTotal: torrent.size,
speed: torrent.dlspeed,
eta: torrent.eta,
state: this.mapState(torrent.state),
};
}
/**
* Map qBittorrent state to our simplified state
*/
private mapState(state: TorrentState): string {
const stateMap: Record<TorrentState, string> = {
downloading: 'downloading',
uploading: 'completed',
stalledDL: 'downloading',
stalledUP: 'completed',
pausedDL: 'paused',
pausedUP: 'paused',
queuedDL: 'queued',
queuedUP: 'completed',
checkingDL: 'checking',
checkingUP: 'checking',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
};
return stateMap[state] || 'unknown';
}
/**
* Extract info_hash from magnet link
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
// Extract hash from magnet:?xt=urn:btih:HASH
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
return match[1].toLowerCase();
}
return null;
}
}
// Singleton instance
let qbittorrentService: QBittorrentService | null = null;
let configLoaded = false;
export async function getQBittorrentService(): Promise<QBittorrentService> {
// Always recreate if config hasn't been loaded successfully
if (!qbittorrentService || !configLoaded) {
try {
// Get configuration from database ONLY (no env var fallback)
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
console.log('[qBittorrent] Loading configuration from database...');
const config = await configService.getMany([
'download_client_url',
'download_client_username',
'download_client_password',
'download_dir',
]);
console.log('[qBittorrent] Config loaded:', {
hasUrl: !!config.download_client_url,
hasUsername: !!config.download_client_username,
hasPassword: !!config.download_client_password,
hasPath: !!config.download_dir,
});
// Validate all required fields are present (no env var fallback)
const missingFields: string[] = [];
if (!config.download_client_url) {
missingFields.push('qBittorrent URL');
}
if (!config.download_client_username) {
missingFields.push('qBittorrent username');
}
if (!config.download_client_password) {
missingFields.push('qBittorrent password');
}
if (!config.download_dir) {
missingFields.push('Download path');
}
if (missingFields.length > 0) {
const errorMsg = `qBittorrent is not fully configured. Missing: ${missingFields.join(', ')}. Please configure qBittorrent in the admin settings.`;
console.error('[qBittorrent]', errorMsg);
throw new Error(errorMsg);
}
// TypeScript type narrowing: at this point we know all values are non-null
const url = config.download_client_url as string;
const username = config.download_client_username as string;
const password = config.download_client_password as string;
const savePath = config.download_dir as string;
console.log('[qBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
url,
username,
password,
savePath,
'readmeabook'
);
// Test connection
console.log('[qBittorrent] Testing connection...');
const isConnected = await qbittorrentService.testConnection();
if (!isConnected) {
console.warn('[qBittorrent] Connection test failed');
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
} else {
console.log('[qBittorrent] Connection test successful');
configLoaded = true; // Mark as successfully loaded
}
} catch (error) {
console.error('[qBittorrent] Failed to initialize service:', error);
qbittorrentService = null; // Reset service on error
configLoaded = false;
throw error;
}
}
return qbittorrentService;
}