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.
This commit is contained in:
kikootwo
2025-12-24 23:37:40 -05:00
parent 1374e66f13
commit d617e26c92
12 changed files with 145 additions and 51 deletions
+27 -4
View File
@@ -11,11 +11,21 @@ Target directory read from database config `media_dir` (configurable in setup wi
``` ```
[media_dir]/ [media_dir]/
└── Author Name/ └── Author Name/
└── Book Title (Year)/ └── Book Title (Year) ASIN/
├── Book Title.m4b ├── Book Title.m4b
└── cover.jpg └── 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) Default: `/media/audiobooks/` (if not configured)
## Process ## Process
@@ -23,9 +33,9 @@ Default: `/media/audiobooks/` (if not configured)
1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file) 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 2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files
3. Read media directory from database config `media_dir` 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) 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 7. Copy cover art if found, else download from Audible
8. Originals remain until seeding requirements met 8. Originals remain until seeding requirements met
@@ -46,6 +56,18 @@ Default: `/media/audiobooks/` (if not configured)
- `artist` - Author (fallback) - `artist` - Author (fallback)
- `composer` - Narrator (standard audiobook field) - `composer` - Narrator (standard audiobook field)
- `date` - Year - `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:** **Configuration:**
- Key: `metadata_tagging_enabled` (Configuration table) - Key: `metadata_tagging_enabled` (Configuration table)
@@ -65,6 +87,7 @@ Default: `/media/audiobooks/` (if not configured)
- Ensures Plex can match audiobooks correctly - Ensures Plex can match audiobooks correctly
- Writes metadata from Audible/Audnexus (known accurate) - Writes metadata from Audible/Audnexus (known accurate)
- Prevents "[Various Albums]" and other metadata issues - Prevents "[Various Albums]" and other metadata issues
- Embeds ASIN directly in audio files for better identification and matching
**Tech Stack:** **Tech Stack:**
- ffmpeg (system dependency - included in Docker image) - ffmpeg (system dependency - included in Docker image)
@@ -103,7 +126,7 @@ interface OrganizationResult {
async function organize( async function organize(
downloadPath: string, downloadPath: string,
audiobook: {title: string, author: string, year?: number, coverArtUrl?: string} audiobook: {title: string, author: string, year?: number, coverArtUrl?: string, asin?: string}
): Promise<OrganizationResult>; ): Promise<OrganizationResult>;
``` ```
+8
View File
@@ -15,6 +15,14 @@ import { getEncryptionService } from '@/lib/services/encryption.service';
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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(); const { username, password } = await request.json();
if (!username || !password) { if (!username || !password) {
+8
View File
@@ -8,6 +8,14 @@ import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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(); const { username, password } = await request.json();
if (!username || !password) { if (!username || !password) {
+10 -2
View File
@@ -12,6 +12,9 @@ export async function GET() {
const configService = new ConfigurationService(); const configService = new ConfigurationService();
const backendMode = await configService.get('system.backend_mode'); 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') { if (backendMode === 'audiobookshelf') {
// Audiobookshelf mode - check which auth methods are enabled // Audiobookshelf mode - check which auth methods are enabled
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true'; const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
@@ -25,14 +28,16 @@ export async function GET() {
const providers: string[] = []; const providers: string[] = [];
if (oidcEnabled) providers.push('oidc'); 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({ return NextResponse.json({
backendMode: 'audiobookshelf', backendMode: 'audiobookshelf',
providers, providers,
registrationEnabled, registrationEnabled: !localLoginDisabled && registrationEnabled,
hasLocalUsers, hasLocalUsers,
oidcProviderName: oidcEnabled ? oidcProviderName : null, oidcProviderName: oidcEnabled ? oidcProviderName : null,
localLoginDisabled,
}); });
} else { } else {
// Plex mode - check if local admin exists (setup admin) // Plex mode - check if local admin exists (setup admin)
@@ -49,17 +54,20 @@ export async function GET() {
registrationEnabled: false, registrationEnabled: false,
hasLocalUsers, hasLocalUsers,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled,
}); });
} }
} catch (error) { } catch (error) {
console.error('[Auth] Failed to fetch auth providers:', error); console.error('[Auth] Failed to fetch auth providers:', error);
// Default to Plex mode if config can't be read // Default to Plex mode if config can't be read
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
return NextResponse.json({ return NextResponse.json({
backendMode: 'plex', backendMode: 'plex',
providers: ['plex'], providers: ['plex'],
registrationEnabled: false, registrationEnabled: false,
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled,
}); });
} }
} }
+8
View File
@@ -29,6 +29,14 @@ function checkRateLimit(ip: string): boolean {
} }
export async function POST(request: NextRequest) { 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 // Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown'; const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) { if (!checkRateLimit(ip)) {
@@ -12,6 +12,7 @@ import { rankTorrents } from '@/lib/utils/ranking-algorithm';
/** /**
* POST /api/requests/[id]/interactive-search * POST /api/requests/[id]/interactive-search
* Search for torrents and return results for user selection * Search for torrents and return results for user selection
* Body (optional): { customTitle?: string }
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
@@ -28,6 +29,15 @@ export async function POST(
const { id } = await params; 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({ const requestRecord = await prisma.request.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -74,9 +84,13 @@ export async function POST(
// Search Prowlarr for torrents - ONLY enabled indexers // Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService(); 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}`); 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, { const results = await prowlarr.search(searchQuery, {
indexerIds: enabledIndexerIds, indexerIds: enabledIndexerIds,
@@ -94,6 +108,7 @@ export async function POST(
} }
// Rank torrents using the ranking algorithm // Rank torrents using the ranking algorithm
// Always use the audiobook's title/author for ranking (not custom search query)
const rankedResults = rankTorrents(results, { const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title, title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
@@ -108,8 +123,9 @@ export async function POST(
const top3 = filteredResults.slice(0, 3); const top3 = filteredResults.slice(0, 3);
if (top3.length > 0) { if (top3.length > 0) {
console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`); console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`);
console.log(`[InteractiveSearch] Requested Title: "${requestRecord.audiobook.title}"`); console.log(`[InteractiveSearch] Search Query: "${searchQuery}"`);
console.log(`[InteractiveSearch] Requested Author: "${requestRecord.audiobook.author}"`); 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] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
console.log(`[InteractiveSearch] --------------------------------------------------------`); console.log(`[InteractiveSearch] --------------------------------------------------------`);
top3.forEach((result, index) => { top3.forEach((result, index) => {
+3 -1
View File
@@ -37,6 +37,7 @@ function LoginContent() {
registrationEnabled: boolean; registrationEnabled: boolean;
hasLocalUsers: boolean; hasLocalUsers: boolean;
oidcProviderName: string | null; oidcProviderName: string | null;
localLoginDisabled: boolean;
} | null>(null); } | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false); const [showRegisterForm, setShowRegisterForm] = useState(false);
const [registerUsername, setRegisterUsername] = useState(''); const [registerUsername, setRegisterUsername] = useState('');
@@ -75,6 +76,7 @@ function LoginContent() {
registrationEnabled: false, registrationEnabled: false,
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled: false,
}); });
} }
}; };
@@ -727,7 +729,7 @@ function LoginContent() {
)} )}
{/* Admin Login toggle for Plex mode */} {/* Admin Login toggle for Plex mode */}
{authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && ( {authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && !authProviders.localLoginDisabled && (
<> <>
<div className="relative my-6 sm:my-8"> <div className="relative my-6 sm:my-8">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
@@ -73,8 +73,9 @@ export function InteractiveTorrentSearchModal({
try { try {
let data; let data;
if (hasRequestId) { if (hasRequestId) {
// Existing flow: search by requestId (cannot customize search term) // Existing flow: search by requestId with optional custom title
data = await searchByRequestId(requestId); const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else { } else {
// New flow: search by custom title + original author // New flow: search by custom title + original author
data = await searchByAudiobook(searchTitle, audiobook.author); data = await searchByAudiobook(searchTitle, audiobook.author);
@@ -140,42 +141,31 @@ export function InteractiveTorrentSearchModal({
<> <>
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full"> <Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
<div className="space-y-4"> <div className="space-y-4">
{/* Search customization */} {/* Search customization - editable for ALL modes */}
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg"> <div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
{hasRequestId ? ( <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
// Existing request: show static title (cannot customize) Search Title
<> </label>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{audiobook.title}</h3> <div className="flex gap-2">
<p className="text-sm text-gray-600 dark:text-gray-400">By {audiobook.author}</p> <input
</> type="text"
) : ( value={searchTitle}
// New search: allow title customization onChange={(e) => setSearchTitle(e.target.value)}
<> onKeyPress={handleSearchKeyPress}
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> placeholder="Enter book title to search..."
Search Title disabled={isSearching}
</label> 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"
<div className="flex gap-2"> />
<input <Button
type="text" onClick={performSearch}
value={searchTitle} disabled={isSearching || !searchTitle.trim()}
onChange={(e) => setSearchTitle(e.target.value)} variant="primary"
onKeyPress={handleSearchKeyPress} size="sm"
placeholder="Enter book title to search..." >
disabled={isSearching} Search
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" </Button>
/> </div>
<Button <p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
onClick={performSearch}
disabled={isSearching || !searchTitle.trim()}
variant="primary"
size="sm"
>
Search
</Button>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
</>
)}
</div> </div>
{/* Error message */} {/* Error message */}
+5 -1
View File
@@ -216,7 +216,7 @@ export function useInteractiveSearch() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const searchTorrents = async (requestId: string) => { const searchTorrents = async (requestId: string, customTitle?: string) => {
if (!accessToken) { if (!accessToken) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
@@ -227,6 +227,10 @@ export function useInteractiveSearch() {
try { try {
const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search`, { const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
}); });
const data = await response.json(); const data = await response.json();
@@ -54,6 +54,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
author: audiobook.author, author: audiobook.author,
narrator: audiobook.narrator || undefined, narrator: audiobook.narrator || undefined,
coverArtUrl: audiobook.coverArtUrl || undefined, coverArtUrl: audiobook.coverArtUrl || undefined,
asin: audiobook.audibleAsin || undefined,
}, },
jobId ? { jobId, context: 'FileOrganizer' } : undefined jobId ? { jobId, context: 'FileOrganizer' } : undefined
); );
+18 -3
View File
@@ -16,6 +16,7 @@ export interface AudiobookMetadata {
narrator?: string; narrator?: string;
year?: number; year?: number;
coverArtUrl?: string; coverArtUrl?: string;
asin?: string;
} }
export interface OrganizationResult { export interface OrganizationResult {
@@ -110,6 +111,7 @@ export class FileOrganizer {
author: audiobook.author, author: audiobook.author,
narrator: audiobook.narrator, narrator: audiobook.narrator,
year: audiobook.year, year: audiobook.year,
asin: audiobook.asin,
}); });
const successCount = taggingResults.filter((r) => r.success).length; const successCount = taggingResults.filter((r) => r.success).length;
@@ -153,7 +155,8 @@ export class FileOrganizer {
this.mediaDir, this.mediaDir,
audiobook.author, audiobook.author,
audiobook.title, audiobook.title,
audiobook.year audiobook.year,
audiobook.asin
); );
await logger?.info(`Target path: ${targetPath}`); await logger?.info(`Target path: ${targetPath}`);
@@ -359,16 +362,28 @@ export class FileOrganizer {
/** /**
* Build target path with sanitized names * Build target path with sanitized names
* Format: Author/Title (Year) ASIN or Author/Title ASIN or Author/Title (Year)
*/ */
private buildTargetPath( private buildTargetPath(
baseDir: string, baseDir: string,
author: string, author: string,
title: string, title: string,
year?: number year?: number,
asin?: string
): string { ): string {
const authorClean = this.sanitizePath(author); const authorClean = this.sanitizePath(author);
const titleClean = this.sanitizePath(title); 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); return path.join(baseDir, authorClean, folderName);
} }
+11
View File
@@ -15,6 +15,7 @@ export interface MetadataTaggingOptions {
author: string; author: string;
narrator?: string; narrator?: string;
year?: number; year?: number;
asin?: string;
} }
export interface TaggingResult { export interface TaggingResult {
@@ -76,6 +77,11 @@ export async function tagAudioFileMetadata(
args.push('-metadata', `date="${metadata.year}"`); 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) // Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4'); args.push('-f', 'mp4');
} }
@@ -97,6 +103,11 @@ export async function tagAudioFileMetadata(
args.push('-metadata', `date="${metadata.year}"`); 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) // Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp3'); args.push('-f', 'mp3');
} }