Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
/**
* Component: API Utility Functions
* Documentation: documentation/frontend/utilities.md
*/
import { isTokenExpired } from './jwt-client';
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/**
* Refresh the access token using the refresh token
*/
async function refreshAccessToken(): Promise<string | null> {
// If already refreshing, return the existing promise
if (isRefreshing && refreshPromise) {
console.log('[refreshAccessToken] Already refreshing, returning existing promise');
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
console.log('[refreshAccessToken] Has refresh token:', !!refreshToken);
if (!refreshToken) {
console.error('[refreshAccessToken] No refresh token found');
return null;
}
// Check if refresh token is expired
if (isTokenExpired(refreshToken)) {
console.error('[refreshAccessToken] Refresh token is expired');
return null;
}
console.log('[refreshAccessToken] Calling /api/auth/refresh');
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
console.log('[refreshAccessToken] Refresh response status:', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[refreshAccessToken] Refresh failed:', errorData);
return null;
}
const data = await response.json();
const newAccessToken = data.accessToken;
console.log('[refreshAccessToken] New access token received');
// Update localStorage
localStorage.setItem('accessToken', newAccessToken);
return newAccessToken;
} catch (error) {
console.error('[refreshAccessToken] Token refresh failed:', error);
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Logout user by clearing tokens and redirecting
*/
function performLogout() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Redirect to login if not already there
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
}
}
/**
* Make an authenticated API request with JWT token
* Automatically handles 401 errors by refreshing token and retrying
*/
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = localStorage.getItem('accessToken');
console.log('[fetchWithAuth] Making request to:', url);
console.log('[fetchWithAuth] Has token:', !!token);
const headers = {
...options.headers,
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
// Make initial request
let response = await fetch(url, {
...options,
headers,
});
console.log('[fetchWithAuth] Initial response status:', response.status);
// Handle 401 Unauthorized - attempt token refresh
if (response.status === 401) {
console.log('[fetchWithAuth] Got 401, attempting token refresh...');
const newAccessToken = await refreshAccessToken();
if (newAccessToken) {
console.log('[fetchWithAuth] Token refreshed successfully, retrying request');
// Retry request with new token
const newHeaders = {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`,
};
response = await fetch(url, {
...options,
headers: newHeaders,
});
console.log('[fetchWithAuth] Retry response status:', response.status);
// If still 401, logout
if (response.status === 401) {
console.error('[fetchWithAuth] Still 401 after refresh, logging out');
performLogout();
}
} else {
// Refresh failed - logout
console.error('[fetchWithAuth] Token refresh failed, logging out');
performLogout();
}
}
return response;
}
/**
* Fetch JSON data with authentication
*/
export async function fetchJSON<T = any>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetchWithAuth(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
/**
* SWR fetcher with authentication
*/
export const authenticatedFetcher = (url: string) => fetchJSON(url);
+463
View File
@@ -0,0 +1,463 @@
/**
* Component: Audiobook Matching Utility
* Documentation: documentation/integrations/audible.md
*
* Real-time matching between Audible books and library backends (Plex or Audiobookshelf).
* Supports ASIN, ISBN, and fuzzy title/author matching.
*/
import { prisma } from '@/lib/db';
import { compareTwoStrings } from 'string-similarity';
import { LibraryItem } from '@/lib/services/library';
// Debug logging controlled by LOG_LEVEL environment variable
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
export interface AudiobookMatchInput {
asin: string;
title: string;
author: string;
narrator?: string;
}
export interface AudiobookMatchResult {
plexGuid: string;
title: string;
author: string;
}
/**
* Normalize audiobook title for matching by removing common suffixes/prefixes
* that don't affect the core title identity.
*/
function normalizeTitle(title: string): string {
let normalized = title.toLowerCase().trim();
// Remove common parenthetical additions (case-insensitive)
normalized = normalized.replace(/\s*\(unabridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(abridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full cast\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full-cast edition\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(dramatized\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(narrated by[^)]*\)\s*/gi, ' ');
// Remove common subtitle patterns
normalized = normalized.replace(/:\s*a novel\s*$/gi, '');
normalized = normalized.replace(/:\s*a thriller\s*$/gi, '');
normalized = normalized.replace(/:\s*a memoir\s*$/gi, '');
// Remove book number suffixes (but keep them in main title if they're significant)
// Only remove if they're clearly series indicators at the end
normalized = normalized.replace(/,?\s*book\s+\d+\s*$/gi, '');
normalized = normalized.replace(/:\s*book\s+\d+\s*$/gi, '');
// Clean up extra whitespace
normalized = normalized.replace(/\s+/g, ' ').trim();
return normalized;
}
/**
* Find a matching audiobook in the Plex library for a given Audible audiobook.
*
* Matching logic (in order of priority):
* 1. **ASIN in plexGuid** - Check if any Plex book's GUID contains the Audible ASIN (100% match)
* 2. **Fuzzy matching** - Normalized title/author string similarity with 70% threshold
*
* @param audiobook - Audible audiobook to match
* @returns Matched Plex library item or null
*/
export async function findPlexMatch(
audiobook: AudiobookMatchInput
): Promise<AudiobookMatchResult | null> {
// Query plex_library for potential matches
// IMPORTANT: Search by TITLE ONLY (not author) because Plex often has narrator as author
const titleSearchLength = Math.min(20, audiobook.title.length);
const plexBooks = await prisma.plexLibrary.findMany({
where: {
title: {
contains: audiobook.title.substring(0, titleSearchLength),
mode: 'insensitive',
},
},
select: {
plexGuid: true,
title: true,
author: true,
},
take: 20,
});
// Build match result for logging
const matchResult: any = {
input: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || null,
asin: audiobook.asin,
},
candidatesFound: plexBooks.length,
matchType: null,
matched: false,
result: null,
};
// If no candidates found, log and return null
if (plexBooks.length === 0) {
matchResult.matchType = 'no_candidates';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
// PRIORITY 1: Check for EXACT ASIN match in plexGuid
for (const plexBook of plexBooks) {
if (plexBook.plexGuid && plexBook.plexGuid.includes(audiobook.asin)) {
matchResult.matchType = 'asin_exact';
matchResult.matched = true;
matchResult.result = {
plexGuid: plexBook.plexGuid,
plexTitle: plexBook.title,
plexAuthor: plexBook.author,
confidence: 100,
};
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return plexBook;
}
}
// FILTER OUT candidates with wrong ASINs in plexGuid
const ASIN_PATTERN = /[A-Z0-9]{10}/g;
const rejectedAsins: string[] = [];
const validCandidates = plexBooks.filter((plexBook) => {
if (!plexBook.plexGuid) return true;
const asinsInGuid = plexBook.plexGuid.match(ASIN_PATTERN);
if (!asinsInGuid || asinsInGuid.length === 0) return true;
const hasOurAsin = asinsInGuid.some(asin => asin === audiobook.asin);
const hasOtherAsins = asinsInGuid.some(asin => asin !== audiobook.asin);
if (hasOtherAsins && !hasOurAsin) {
rejectedAsins.push(...asinsInGuid);
return false;
}
return true;
});
matchResult.asinFiltering = {
beforeCount: plexBooks.length,
afterCount: validCandidates.length,
rejectedAsins: rejectedAsins.length > 0 ? rejectedAsins : undefined,
};
if (validCandidates.length === 0) {
matchResult.matchType = 'asin_filtered_all';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
// Normalize the Audible title
const normalizedAudibleTitle = normalizeTitle(audiobook.title);
// PRIORITY 2: Perform fuzzy matching
const candidates = validCandidates.map((plexBook) => {
const normalizedPlexTitle = normalizeTitle(plexBook.title);
const titleScore = compareTwoStrings(normalizedAudibleTitle, normalizedPlexTitle);
const authorScore = compareTwoStrings(
audiobook.author.toLowerCase(),
plexBook.author.toLowerCase()
);
let narratorScore = 0;
let usedNarratorMatch = false;
if (audiobook.narrator) {
narratorScore = compareTwoStrings(
audiobook.narrator.toLowerCase(),
plexBook.author.toLowerCase()
);
usedNarratorMatch = narratorScore > authorScore;
}
const personScore = usedNarratorMatch ? narratorScore : authorScore;
const overallScore = titleScore * 0.7 + personScore * 0.3;
return {
plexBook,
titleScore,
authorScore,
narratorScore,
usedNarratorMatch,
score: overallScore
};
});
// Sort by score descending
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Add best match details to result
matchResult.bestCandidate = {
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
plexGuid: bestMatch.plexBook.plexGuid,
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
narrator: audiobook.narrator ? Math.round(bestMatch.narratorScore * 100) : null,
usedMatch: bestMatch.usedNarratorMatch ? 'narrator' : 'author',
overall: Math.round(bestMatch.score * 100),
},
threshold: 70,
};
// Accept match if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
matchResult.matchType = 'fuzzy';
matchResult.matched = true;
matchResult.result = {
plexGuid: bestMatch.plexBook.plexGuid,
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
confidence: Math.round(bestMatch.score * 100),
};
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return bestMatch.plexBook;
}
// No match found
matchResult.matchType = 'fuzzy_below_threshold';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
/**
* Enrich an Audible audiobook with Plex library match information.
* Used by API routes to add availability status to responses.
*/
export async function enrichAudiobookWithMatch(audiobook: AudiobookMatchInput & Record<string, any>) {
const match = await findPlexMatch(audiobook);
return {
...audiobook,
isAvailable: match !== null,
plexGuid: match?.plexGuid || null,
};
}
/**
* Batch enrich multiple audiobooks with match information.
* Processes in parallel for better performance.
*
* @param audiobooks - Audiobooks to enrich
* @param userId - Optional user ID to check request status
*/
export async function enrichAudiobooksWithMatches(
audiobooks: Array<AudiobookMatchInput & Record<string, any>>,
userId?: string
) {
const results = await Promise.all(audiobooks.map((book) => enrichAudiobookWithMatch(book)));
// Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin);
// Get all audiobook records for these ASINs with ALL requests
const audiobookRecords = await prisma.audiobook.findMany({
where: {
audibleAsin: { in: asins },
},
select: {
id: true,
audibleAsin: true,
requests: {
select: {
id: true,
status: true,
userId: true,
user: {
select: {
plexUsername: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
// Create a map of ASIN -> request info
const requestMap = new Map<string, {
requestId: string;
requestStatus: string;
requestedByUserId: string;
requestedByUsername: string;
}>();
for (const record of audiobookRecords) {
if (record.requests.length > 0 && record.audibleAsin) {
const request = record.requests[0];
requestMap.set(record.audibleAsin, {
requestId: request.id,
requestStatus: request.status,
requestedByUserId: request.userId,
requestedByUsername: request.user.plexUsername,
});
}
}
// Add request status to results
for (const result of results) {
const requestInfo = requestMap.get(result.asin);
const enrichedResult = result as any;
if (requestInfo) {
enrichedResult.isRequested = true;
enrichedResult.requestStatus = requestInfo.requestStatus;
enrichedResult.requestId = requestInfo.requestId;
enrichedResult.requestedByUserId = requestInfo.requestedByUserId;
// Only include username if it's not the current user
if (userId && requestInfo.requestedByUserId !== userId) {
enrichedResult.requestedByUsername = requestInfo.requestedByUsername;
}
} else {
enrichedResult.isRequested = false;
enrichedResult.requestStatus = null;
enrichedResult.requestId = null;
enrichedResult.requestedByUserId = null;
enrichedResult.requestedByUsername = null;
}
}
if (DEBUG_ENABLED) {
const summary = {
total: results.length,
available: results.filter(r => r.isAvailable).length,
notAvailable: results.filter(r => !r.isAvailable).length,
requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A',
};
console.log(JSON.stringify({ MATCHER_BATCH_SUMMARY: summary }));
}
return results;
}
/**
* Normalize ISBN for comparison (remove dashes and spaces)
*/
function normalizeISBN(isbn: string): string {
return isbn.replace(/[-\s]/g, '').toUpperCase();
}
/**
* Generic audiobook matching function that works with LibraryItem interface.
* Works with any library backend (Plex, Audiobookshelf, etc.)
*
* Matching priority:
* 1. Exact ASIN match (100% confidence)
* 2. Exact ISBN match (95% confidence)
* 3. Fuzzy title/author match (70%+ threshold)
*
* @param request - Audiobook request details
* @param libraryItems - Items from library backend
* @returns Matched LibraryItem or null
*/
export function matchAudiobook(
request: { title: string; author: string; asin?: string; isbn?: string },
libraryItems: LibraryItem[]
): LibraryItem | null {
// 1. Exact ASIN match (highest confidence)
if (request.asin) {
const asinMatch = libraryItems.find(item =>
item.asin?.toLowerCase() === request.asin?.toLowerCase()
);
if (asinMatch) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'asin_exact',
input: { title: request.title, asin: request.asin },
matched: { title: asinMatch.title, asin: asinMatch.asin },
confidence: 100
}
}));
}
return asinMatch;
}
}
// 2. Exact ISBN match (normalize ISBNs by removing dashes)
if (request.isbn) {
const normalizedRequestISBN = normalizeISBN(request.isbn);
const isbnMatch = libraryItems.find(item =>
item.isbn && normalizeISBN(item.isbn) === normalizedRequestISBN
);
if (isbnMatch) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'isbn_exact',
input: { title: request.title, isbn: request.isbn },
matched: { title: isbnMatch.title, isbn: isbnMatch.isbn },
confidence: 95
}
}));
}
return isbnMatch;
}
}
// 3. Fuzzy title/author match
const normalizedRequestTitle = normalizeTitle(request.title);
const normalizedRequestAuthor = request.author.toLowerCase();
const candidates = libraryItems.map(item => {
const normalizedItemTitle = normalizeTitle(item.title);
const normalizedItemAuthor = item.author.toLowerCase();
const titleScore = compareTwoStrings(normalizedRequestTitle, normalizedItemTitle);
const authorScore = compareTwoStrings(normalizedRequestAuthor, normalizedItemAuthor);
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return { item, titleScore, authorScore, score: overallScore };
});
// Sort by score and get best match
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Accept if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'fuzzy',
input: { title: request.title, author: request.author },
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
overall: Math.round(bestMatch.score * 100)
},
confidence: Math.round(bestMatch.score * 100)
}
}));
}
return bestMatch.item;
}
// No match found
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'no_match',
input: { title: request.title, author: request.author },
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
threshold: 70
}
}));
}
return null;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Component: Class Name Utility
* Documentation: documentation/frontend/components.md
*/
import clsx, { ClassValue } from 'clsx';
/**
* Utility for merging Tailwind CSS classes
* Handles conditional classes and removes duplicates
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Component: Cron Utilities
* Documentation: documentation/backend/services/scheduler.md
*/
export interface SchedulePreset {
label: string;
cron: string;
description: string;
}
export const SCHEDULE_PRESETS: SchedulePreset[] = [
{ label: 'Every 15 minutes', cron: '*/15 * * * *', description: 'Runs 4 times per hour' },
{ label: 'Every 30 minutes', cron: '*/30 * * * *', description: 'Runs twice per hour' },
{ label: 'Every hour', cron: '0 * * * *', description: 'Runs at the start of every hour' },
{ label: 'Every 2 hours', cron: '0 */2 * * *', description: 'Runs 12 times per day' },
{ label: 'Every 3 hours', cron: '0 */3 * * *', description: 'Runs 8 times per day' },
{ label: 'Every 6 hours', cron: '0 */6 * * *', description: 'Runs 4 times per day' },
{ label: 'Every 12 hours', cron: '0 */12 * * *', description: 'Runs twice per day' },
{ label: 'Daily at midnight', cron: '0 0 * * *', description: 'Runs once per day at 12:00 AM' },
{ label: 'Daily at noon', cron: '0 12 * * *', description: 'Runs once per day at 12:00 PM' },
{ label: 'Daily at 6 AM', cron: '0 6 * * *', description: 'Runs once per day at 6:00 AM' },
{ label: 'Weekly (Sunday midnight)', cron: '0 0 * * 0', description: 'Runs once per week' },
{ label: 'Monthly (1st at midnight)', cron: '0 0 1 * *', description: 'Runs once per month' },
];
/**
* Converts a cron expression to a human-readable description
* @param cron - The cron expression (e.g., "0 *\/6 * * *")
* @returns Human-readable description (e.g., "Every 6 hours")
*/
export function cronToHuman(cron: string): string {
// Check if it matches a preset
const preset = SCHEDULE_PRESETS.find(p => p.cron === cron);
if (preset) {
return preset.label;
}
// Parse the cron expression
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) {
return cron; // Invalid cron, return as-is
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Minutes pattern
if (minute.startsWith('*/')) {
const interval = parseInt(minute.substring(2), 10);
return `Every ${interval} minute${interval !== 1 ? 's' : ''}`;
}
// Hours pattern
if (hour.startsWith('*/') && minute === '0') {
const interval = parseInt(hour.substring(2), 10);
return `Every ${interval} hour${interval !== 1 ? 's' : ''}`;
}
// Daily pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
if (hour === '*') {
if (minute === '0') {
return 'Every hour';
}
const minInterval = parseInt(minute.replace('*/', ''), 10);
return `Every ${minInterval} minutes`;
}
const hourNum = parseInt(hour, 10);
if (!isNaN(hourNum)) {
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Daily at ${time}`;
}
}
// Weekly pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const days = parseDayOfWeek(dayOfWeek);
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Weekly on ${days} at ${time}`;
}
// Monthly pattern
if (month === '*' && dayOfWeek === '*' && dayOfMonth !== '*') {
const day = parseInt(dayOfMonth, 10);
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Monthly on day ${day} at ${time}`;
}
// Fallback: return the cron expression
return cron;
}
/**
* Parse day of week number to name
*/
function parseDayOfWeek(dayOfWeek: string): string {
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const day = parseInt(dayOfWeek, 10);
if (!isNaN(day) && day >= 0 && day <= 6) {
return dayNames[day];
}
return dayOfWeek;
}
/**
* Format hour and minute to 12-hour time
*/
function formatTime(hour: number, minute: number): string {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
const displayMinute = minute.toString().padStart(2, '0');
return `${displayHour}:${displayMinute} ${period}`;
}
/**
* Validates a cron expression
* @param cron - The cron expression to validate
* @returns true if valid, false otherwise
*/
export function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return false;
}
// Basic validation - each part should be valid
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
return (
isValidCronField(minute, 0, 59) &&
isValidCronField(hour, 0, 23) &&
isValidCronField(dayOfMonth, 1, 31) &&
isValidCronField(month, 1, 12) &&
isValidCronField(dayOfWeek, 0, 7)
);
}
/**
* Validates a single cron field
*/
function isValidCronField(field: string, min: number, max: number): boolean {
// Asterisk is always valid
if (field === '*') {
return true;
}
// Step values (*/n)
if (field.startsWith('*/')) {
const step = parseInt(field.substring(2), 10);
return !isNaN(step) && step > 0 && step <= max;
}
// Range (n-m)
if (field.includes('-')) {
const [start, end] = field.split('-').map(s => parseInt(s, 10));
return !isNaN(start) && !isNaN(end) && start >= min && end <= max && start < end;
}
// List (n,m,o)
if (field.includes(',')) {
const values = field.split(',').map(s => parseInt(s, 10));
return values.every(v => !isNaN(v) && v >= min && v <= max);
}
// Single value
const value = parseInt(field, 10);
return !isNaN(value) && value >= min && value <= max;
}
/**
* Custom schedule builder
*/
export interface CustomSchedule {
type: 'minutes' | 'hours' | 'daily' | 'weekly' | 'monthly' | 'custom';
interval?: number; // For minutes/hours
time?: { hour: number; minute: number }; // For daily/weekly/monthly
dayOfWeek?: number; // For weekly (0-6)
dayOfMonth?: number; // For monthly (1-31)
customCron?: string; // For custom
}
/**
* Converts custom schedule to cron expression
*/
export function customScheduleToCron(schedule: CustomSchedule): string {
switch (schedule.type) {
case 'minutes':
return `*/${schedule.interval || 15} * * * *`;
case 'hours':
const hourInterval = schedule.interval || 1;
// If interval is 24 or more hours, convert to daily at midnight
if (hourInterval >= 24) {
return `0 0 * * *`; // Daily at midnight
}
return `0 */${hourInterval} * * *`;
case 'daily':
const dailyHour = schedule.time?.hour || 0;
const dailyMinute = schedule.time?.minute || 0;
return `${dailyMinute} ${dailyHour} * * *`;
case 'weekly':
const weeklyHour = schedule.time?.hour || 0;
const weeklyMinute = schedule.time?.minute || 0;
const weeklyDay = schedule.dayOfWeek || 0;
return `${weeklyMinute} ${weeklyHour} * * ${weeklyDay}`;
case 'monthly':
const monthlyHour = schedule.time?.hour || 0;
const monthlyMinute = schedule.time?.minute || 0;
const monthlyDay = schedule.dayOfMonth || 1;
return `${monthlyMinute} ${monthlyHour} ${monthlyDay} * *`;
case 'custom':
return schedule.customCron || '0 * * * *';
default:
return '0 * * * *';
}
}
/**
* Attempts to parse a cron expression into a custom schedule
*/
export function cronToCustomSchedule(cron: string): CustomSchedule {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { type: 'custom', customCron: cron };
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Minutes pattern
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const interval = parseInt(minute.substring(2), 10);
return { type: 'minutes', interval };
}
// Hours pattern
if (hour.startsWith('*/') && minute === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const interval = parseInt(hour.substring(2), 10);
return { type: 'hours', interval };
}
// Daily pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
return { type: 'daily', time: { hour: hourNum, minute: minuteNum } };
}
}
// Weekly pattern
if (dayOfMonth === '*' && month === '*' && !dayOfWeek.includes('*') && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfWeek, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return { type: 'weekly', time: { hour: hourNum, minute: minuteNum }, dayOfWeek: dayNum };
}
}
// Monthly pattern
if (month === '*' && dayOfWeek === '*' && !dayOfMonth.includes('*') && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfMonth, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return { type: 'monthly', time: { hour: hourNum, minute: minuteNum }, dayOfMonth: dayNum };
}
}
// Fallback to custom
return { type: 'custom', customCron: cron };
}
+479
View File
@@ -0,0 +1,479 @@
/**
* Component: File Organization System
* Documentation: documentation/phase3/file-organization.md
*/
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { prisma } from '../db';
export interface AudiobookMetadata {
title: string;
author: string;
narrator?: string;
year?: number;
coverArtUrl?: string;
}
export interface OrganizationResult {
success: boolean;
targetPath: string;
filesMovedCount: number;
errors: string[];
audioFiles: string[];
coverArtFile?: string;
}
export interface ValidationResult {
isValid: boolean;
issues: string[];
path: string;
}
export interface LoggerConfig {
jobId: string;
context: string;
}
export class FileOrganizer {
private mediaDir: string;
private tempDir: string;
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook') {
this.mediaDir = mediaDir;
this.tempDir = tempDir;
}
/**
* Organize completed download into proper directory structure
*/
async organize(
downloadPath: string,
audiobook: AudiobookMetadata,
loggerConfig?: LoggerConfig
): Promise<OrganizationResult> {
// Create logger if config provided
const logger = loggerConfig ? createJobLogger(loggerConfig.jobId, loggerConfig.context) : null;
const result: OrganizationResult = {
success: false,
targetPath: '',
filesMovedCount: 0,
errors: [],
audioFiles: [],
};
try {
await logger?.info(`Organizing: ${downloadPath}`);
// Find audiobook files
const { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath);
if (audioFiles.length === 0) {
throw new Error('No audiobook files found in download');
}
await logger?.info(`Found ${audioFiles.length} audio files`);
// Determine base path for source files
const baseSourcePath = isFile ? path.dirname(downloadPath) : downloadPath;
// Tag metadata BEFORE moving files (prevents Plex race condition)
// Map from original file path to tagged file path (for successful tags)
const taggedFileMap = new Map<string, string>();
try {
const config = await prisma.configuration.findUnique({
where: { key: 'metadata_tagging_enabled' },
});
const metadataTaggingEnabled = config?.value === 'true';
if (metadataTaggingEnabled && audioFiles.length > 0) {
await logger?.info(`Metadata tagging enabled, checking ffmpeg availability...`);
const ffmpegAvailable = await checkFfmpegAvailable();
if (ffmpegAvailable) {
await logger?.info(`Tagging ${audioFiles.length} audio files with metadata (before move)...`);
// Build full paths to source files for tagging
const sourceFilePaths = audioFiles.map((audioFile) =>
isFile ? downloadPath : path.join(downloadPath, audioFile)
);
const taggingResults = await tagMultipleFiles(sourceFilePaths, {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
year: audiobook.year,
});
const successCount = taggingResults.filter((r) => r.success).length;
const failCount = taggingResults.filter((r) => !r.success).length;
if (successCount > 0) {
await logger?.info(`Successfully tagged ${successCount} file(s) with metadata`);
}
if (failCount > 0) {
await logger?.warn(`Failed to tag ${failCount} file(s): ${
taggingResults
.filter((r) => !r.success)
.map((r) => `${path.basename(r.filePath)}: ${r.error}`)
.join(', ')
}`);
result.errors.push(`Failed to tag ${failCount} file(s) with metadata`);
}
// Build map of successfully tagged files
for (const tagResult of taggingResults) {
if (tagResult.success && tagResult.taggedFilePath) {
taggedFileMap.set(tagResult.filePath, tagResult.taggedFilePath);
}
}
} else {
await logger?.warn(`Metadata tagging enabled but ffmpeg not available - skipping tagging`);
result.errors.push('Metadata tagging skipped: ffmpeg not available');
}
} else {
await logger?.info(`Metadata tagging disabled or no audio files to tag`);
}
} catch (error) {
await logger?.error(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the whole operation if metadata tagging fails - continue with copying files
}
// Build target directory
const targetPath = this.buildTargetPath(
this.mediaDir,
audiobook.author,
audiobook.title,
audiobook.year
);
await logger?.info(`Target path: ${targetPath}`);
// Create target directory
await fs.mkdir(targetPath, { recursive: true });
// Copy audio files (do NOT delete originals - needed for seeding)
for (const audioFile of audioFiles) {
const originalSourcePath = isFile ? downloadPath : path.join(downloadPath, audioFile);
const filename = path.basename(audioFile);
const targetFilePath = path.join(targetPath, filename);
// Check if we have a tagged version of this file
const taggedFilePath = taggedFileMap.get(originalSourcePath);
const sourcePath = taggedFilePath || originalSourcePath; // Use tagged version if available, otherwise use original
// Check if source exists
try {
await fs.access(sourcePath, fs.constants.R_OK);
} catch {
console.warn(`[FileOrganizer] Source file not found or not readable: ${sourcePath}`);
result.errors.push(`Source file not found: ${audioFile}`);
continue;
}
// Check if target already exists (skip if already copied)
try {
await fs.access(targetFilePath);
console.log(`[FileOrganizer] File already exists, skipping: ${filename}`);
result.audioFiles.push(targetFilePath);
// Clean up tagged temp file if it exists
if (taggedFilePath) {
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
}
continue;
} catch {
// File doesn't exist, continue with copy
}
// Copy file (do NOT delete original - needed for seeding)
try {
// Read source file (either tagged version or original)
const fileData = await fs.readFile(sourcePath);
// Write to target with explicit permissions
await fs.writeFile(targetFilePath, fileData, { mode: 0o644 });
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
if (taggedFilePath) {
await logger?.info(`Copied tagged file: ${filename}`);
// Clean up the tagged temp file after successful copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`);
} catch (cleanupError) {
await logger?.warn(`Failed to clean up temp file: ${path.basename(taggedFilePath)}`);
}
} else {
await logger?.info(`Copied: ${filename}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
}
// Handle cover art
if (coverFile) {
const sourcePath = path.join(baseSourcePath, coverFile);
const targetCoverPath = path.join(targetPath, 'cover.jpg');
try {
// Copy cover art (do NOT delete original)
const coverData = await fs.readFile(sourcePath);
await fs.writeFile(targetCoverPath, coverData, { mode: 0o644 });
result.coverArtFile = targetCoverPath;
result.filesMovedCount++;
await logger?.info(`Copied cover art`);
} catch (error) {
await logger?.warn(`Failed to copy cover art: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push('Failed to copy cover art');
}
} else if (audiobook.coverArtUrl) {
// Download cover art from Audible if not in torrent
try {
await this.downloadCoverArt(audiobook.coverArtUrl, targetPath);
result.coverArtFile = path.join(targetPath, 'cover.jpg');
await logger?.info(`Downloaded cover art from Audible`);
} catch (error) {
await logger?.warn(`Failed to download cover art: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push('Failed to download cover art');
}
}
result.targetPath = targetPath;
result.success = true;
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
await logger?.info(`Organization complete: ${result.filesMovedCount} files copied (originals kept for seeding)`);
return result;
} catch (error) {
await logger?.error(`Organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
return result;
}
}
/**
* Find audiobook files in download directory or single file
*/
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
/art\.(jpg|jpeg|png)$/i,
];
const audioFiles: string[] = [];
let coverFile: string | undefined;
let isFile = false;
try {
// Check if downloadPath is a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isFile()) {
// Handle single file case
isFile = true;
const ext = path.extname(downloadPath).toLowerCase();
if (audioExtensions.includes(ext)) {
// Return just the filename (not full path)
audioFiles.push(path.basename(downloadPath));
}
} else {
// Handle directory case
const files = await this.walkDirectory(downloadPath);
for (const file of files) {
const ext = path.extname(file).toLowerCase();
// Check if it's an audio file
if (audioExtensions.includes(ext)) {
audioFiles.push(file);
}
// Check if it's cover art
const basename = path.basename(file);
if (coverPatterns.some((pattern) => pattern.test(basename))) {
coverFile = file;
}
}
}
} catch (error) {
console.error('[FileOrganizer] Error reading directory:', error);
throw error;
}
return { audioFiles, coverFile, isFile };
}
/**
* Recursively walk directory to find all files
*/
private async walkDirectory(dir: string, baseDir: string = ''): Promise<string[]> {
const files: string[] = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = baseDir ? path.join(baseDir, entry.name) : entry.name;
if (entry.isDirectory()) {
const subFiles = await this.walkDirectory(fullPath, relativePath);
files.push(...subFiles);
} else {
files.push(relativePath);
}
}
} catch (error) {
console.error(`[FileOrganizer] Error reading directory ${dir}:`, error);
}
return files;
}
/**
* Build target path with sanitized names
*/
private buildTargetPath(
baseDir: string,
author: string,
title: string,
year?: number
): string {
const authorClean = this.sanitizePath(author);
const titleClean = this.sanitizePath(title);
const folderName = year ? `${titleClean} (${year})` : titleClean;
return path.join(baseDir, authorClean, folderName);
}
/**
* Sanitize path component (remove invalid characters)
*/
private sanitizePath(name: string): string {
return (
name
// Remove invalid filename characters
.replace(/[<>:"/\\|?*]/g, '')
// Remove leading/trailing dots and spaces
.trim()
.replace(/^\.+/, '')
.replace(/\.+$/, '')
// Collapse multiple spaces
.replace(/\s+/g, ' ')
// Limit length (255 chars max for most filesystems)
.slice(0, 200)
);
}
/**
* Download cover art from URL
*/
private async downloadCoverArt(url: string, targetDir: string): Promise<void> {
const targetPath = path.join(targetDir, 'cover.jpg');
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
});
await fs.writeFile(targetPath, response.data);
} catch (error) {
console.error('[FileOrganizer] Failed to download cover art:', error);
throw error;
}
}
/**
* Clean up download directory
*/
async cleanup(downloadPath: string): Promise<void> {
try {
// Remove download directory and all remaining files
await fs.rm(downloadPath, { recursive: true, force: true });
console.log(`[FileOrganizer] Cleaned up: ${downloadPath}`);
} catch (error) {
console.error(`[FileOrganizer] Cleanup failed for ${downloadPath}:`, error);
// Don't throw - cleanup is non-critical
}
}
/**
* Validate directory structure
*/
async validate(basePath: string): Promise<ValidationResult> {
const result: ValidationResult = {
isValid: true,
issues: [],
path: basePath,
};
try {
// Check if base path exists
await fs.access(basePath);
// Check if it's a directory
const stats = await fs.stat(basePath);
if (!stats.isDirectory()) {
result.isValid = false;
result.issues.push('Path is not a directory');
}
// Check if writable
try {
const testFile = path.join(basePath, '.test-write');
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
} catch {
result.isValid = false;
result.issues.push('Directory is not writable');
}
} catch (error) {
result.isValid = false;
result.issues.push(`Path does not exist or is not accessible: ${basePath}`);
}
return result;
}
}
// Singleton instance
let fileOrganizer: FileOrganizer | null = null;
export function getFileOrganizer(): FileOrganizer {
if (!fileOrganizer) {
const mediaDir = process.env.MEDIA_DIR || '/media/audiobooks';
const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook';
fileOrganizer = new FileOrganizer(mediaDir, tempDir);
}
return fileOrganizer;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Component: Job Logger Utility
* Documentation: documentation/backend/services/jobs.md
*
* Provides structured logging for job processors with database persistence
*/
import { prisma } from '../db';
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogMetadata {
[key: string]: any;
}
/**
* Job Logger - Logs events to both console and database
*/
export class JobLogger {
private jobId: string;
private context: string;
constructor(jobId: string, context: string) {
this.jobId = jobId;
this.context = context;
}
/**
* Log info message
*/
async info(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('info', message, metadata);
}
/**
* Log warning message
*/
async warn(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('warn', message, metadata);
}
/**
* Log error message
*/
async error(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('error', message, metadata);
}
/**
* Internal logging method
*/
private async log(level: LogLevel, message: string, metadata?: LogMetadata): Promise<void> {
// Log to console with timestamp (for Docker logs)
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const consoleMessage = `[${this.context}] ${message}`;
switch (level) {
case 'info':
console.log(consoleMessage);
break;
case 'warn':
console.warn(consoleMessage);
break;
case 'error':
console.error(consoleMessage);
break;
}
// Log metadata if provided
if (metadata && Object.keys(metadata).length > 0) {
console.log(timestamp, JSON.stringify(metadata, null, 2));
}
// Persist to database (non-blocking, ignore errors to not break job execution)
try {
await prisma.jobEvent.create({
data: {
jobId: this.jobId,
level,
context: this.context,
message,
metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null,
},
});
} catch (error) {
console.error('[JobLogger] Failed to persist log to database:', error);
// Don't throw - logging failure should not break job execution
}
}
}
/**
* Create a job logger instance
*/
export function createJobLogger(jobId: string, context: string): JobLogger {
return new JobLogger(jobId, context);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Client-Side JWT Utilities
* Documentation: documentation/frontend/routing-auth.md
*/
interface JWTPayload {
sub: string;
plexId: string;
username: string;
role: string;
iat: number;
exp: number;
}
/**
* Decode JWT without verification (client-side only)
* Note: This does NOT verify the signature - only use for reading claims
*/
export function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return decoded as JWTPayload;
} catch (error) {
console.error('Failed to decode JWT:', error);
return null;
}
}
/**
* Check if token is expired
*/
export function isTokenExpired(token: string): boolean {
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) {
return true;
}
const now = Math.floor(Date.now() / 1000);
return decoded.exp < now;
}
/**
* Get milliseconds until token expires
*/
export function getTokenExpiryMs(token: string): number | null {
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) {
return null;
}
const now = Math.floor(Date.now() / 1000);
const expiresIn = decoded.exp - now;
return expiresIn > 0 ? expiresIn * 1000 : 0;
}
/**
* Get milliseconds until token should be refreshed (5 mins before expiry)
*/
export function getRefreshTimeMs(token: string): number | null {
const expiryMs = getTokenExpiryMs(token);
if (expiryMs === null) {
return null;
}
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
const refreshTime = expiryMs - REFRESH_BUFFER_MS;
return refreshTime > 0 ? refreshTime : 0;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Component: JWT Token Utilities
* Documentation: documentation/backend/services/auth.md
*/
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
export interface TokenPayload {
sub: string; // User ID
plexId: string;
username: string;
role: string;
}
export interface RefreshTokenPayload {
sub: string;
type: 'refresh';
}
/**
* Generate access token (short-lived)
*/
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY,
});
}
/**
* Generate refresh token (long-lived)
*/
export function generateRefreshToken(userId: string): string {
const payload: RefreshTokenPayload = {
sub: userId,
type: 'refresh',
};
return jwt.sign(payload, JWT_REFRESH_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRY,
});
}
/**
* Verify access token
*/
export function verifyAccessToken(token: string): TokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as TokenPayload;
return decoded;
} catch (error) {
console.error('[JWT] Access token verification failed:', error);
if (error instanceof Error) {
console.error('[JWT] Error details:', error.message);
}
return null;
}
}
/**
* Verify refresh token
*/
export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_REFRESH_SECRET) as RefreshTokenPayload;
if (decoded.type !== 'refresh') {
return null;
}
return decoded;
} catch (error) {
console.error('Refresh token verification failed:', error);
return null;
}
}
/**
* Decode token without verification (for debugging)
*/
export function decodeToken(token: string): any {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Component: Metadata Tagging Utility
* Documentation: documentation/phase3/file-organization.md
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
const execPromise = promisify(exec);
export interface MetadataTaggingOptions {
title: string;
author: string;
narrator?: string;
year?: number;
}
export interface TaggingResult {
success: boolean;
filePath: string; // Original file path
taggedFilePath?: string; // Path to tagged file (if successful)
error?: string;
}
/**
* Tag audio file metadata using ffmpeg
* Supports m4b and mp3 files
* Uses -codec copy for lossless operation (metadata only, no re-encoding)
*/
export async function tagAudioFileMetadata(
filePath: string,
metadata: MetadataTaggingOptions
): Promise<TaggingResult> {
try {
// Check if file exists
await fs.access(filePath);
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
return {
success: false,
filePath,
error: `Unsupported file format: ${ext}`,
};
}
// Create temporary file path
const tempFile = `${filePath}.tmp`;
// Build ffmpeg command
const args: string[] = [
'ffmpeg',
'-i', `"${filePath}"`,
'-codec', 'copy', // No re-encoding, metadata only
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
'-metadata', `album_artist="${escapeMetadata(metadata.author)}"`, // Author in Album Artist (PRIMARY for Plex)
'-metadata', `artist="${escapeMetadata(metadata.author)}"` // Fallback
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`); // Narrator in Composer
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `album_artist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
// For MP3, composer is also used for narrator
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp3');
}
// Output to temp file
args.push(`"${tempFile}"`);
// Execute ffmpeg command
const command = args.join(' ');
try {
await execPromise(command, { timeout: 120000 }); // 2 minute timeout
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempFile);
} catch {
// Ignore cleanup errors
}
throw new Error(`ffmpeg failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// DO NOT replace original file - return temp file path instead
// This preserves the original file for seeding
return {
success: true,
filePath,
taggedFilePath: tempFile,
};
} catch (error) {
return {
success: false,
filePath,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Tag multiple audio files with metadata
*/
export async function tagMultipleFiles(
filePaths: string[],
metadata: MetadataTaggingOptions
): Promise<TaggingResult[]> {
const results: TaggingResult[] = [];
for (const filePath of filePaths) {
const result = await tagAudioFileMetadata(filePath, metadata);
results.push(result);
}
return results;
}
/**
* Escape metadata values for shell command
* Removes quotes and special characters that could break the command
*/
function escapeMetadata(value: string): string {
return value
.replace(/"/g, '\\"') // Escape double quotes
.replace(/'/g, "\\'") // Escape single quotes
.replace(/`/g, '\\`') // Escape backticks
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/\\/g, '\\\\'); // Escape backslashes
}
/**
* Check if ffmpeg is available
*/
export async function checkFfmpegAvailable(): Promise<boolean> {
try {
await execPromise('ffmpeg -version');
return true;
} catch {
return false;
}
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Component: Intelligent Ranking Algorithm
* Documentation: documentation/phase3/ranking-algorithm.md
*/
import { compareTwoStrings } from 'string-similarity';
export interface TorrentResult {
indexer: string;
title: string;
size: number;
seeders: number;
leechers: number;
publishDate: Date;
downloadUrl: string;
infoHash?: string;
guid: string;
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
bitrate?: string;
hasChapters?: boolean;
}
export interface AudiobookRequest {
title: string;
author: string;
narrator?: string;
durationMinutes?: number;
}
export interface ScoreBreakdown {
formatScore: number;
seederScore: number;
sizeScore: number;
matchScore: number;
totalScore: number;
notes: string[];
}
export interface RankedTorrent extends TorrentResult {
score: number;
rank: number;
breakdown: ScoreBreakdown;
}
export class RankingAlgorithm {
/**
* Rank all torrents and return sorted by score (best first)
*/
rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest
): RankedTorrent[] {
const ranked = torrents.map((torrent) => {
const formatScore = this.scoreFormat(torrent);
const seederScore = this.scoreSeeders(torrent.seeders);
const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
const matchScore = this.scoreMatch(torrent, audiobook);
const totalScore = formatScore + seederScore + sizeScore + matchScore;
return {
...torrent,
score: totalScore,
rank: 0, // Will be assigned after sorting
breakdown: {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: [],
}),
},
};
});
// Sort by score descending (best first)
ranked.sort((a, b) => b.score - a.score);
// Assign ranks
ranked.forEach((r, index) => {
r.rank = index + 1;
});
return ranked;
}
/**
* Get detailed scoring breakdown for a torrent
*/
getScoreBreakdown(
torrent: TorrentResult,
audiobook: AudiobookRequest
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
const seederScore = this.scoreSeeders(torrent.seeders);
const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
const matchScore = this.scoreMatch(torrent, audiobook);
const totalScore = formatScore + seederScore + sizeScore + matchScore;
return {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: [],
}),
};
}
/**
* Score format quality (40 points max)
* M4B with chapters: 40 pts
* M4B without chapters: 35 pts
* M4A: 25 pts
* MP3: 15 pts
* Other: 5 pts
*/
private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent);
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 40 : 35;
case 'M4A':
return 25;
case 'MP3':
return 15;
default:
return 5;
}
}
/**
* Score seeder count (25 points max)
* Logarithmic scaling:
* 1 seeder: 0 points
* 10 seeders: 10 points
* 100 seeders: 20 points
* 1000+ seeders: 25 points
*/
private scoreSeeders(seeders: number): number {
if (seeders === 0) return 0;
return Math.min(25, Math.log10(seeders + 1) * 10);
}
/**
* Score size reasonableness (20 points max)
* Expected: 1-2 MB per minute (64-128 kbps)
* Perfect match: 20 points
* Too small/large: Reduced points
*/
private scoreSize(size: number, durationMinutes?: number): number {
if (!durationMinutes) {
return 10; // Neutral score if duration unknown
}
// Expected size: 1-2 MB per minute
const minExpected = durationMinutes * 1024 * 1024; // 1 MB/min
const maxExpected = durationMinutes * 2 * 1024 * 1024; // 2 MB/min
if (size >= minExpected && size <= maxExpected) {
return 20; // Perfect size
}
// Calculate deviation penalty
const deviation =
size < minExpected
? (minExpected - size) / minExpected
: (size - maxExpected) / maxExpected;
return Math.max(0, 20 - deviation * 20);
}
/**
* Score title/author match quality (15 points max)
* Title similarity: 0-10 points
* Author presence: 0-5 points
*/
private scoreMatch(
torrent: TorrentResult,
audiobook: AudiobookRequest
): number {
const title = torrent.title.toLowerCase();
const requestTitle = audiobook.title.toLowerCase();
const requestAuthor = audiobook.author.toLowerCase();
// Title similarity (0-10 points)
const titleSimilarity = compareTwoStrings(requestTitle, title) * 10;
// Author presence (0-5 points)
const hasAuthor = title.includes(requestAuthor) ? 5 : 0;
return Math.min(15, titleSimilarity + hasAuthor);
}
/**
* Detect format from torrent title
*/
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
// Use explicit format if provided
if (torrent.format) {
return torrent.format;
}
const title = torrent.title.toUpperCase();
// Check for format keywords in title
if (title.includes('M4B')) return 'M4B';
if (title.includes('M4A')) return 'M4A';
if (title.includes('MP3')) return 'MP3';
// Default to OTHER if no format detected
return 'OTHER';
}
/**
* Generate human-readable notes about scoring
*/
private generateNotes(
torrent: TorrentResult,
breakdown: ScoreBreakdown
): string[] {
const notes: string[] = [];
// Format notes
const format = this.detectFormat(torrent);
if (format === 'M4B') {
notes.push('Excellent format (M4B)');
if (torrent.hasChapters !== false) {
notes.push('Has chapter markers');
}
} else if (format === 'M4A') {
notes.push('Good format (M4A)');
} else if (format === 'MP3') {
notes.push('Acceptable format (MP3)');
} else {
notes.push('Unknown or uncommon format');
}
// Seeder notes
if (torrent.seeders === 0) {
notes.push('⚠️ No seeders available');
} else if (torrent.seeders < 5) {
notes.push(`Low seeders (${torrent.seeders})`);
} else if (torrent.seeders >= 50) {
notes.push(`Excellent availability (${torrent.seeders} seeders)`);
}
// Size notes
if (breakdown.sizeScore < 10) {
notes.push('⚠️ Unusual file size');
}
// Match notes
if (breakdown.matchScore < 8) {
notes.push('⚠️ Title/author may not match well');
}
// Overall quality assessment
if (breakdown.totalScore >= 80) {
notes.push('✓ Excellent choice');
} else if (breakdown.totalScore >= 60) {
notes.push('✓ Good choice');
} else if (breakdown.totalScore < 40) {
notes.push('⚠️ Consider reviewing this choice');
}
return notes;
}
}
// Singleton instance
let ranker: RankingAlgorithm | null = null;
export function getRankingAlgorithm(): RankingAlgorithm {
if (!ranker) {
ranker = new RankingAlgorithm();
}
return ranker;
}
/**
* Helper function to rank torrents using the singleton instance
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest
): (TorrentResult & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook);
// Return torrents with qualityScore field for compatibility
return ranked.map((r) => ({
...r,
qualityScore: Math.round(r.score),
}));
}
+75
View File
@@ -0,0 +1,75 @@
/**
* URL Utilities for OAuth and Redirects
* Documentation: documentation/backend/services/environment.md
*/
/**
* Get application base URL for OAuth callbacks and redirects
*
* Priority order:
* 1. PUBLIC_URL - Primary documented environment variable
* 2. NEXTAUTH_URL - Legacy fallback for backward compatibility
* 3. BASE_URL - Alternative fallback
* 4. http://localhost:3030 - Development default
*
* @returns Normalized base URL (no trailing slash)
*
* @example
* // With PUBLIC_URL set
* process.env.PUBLIC_URL = 'https://example.com/'
* getBaseUrl() // Returns: 'https://example.com'
*
* // Without any env vars (development)
* getBaseUrl() // Returns: 'http://localhost:3030'
*/
export function getBaseUrl(): string {
const publicUrl = process.env.PUBLIC_URL?.trim();
const nextAuthUrl = process.env.NEXTAUTH_URL?.trim();
const baseUrl = process.env.BASE_URL?.trim();
// Priority: PUBLIC_URL > NEXTAUTH_URL > BASE_URL > localhost
let url = publicUrl || nextAuthUrl || baseUrl || 'http://localhost:3030';
// Normalize: remove trailing slash
url = url.replace(/\/$/, '');
// Validate URL format
if (!url.startsWith('http://') && !url.startsWith('https://')) {
console.warn(`[URL Utility] Invalid base URL format: ${url}. URLs must start with http:// or https://`);
}
// Production warning if using localhost
if (process.env.NODE_ENV === 'production' && url.includes('localhost')) {
console.warn('[URL Utility] ⚠️ WARNING: Using localhost URL in production. OAuth callbacks may fail. Set PUBLIC_URL environment variable.');
}
// Log which variable is being used (debug only)
if (process.env.LOG_LEVEL === 'debug') {
const source = publicUrl ? 'PUBLIC_URL' :
nextAuthUrl ? 'NEXTAUTH_URL' :
baseUrl ? 'BASE_URL' :
'default (localhost)';
console.debug(`[URL Utility] Using base URL from ${source}: ${url}`);
}
return url;
}
/**
* Build full OAuth callback URL
*
* @param path - Callback path (e.g., '/api/auth/oidc/callback')
* @returns Full callback URL
*
* @example
* getCallbackUrl('/api/auth/oidc/callback')
* // Returns: 'https://example.com/api/auth/oidc/callback'
*/
export function getCallbackUrl(path: string): string {
const baseUrl = getBaseUrl();
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}${normalizedPath}`;
}