From d617e26c92889845c6ad6c1535b73d15e2d1aa6e Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 24 Dec 2025 23:37:40 -0500 Subject: [PATCH] Add ASIN support to file organization and metadata This update enhances audiobook file organization by including the ASIN in folder names and embedding it as a custom metadata tag in audio files (M4B/M4A/MP3). Documentation is updated to reflect the new folder naming convention and metadata tagging. Additionally, local login and registration can now be disabled via an environment variable, and the interactive torrent search modal allows custom search titles for all modes. --- documentation/phase3/file-organization.md | 31 +++++++-- src/app/api/auth/admin/login/route.ts | 8 +++ src/app/api/auth/local/login/route.ts | 8 +++ src/app/api/auth/providers/route.ts | 12 +++- src/app/api/auth/register/route.ts | 8 +++ .../requests/[id]/interactive-search/route.ts | 22 ++++++- src/app/login/page.tsx | 4 +- .../InteractiveTorrentSearchModal.tsx | 64 ++++++++----------- src/lib/hooks/useRequests.ts | 6 +- .../processors/organize-files.processor.ts | 1 + src/lib/utils/file-organizer.ts | 21 +++++- src/lib/utils/metadata-tagger.ts | 11 ++++ 12 files changed, 145 insertions(+), 51 deletions(-) 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'); }