Update Audiobookshelf API key instructions and improve chapter merging

Replaces outdated Audiobookshelf API token instructions with new API key generation steps across documentation and UI. Enhances chapter merging logic to detect and handle book titles in metadata, prioritizing filename extraction for chapter names, and updates logging and documentation to reflect these changes. Adds ASIN copy-to-clipboard feature in AudiobookDetailsModal.
This commit is contained in:
kikootwo
2026-01-14 12:17:41 -05:00
parent 307b63fab4
commit b3f89d67bb
8 changed files with 175 additions and 36 deletions
+1 -1
View File
@@ -1340,7 +1340,7 @@ export default function AdminSettings() {
placeholder="Enter your Audiobookshelf API token"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Found in Audiobookshelf Settings Users Your Account API Tokens
Generate in Audiobookshelf: Settings API Keys Add API Key
</p>
</div>
+3 -3
View File
@@ -136,7 +136,7 @@ export function AudiobookshelfStep({
onChange={(e) => onUpdate('absApiToken', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Find this in Audiobookshelf Settings Users Your User API Token
Generate this in Audiobookshelf Settings API Keys Add API Key
</p>
</div>
@@ -270,8 +270,8 @@ export function AudiobookshelfStep({
About API Token
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
You can generate an API token in Audiobookshelf by going to Settings Users
selecting your user and copying the API Token.
Generate an API key in Audiobookshelf by navigating to Settings API Keys Add API Key.
Give it a descriptive name and copy the generated key to use here.
</p>
</div>
</div>
@@ -43,6 +43,7 @@ export function AudiobookDetailsModal({
const [requestError, setRequestError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
useEffect(() => {
setMounted(true);
@@ -123,6 +124,16 @@ export function AudiobookDetailsModal({
}
};
const handleCopyAsin = async () => {
try {
await navigator.clipboard.writeText(asin);
setAsinCopied(true);
setTimeout(() => setAsinCopied(false), 2000);
} catch (err) {
console.error('Failed to copy ASIN:', err);
}
};
if (!isOpen || !mounted) return null;
const modalContent = (
@@ -291,6 +302,40 @@ export function AudiobookDetailsModal({
</div>
)}
{/* ASIN */}
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">ASIN</p>
<button
onClick={handleCopyAsin}
className="group flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title="Click to copy ASIN"
>
<span className="font-mono text-sm">{asin}</span>
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{asinCopied ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
)}
</svg>
</button>
</div>
{/* Availability Status */}
{isAvailable && (
<div>
+68 -7
View File
@@ -42,6 +42,7 @@ export interface ChapterFile {
bitrate?: number; // kbps
trackNumber?: number; // from metadata
titleMetadata?: string; // from metadata
titleIsBookTitle?: boolean; // true if titleMetadata is the book title (not chapter-specific)
chapterTitle: string; // final computed title
}
@@ -196,7 +197,9 @@ function extractChapterNameFromFilename(filename: string): string | null {
// Try to extract meaningful name after chapter indicator
// "01 - The Beginning" -> "The Beginning"
// "Chapter 1 - Introduction" -> "Introduction"
// "Book Title - 01 - Chapter Name" -> "Chapter Name"
const patterns = [
/[\s._-]+\d+[\s._-]+(.+)$/, // "BookTitle - 01 - ChapterName" (extract after last digit sequence)
/^\d+[\s._-]+(.+)$/, // "01 - Title" or "01_Title"
/^chapter\s*\d+[\s._-]+(.+)$/i, // "Chapter 1 - Title"
/^ch\s*\d+[\s._-]+(.+)$/i, // "Ch1 - Title"
@@ -217,24 +220,65 @@ function extractChapterNameFromFilename(filename: string): string | null {
}
/**
* Get chapter title with priority: metadata > filename > fallback
* Get chapter title with priority: filename > metadata (if not book title) > fallback
*/
function getChapterTitle(file: ChapterFile, index: number): string {
// Priority 1: Title metadata (if meaningful)
if (file.titleMetadata && !isGenericTitle(file.titleMetadata)) {
return file.titleMetadata;
}
// Priority 2: Extract from filename
// Priority 1: Extract from filename (most reliable for chapter-specific names)
const extracted = extractChapterNameFromFilename(file.filename);
if (extracted) {
return extracted;
}
// Priority 2: Title metadata (only if meaningful AND not the book title)
if (file.titleMetadata && !file.titleIsBookTitle && !isGenericTitle(file.titleMetadata)) {
return file.titleMetadata;
}
// Priority 3: Fallback to "Chapter X"
return `Chapter ${index + 1}`;
}
/**
* Detect if a title appearing in metadata is the book title (not chapter-specific)
* Returns the book title if >80% of files have the same title metadata
*/
function detectBookTitle(files: { titleMetadata?: string }[]): string | null {
if (files.length === 0) return null;
// Count occurrences of each title
const titleCounts = new Map<string, number>();
let filesWithTitle = 0;
for (const file of files) {
if (file.titleMetadata && file.titleMetadata.trim().length > 0) {
const title = file.titleMetadata.trim();
titleCounts.set(title, (titleCounts.get(title) || 0) + 1);
filesWithTitle++;
}
}
if (filesWithTitle === 0) return null;
// Find most common title
let mostCommonTitle: string | null = null;
let maxCount = 0;
for (const [title, count] of titleCounts.entries()) {
if (count > maxCount) {
maxCount = count;
mostCommonTitle = title;
}
}
// If >80% of files have the same title, it's likely the book title
const threshold = files.length * 0.8;
if (mostCommonTitle && maxCount >= threshold) {
return mostCommonTitle;
}
return null;
}
/**
* Analyze and order chapter files
* Returns files in correct order with metadata populated
@@ -255,6 +299,7 @@ export async function analyzeChapterFiles(
bitrate: probe.bitrate,
trackNumber: probe.trackNumber,
titleMetadata: probe.title,
titleIsBookTitle: false, // Will be updated if book title detected
chapterTitle: '', // Will be computed after ordering
};
});
@@ -266,6 +311,22 @@ export async function analyzeChapterFiles(
const sampleFilenames = files.slice(0, sampleCount).map(f => f.filename);
await logger?.info(`Sample filenames: ${sampleFilenames.join(', ')}${files.length > sampleCount ? ', ...' : ''}`);
// Detect if title metadata is actually the book title (not chapter-specific)
const bookTitle = detectBookTitle(files);
if (bookTitle) {
const filesWithBookTitle = files.filter(f => f.titleMetadata?.trim() === bookTitle).length;
await logger?.info(`Detected book title in metadata: "${bookTitle}" (appears in ${filesWithBookTitle}/${files.length} files)`);
// Flag all files that have the book title as metadata
for (const file of files) {
if (file.titleMetadata?.trim() === bookTitle) {
file.titleIsBookTitle = true;
}
}
await logger?.info(`Title metadata flagged as book title - will prioritize filename extraction for chapter names`);
}
// Create filename-based order (natural sort)
const filenameOrder = [...files].sort((a, b) =>
naturalSortCompare(a.filename, b.filename)