mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user