mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user