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