Detail page: - collapse Chapters section behind header toggle (73-chapter pages no longer push content below the fold) - square cover frames throughout (audiobook covers are Audible-style 1:1, not 2:3 book portrait) - narrator picker dropdown when multiple narrations of the same book exist - clickable genre badges (route to /audiobooks?genre=X) - reordered so credits/rails sit above the chapter list - Play-from-Start button forces remount via playToken counter (was a no-op when player was already at position 0) - regression test for the Play-from-Start fix Mini bar / Now Listening: - mini bar respects --app-sidebar-offset so it stops getting covered by the desktop sidebar - Now Listening adds overflow scroll + a labeled "Back to player" button so controls are never inaccessible on short viewports Library page: - infinite scroll (replaces Previous/Next pagination) - genre filter chip with X-to-clear Scanner: - 8-worker parallel reconcile (env SILO_AUDIOBOOK_SCAN_WORKERS to override) - file-path-first dedup so cleaned titles don't collapse separate narrations - title cleanup at write time (strips "Read by X" / "(unabridged)" suffixes; original tag preserved in original_title) - audiobook_series upsert from tag-derived series_name/series_position - secondary dedup check (author + narrator + year + duration ±0.5% + title-prefix) so two folders of the same book attach to one row Backend detail handler: - new fetchAlsoByAuthor, fetchInSeries (with series row > 1 entry guard), fetchSimilar (embedding-first with shared-genre fallback), fetchOtherNarrations (regex-strips narrator suffix to group siblings) - audiobookDetailResponse gained also_by_author, in_series, similar_audiobooks, other_narrations fields - list endpoint accepts a genre query param Embeddings: - BuildEmbeddingText branches on item.Type == "audiobook" to use author/narrator credits instead of cast/director/writer - mediaTypeLabel helper centralizes movie / "TV series" / audiobook - ListEmbeddingTextCandidates SQL mirrors the Go branching exactly - ItemsNeedingEmbedding and TotalMediaItemCount loosen status='matched' gate to also include audiobooks (which don't go through TMDB match) - FindSimilar gains a mediaType filter so cross-type results never appear - callers in similar.go and personal.go pass the source item's type Collections: - MediaAudiobook MediaKind + audiobook(s) case in templateEligibleForLibrary (stops offering broken movie/TV templates to audiobook libraries) - useAddItemToCollection hook (user + admin/library endpoints) - AddToCollectionDialog wired into audiobook detail and movie/series ActionBar overflow menu - ManualCollectionItemsEditor gained a search-and-add panel with debounced live results - QueryDefinition.media_scope, QuerySortRelevanceScope, ALL_MEDIA_SCOPES extended to include "audiobook" - CatalogFilterBar gained an Audiobooks media scope option - parseCatalogMediaScope (backend) accepts "audiobook" Migrations: - 145_audiobook_series: per-book series_name/series_index with a best-effort title-pattern backfill for the existing corpus - 146_audiobook_title_cleanup: strips narrator suffix / (unabridged) noise from existing titles, preserving raw in original_title Scripts: - scripts/dedup_audiobooks.py: one-shot merge for "Title" vs "Title: Subtitle" duplicates, file-path-stable, dry-run by default Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.1 KiB
Go
102 lines
3.1 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseAudiobookFolderSingleM4B(t *testing.T) {
|
|
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
ffprobePath = "ffprobe"
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
t.Skip("ffprobe not available")
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/single_book")
|
|
if err != nil {
|
|
t.Fatalf("parseAudiobookFolder: %v", err)
|
|
}
|
|
if got.Title != "Test Audiobook" {
|
|
t.Errorf("Title = %q, want %q", got.Title, "Test Audiobook")
|
|
}
|
|
if got.Author != "Test Author" {
|
|
t.Errorf("Author = %q, want %q", got.Author, "Test Author")
|
|
}
|
|
if got.Series != "Test Series" {
|
|
t.Errorf("Series = %q, want %q", got.Series, "Test Series")
|
|
}
|
|
if got.Year != 2024 {
|
|
t.Errorf("Year = %d, want 2024", got.Year)
|
|
}
|
|
if len(got.Files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(got.Files))
|
|
}
|
|
if len(got.Files[0].Chapters) != 2 {
|
|
t.Errorf("file 0 chapters = %d, want 2", len(got.Files[0].Chapters))
|
|
}
|
|
}
|
|
|
|
func TestParseAudiobookFolderMultiFile(t *testing.T) {
|
|
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
ffprobePath = "ffprobe"
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
t.Skip("ffprobe not available")
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/multi_file")
|
|
if err != nil {
|
|
t.Fatalf("parseAudiobookFolder: %v", err)
|
|
}
|
|
if got.Title != "Multi File Test" {
|
|
t.Errorf("Title = %q, want %q", got.Title, "Multi File Test")
|
|
}
|
|
if got.Author != "Multi Author" {
|
|
t.Errorf("Author = %q, want %q", got.Author, "Multi Author")
|
|
}
|
|
if got.Year != 2023 {
|
|
t.Errorf("Year = %d, want 2023", got.Year)
|
|
}
|
|
if len(got.Files) != 3 {
|
|
t.Fatalf("got %d files, want 3", len(got.Files))
|
|
}
|
|
for i, f := range got.Files {
|
|
if len(f.Chapters) != 1 {
|
|
t.Errorf("file %d: %d synthesized chapters, want 1", i, len(f.Chapters))
|
|
continue
|
|
}
|
|
wantStem := fmt.Sprintf("part%d", i+1)
|
|
if f.Chapters[0].Title != wantStem {
|
|
t.Errorf("file %d: chapter title = %q, want %q", i, f.Chapters[0].Title, wantStem)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStripNarratorSuffix(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"Solitaire Read By Holly Gibbs", "Solitaire"},
|
|
{"The Skyward Series 3 - Cytonic (UK Version: Read by Sophie Aldred)", "The Skyward Series 3 - Cytonic"},
|
|
{"Mick Stranahan (unabridged) 1 - Skin Tight", "Mick Stranahan 1 - Skin Tight"},
|
|
{"An Evening with Alan Titchmarsh (Unabridged)", "An Evening with Alan Titchmarsh"},
|
|
// "Read by Celebrities" is a series name here, not a narrator credit
|
|
{"Classic Stories and Tales Read by Celebrities 1 - Classics of Childhood, Volume One",
|
|
"Classic Stories and Tales Read by Celebrities 1 - Classics of Childhood, Volume One"},
|
|
{"Old Harry's Game", "Old Harry's Game"}, // unchanged
|
|
}
|
|
for _, c := range cases {
|
|
got := stripNarratorSuffix(c.in)
|
|
if got != c.want {
|
|
t.Errorf("stripNarratorSuffix(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|