diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index de5df98..8927d95 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -11,11 +11,21 @@ Target directory read from database config `media_dir` (configurable in setup wi ``` [media_dir]/ └── Author Name/ - └── Book Title (Year)/ + └── Book Title (Year) ASIN/ ├── Book Title.m4b └── cover.jpg ``` +**Folder naming format:** +- With year and ASIN: `Book Title (Year) ASIN` +- With ASIN only: `Book Title ASIN` +- With year only: `Book Title (Year)` +- Fallback: `Book Title` + +**Example:** `Douglas Adams/The Hitchhiker's Guide to the Galaxy (2005) B0009JKV9W/` + +**Rationale:** Including ASIN in folder name improves Plex/Audnexus agent matching accuracy. + Default: `/media/audiobooks/` (if not configured) ## Process @@ -23,9 +33,9 @@ Default: `/media/audiobooks/` (if not configured) 1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file) 2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files 3. Read media directory from database config `media_dir` -4. Create `[media_dir]/[Author]/[Title]/` +4. Create `[media_dir]/[Author]/[Title (Year) ASIN]/` 5. **Copy** files (not move - originals stay for seeding) -6. **Tag metadata** (if enabled) - writes correct title, author, narrator to audio files +6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files 7. Copy cover art if found, else download from Audible 8. Originals remain until seeding requirements met @@ -46,6 +56,18 @@ Default: `/media/audiobooks/` (if not configured) - `artist` - Author (fallback) - `composer` - Narrator (standard audiobook field) - `date` - Year +- `ASIN` - Audible ASIN (custom tag) + - M4B/M4A/MP4: `----:com.apple.iTunes:ASIN` + - MP3: Custom ID3v2 tag + +**Note:** ASIN is a custom metadata tag and may not appear in standard file properties viewers (Windows/macOS/Linux). Use specialized tools to verify: +```bash +# Verify ASIN metadata with ffprobe +ffprobe -v quiet -print_format json -show_format "audiobook.m4b" | grep -i asin + +# Or use exiftool +exiftool "audiobook.m4b" | grep -i asin +``` **Configuration:** - Key: `metadata_tagging_enabled` (Configuration table) @@ -65,6 +87,7 @@ Default: `/media/audiobooks/` (if not configured) - Ensures Plex can match audiobooks correctly - Writes metadata from Audible/Audnexus (known accurate) - Prevents "[Various Albums]" and other metadata issues +- Embeds ASIN directly in audio files for better identification and matching **Tech Stack:** - ffmpeg (system dependency - included in Docker image) @@ -103,7 +126,7 @@ interface OrganizationResult { async function organize( downloadPath: string, - audiobook: {title: string, author: string, year?: number, coverArtUrl?: string} + audiobook: {title: string, author: string, year?: number, coverArtUrl?: string, asin?: string} ): Promise; ``` diff --git a/src/app/api/auth/admin/login/route.ts b/src/app/api/auth/admin/login/route.ts index 2979746..cafe63e 100644 --- a/src/app/api/auth/admin/login/route.ts +++ b/src/app/api/auth/admin/login/route.ts @@ -15,6 +15,14 @@ import { getEncryptionService } from '@/lib/services/encryption.service'; */ export async function POST(request: NextRequest) { try { + // Check if local login is disabled + if (process.env.DISABLE_LOCAL_LOGIN === 'true') { + return NextResponse.json( + { error: 'Local login is disabled' }, + { status: 403 } + ); + } + const { username, password } = await request.json(); if (!username || !password) { diff --git a/src/app/api/auth/local/login/route.ts b/src/app/api/auth/local/login/route.ts index 3e9ccc0..ed4148a 100644 --- a/src/app/api/auth/local/login/route.ts +++ b/src/app/api/auth/local/login/route.ts @@ -8,6 +8,14 @@ import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider'; export async function POST(request: NextRequest) { try { + // Check if local login is disabled + if (process.env.DISABLE_LOCAL_LOGIN === 'true') { + return NextResponse.json( + { error: 'Local login is disabled' }, + { status: 403 } + ); + } + const { username, password } = await request.json(); if (!username || !password) { diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts index b66e095..37e9cb5 100644 --- a/src/app/api/auth/providers/route.ts +++ b/src/app/api/auth/providers/route.ts @@ -12,6 +12,9 @@ export async function GET() { const configService = new ConfigurationService(); const backendMode = await configService.get('system.backend_mode'); + // Check if local login is disabled via environment variable + const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; + if (backendMode === 'audiobookshelf') { // Audiobookshelf mode - check which auth methods are enabled const oidcEnabled = (await configService.get('oidc.enabled')) === 'true'; @@ -25,14 +28,16 @@ export async function GET() { const providers: string[] = []; if (oidcEnabled) providers.push('oidc'); - if (hasLocalUsers) providers.push('local'); + // Only add 'local' provider if not disabled and users exist + if (hasLocalUsers && !localLoginDisabled) providers.push('local'); return NextResponse.json({ backendMode: 'audiobookshelf', providers, - registrationEnabled, + registrationEnabled: !localLoginDisabled && registrationEnabled, hasLocalUsers, oidcProviderName: oidcEnabled ? oidcProviderName : null, + localLoginDisabled, }); } else { // Plex mode - check if local admin exists (setup admin) @@ -49,17 +54,20 @@ export async function GET() { registrationEnabled: false, hasLocalUsers, oidcProviderName: null, + localLoginDisabled, }); } } catch (error) { console.error('[Auth] Failed to fetch auth providers:', error); // Default to Plex mode if config can't be read + const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; return NextResponse.json({ backendMode: 'plex', providers: ['plex'], registrationEnabled: false, hasLocalUsers: false, oidcProviderName: null, + localLoginDisabled, }); } } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index d239b86..4e31108 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -29,6 +29,14 @@ function checkRateLimit(ip: string): boolean { } export async function POST(request: NextRequest) { + // Check if local login is disabled + if (process.env.DISABLE_LOCAL_LOGIN === 'true') { + return NextResponse.json( + { error: 'Local registration is disabled' }, + { status: 403 } + ); + } + // Rate limiting const ip = request.headers.get('x-forwarded-for') || 'unknown'; if (!checkRateLimit(ip)) { diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index fd2fc09..819b99e 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -12,6 +12,7 @@ import { rankTorrents } from '@/lib/utils/ranking-algorithm'; /** * POST /api/requests/[id]/interactive-search * Search for torrents and return results for user selection + * Body (optional): { customTitle?: string } */ export async function POST( request: NextRequest, @@ -28,6 +29,15 @@ export async function POST( const { id } = await params; + // Parse optional request body + let customTitle: string | undefined; + try { + const body = await req.json(); + customTitle = body.customTitle; + } catch (e) { + // No body or invalid JSON - that's okay, customTitle will be undefined + } + const requestRecord = await prisma.request.findUnique({ where: { id }, include: { @@ -74,9 +84,13 @@ export async function POST( // Search Prowlarr for torrents - ONLY enabled indexers const prowlarr = await getProwlarrService(); - const searchQuery = requestRecord.audiobook.title; // Title only - cast wide net + // Use custom title if provided, otherwise use audiobook's title + const searchQuery = customTitle || requestRecord.audiobook.title; console.log(`[InteractiveSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`); + if (customTitle) { + console.log(`[InteractiveSearch] Using custom search title (original: "${requestRecord.audiobook.title}")`); + } const results = await prowlarr.search(searchQuery, { indexerIds: enabledIndexerIds, @@ -94,6 +108,7 @@ export async function POST( } // Rank torrents using the ranking algorithm + // Always use the audiobook's title/author for ranking (not custom search query) const rankedResults = rankTorrents(results, { title: requestRecord.audiobook.title, author: requestRecord.audiobook.author, @@ -108,8 +123,9 @@ export async function POST( const top3 = filteredResults.slice(0, 3); if (top3.length > 0) { console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`); - console.log(`[InteractiveSearch] Requested Title: "${requestRecord.audiobook.title}"`); - console.log(`[InteractiveSearch] Requested Author: "${requestRecord.audiobook.author}"`); + console.log(`[InteractiveSearch] Search Query: "${searchQuery}"`); + console.log(`[InteractiveSearch] Requested Title (for ranking): "${requestRecord.audiobook.title}"`); + console.log(`[InteractiveSearch] Requested Author (for ranking): "${requestRecord.audiobook.author}"`); console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`); console.log(`[InteractiveSearch] --------------------------------------------------------`); top3.forEach((result, index) => { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 571dd50..bf73fdd 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -37,6 +37,7 @@ function LoginContent() { registrationEnabled: boolean; hasLocalUsers: boolean; oidcProviderName: string | null; + localLoginDisabled: boolean; } | null>(null); const [showRegisterForm, setShowRegisterForm] = useState(false); const [registerUsername, setRegisterUsername] = useState(''); @@ -75,6 +76,7 @@ function LoginContent() { registrationEnabled: false, hasLocalUsers: false, oidcProviderName: null, + localLoginDisabled: false, }); } }; @@ -727,7 +729,7 @@ function LoginContent() { )} {/* Admin Login toggle for Plex mode */} - {authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && ( + {authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && !authProviders.localLoginDisabled && ( <>
diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 4ed2848..589c0d9 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -73,8 +73,9 @@ export function InteractiveTorrentSearchModal({ try { let data; if (hasRequestId) { - // Existing flow: search by requestId (cannot customize search term) - data = await searchByRequestId(requestId); + // Existing flow: search by requestId with optional custom title + const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; + data = await searchByRequestId(requestId, customTitle); } else { // New flow: search by custom title + original author data = await searchByAudiobook(searchTitle, audiobook.author); @@ -140,42 +141,31 @@ export function InteractiveTorrentSearchModal({ <>
- {/* Search customization */} + {/* Search customization - editable for ALL modes */}
- {hasRequestId ? ( - // Existing request: show static title (cannot customize) - <> -

{audiobook.title}

-

By {audiobook.author}

- - ) : ( - // New search: allow title customization - <> - -
- setSearchTitle(e.target.value)} - onKeyPress={handleSearchKeyPress} - placeholder="Enter book title to search..." - disabled={isSearching} - className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" - /> - -
-

By {audiobook.author}

- - )} + +
+ setSearchTitle(e.target.value)} + onKeyPress={handleSearchKeyPress} + placeholder="Enter book title to search..." + disabled={isSearching} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" + /> + +
+

By {audiobook.author}

{/* Error message */} diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts index 294397a..1af4713 100644 --- a/src/lib/hooks/useRequests.ts +++ b/src/lib/hooks/useRequests.ts @@ -216,7 +216,7 @@ export function useInteractiveSearch() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const searchTorrents = async (requestId: string) => { + const searchTorrents = async (requestId: string, customTitle?: string) => { if (!accessToken) { throw new Error('Not authenticated'); } @@ -227,6 +227,10 @@ export function useInteractiveSearch() { try { const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: customTitle ? JSON.stringify({ customTitle }) : undefined, }); const data = await response.json(); diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 885f2db..526a633 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -54,6 +54,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi author: audiobook.author, narrator: audiobook.narrator || undefined, coverArtUrl: audiobook.coverArtUrl || undefined, + asin: audiobook.audibleAsin || undefined, }, jobId ? { jobId, context: 'FileOrganizer' } : undefined ); diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index c397df6..8936dec 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -16,6 +16,7 @@ export interface AudiobookMetadata { narrator?: string; year?: number; coverArtUrl?: string; + asin?: string; } export interface OrganizationResult { @@ -110,6 +111,7 @@ export class FileOrganizer { author: audiobook.author, narrator: audiobook.narrator, year: audiobook.year, + asin: audiobook.asin, }); const successCount = taggingResults.filter((r) => r.success).length; @@ -153,7 +155,8 @@ export class FileOrganizer { this.mediaDir, audiobook.author, audiobook.title, - audiobook.year + audiobook.year, + audiobook.asin ); await logger?.info(`Target path: ${targetPath}`); @@ -359,16 +362,28 @@ export class FileOrganizer { /** * Build target path with sanitized names + * Format: Author/Title (Year) ASIN or Author/Title ASIN or Author/Title (Year) */ private buildTargetPath( baseDir: string, author: string, title: string, - year?: number + year?: number, + asin?: string ): string { const authorClean = this.sanitizePath(author); const titleClean = this.sanitizePath(title); - const folderName = year ? `${titleClean} (${year})` : titleClean; + + // Build folder name with optional year and ASIN + let folderName = titleClean; + + if (year) { + folderName = `${folderName} (${year})`; + } + + if (asin) { + folderName = `${folderName} ${asin}`; + } return path.join(baseDir, authorClean, folderName); } diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts index b5cb2aa..685d6a5 100644 --- a/src/lib/utils/metadata-tagger.ts +++ b/src/lib/utils/metadata-tagger.ts @@ -15,6 +15,7 @@ export interface MetadataTaggingOptions { author: string; narrator?: string; year?: number; + asin?: string; } export interface TaggingResult { @@ -76,6 +77,11 @@ export async function tagAudioFileMetadata( args.push('-metadata', `date="${metadata.year}"`); } + if (metadata.asin) { + // Use custom iTunes tag format for M4B/M4A/MP4 files + args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(metadata.asin)}"`); + } + // Explicitly specify output format (fixes .tmp extension issue) args.push('-f', 'mp4'); } @@ -97,6 +103,11 @@ export async function tagAudioFileMetadata( args.push('-metadata', `date="${metadata.year}"`); } + if (metadata.asin) { + // Use TXXX frame for custom ID3v2 tags in MP3 files + args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`); + } + // Explicitly specify output format (fixes .tmp extension issue) args.push('-f', 'mp3'); }