Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
278 lines
8.4 KiB
Go
278 lines
8.4 KiB
Go
package services
|
|
|
|
// Unit tests for the exhaustive audiobook metadata ingestion: CUE
|
|
// sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms)
|
|
// and tag-based track ordering.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCueTimeToSeconds(t *testing.T) {
|
|
cases := map[string]float64{
|
|
"00:00:00": 0,
|
|
"01:00:00": 60,
|
|
"03:25:12": 3*60 + 25 + 12.0/75.0,
|
|
"75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec
|
|
"not-a-time": 0,
|
|
"1:2": 0,
|
|
}
|
|
for in, want := range cases {
|
|
if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 {
|
|
t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseCueSheet(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cuePath := filepath.Join(dir, "book.cue")
|
|
cue := `TITLE "Cue Book"
|
|
FILE "book.flac" WAVE
|
|
TRACK 01 AUDIO
|
|
TITLE "Opening"
|
|
INDEX 01 00:00:00
|
|
TRACK 02 AUDIO
|
|
TITLE "The Middle Bit"
|
|
INDEX 01 00:01:30
|
|
TRACK 03 AUDIO
|
|
TITLE "Closing"
|
|
INDEX 01 00:03:00
|
|
FILE "other.flac" WAVE
|
|
TRACK 01 AUDIO
|
|
TITLE "Foreign Track"
|
|
INDEX 01 00:00:00
|
|
`
|
|
if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chapters := parseCueSheet(cuePath, "book.flac")
|
|
if len(chapters) != 3 {
|
|
t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters)
|
|
}
|
|
if chapters[0].Title != "Opening" || chapters[0].Start != 0 {
|
|
t.Fatalf("first = %+v", chapters[0])
|
|
}
|
|
// MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s
|
|
if chapters[1].Start != 1.4 || chapters[1].End != 3.0 {
|
|
t.Fatalf("second = %+v", chapters[1])
|
|
}
|
|
if chapters[2].End != 0 {
|
|
t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2])
|
|
}
|
|
}
|
|
|
|
func TestReadCueChaptersMatched(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n"
|
|
if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chapters := readCueChapters(filepath.Join(dir, "audio.mp3"))
|
|
if len(chapters) != 2 {
|
|
t.Fatalf("chapters = %d", len(chapters))
|
|
}
|
|
|
|
// No cue at all → nil, never an error.
|
|
if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil {
|
|
t.Fatalf("expected nil for missing cue, got %+v", chapters)
|
|
}
|
|
}
|
|
|
|
func TestFindAndApplyPlaylistHint(t *testing.T) {
|
|
dir := t.TempDir()
|
|
playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n"
|
|
if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Exactly one playlist → hint; two → none.
|
|
hint := findPlaylistHint(dir)
|
|
if hint == nil {
|
|
t.Fatal("expected hint")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if findPlaylistHint(dir) != nil {
|
|
t.Fatal("multiple playlists must produce no hint")
|
|
}
|
|
|
|
abs := []string{
|
|
filepath.Join(dir, "01_First.mp3"),
|
|
filepath.Join(dir, "02_Second.mp3"),
|
|
filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last
|
|
}
|
|
ordered := hint.reorder(abs)
|
|
if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] {
|
|
t.Fatalf("playlist order not applied: %v", ordered)
|
|
}
|
|
if got := hint.titleFor(abs[0]); got != "Chapter One" {
|
|
t.Fatalf("titleFor(./01_First.mp3) = %q", got)
|
|
}
|
|
if got := hint.titleFor(abs[1]); got != "Chapter Zero" {
|
|
t.Fatalf("titleFor(02_Second.mp3) = %q", got)
|
|
}
|
|
if got := hint.titleFor(abs[2]); got != "" {
|
|
t.Fatalf("unlisted track must have no title, got %q", got)
|
|
}
|
|
if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" {
|
|
t.Fatal("nil hint must be safe")
|
|
}
|
|
}
|
|
|
|
func syntheticReport(tags map[string]string) *ffprobeReport {
|
|
r := &ffprobeReport{}
|
|
r.Format.Tags = tags
|
|
return r
|
|
}
|
|
|
|
func TestApplyAudioTagFallbacks(t *testing.T) {
|
|
t.Run("full fill", func(t *testing.T) {
|
|
meta := &MediaMetadata{}
|
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
|
"album": "The Tagged Album",
|
|
"album_artist": "Tag Author",
|
|
"artist": "Wrong Author",
|
|
"composer": "Tag Narrator",
|
|
"grouping": "Tag Series",
|
|
"genre": "Audiobook",
|
|
"date": "2020",
|
|
"----:com.apple.iTunes:SUBTITLE": "Or, A Life",
|
|
"----:com.apple.iTunes:SERIES": "Freeform Series",
|
|
"----:com.apple.iTunes:SERIES-PART": "4",
|
|
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
|
|
"----:com.apple.iTunes:ABRIDGED": "1",
|
|
}))
|
|
if meta.Title != "The Tagged Album: Or, A Life" {
|
|
t.Fatalf("title = %q", meta.Title)
|
|
}
|
|
if meta.Author != "Tag Author" {
|
|
t.Fatalf("author = %q", meta.Author)
|
|
}
|
|
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" {
|
|
t.Fatalf("narrators = %v", meta.Narrators)
|
|
}
|
|
// grouping outranks freeform SERIES for the series name only
|
|
// because it is checked first; both are tag-level fallbacks.
|
|
if meta.Series == "" {
|
|
t.Fatal("series not filled from tags")
|
|
}
|
|
if meta.SeriesNumber != 4 {
|
|
t.Fatalf("series number = %v", meta.SeriesNumber)
|
|
}
|
|
if meta.ASIN != "B0ASIN1234" {
|
|
t.Fatalf("asin = %q", meta.ASIN)
|
|
}
|
|
if !meta.Abridged {
|
|
t.Fatal("abridged not filled")
|
|
}
|
|
if meta.PublishDate.Year() != 2020 {
|
|
t.Fatalf("publish date = %v", meta.PublishDate)
|
|
}
|
|
if meta.Genre != "Audiobook" {
|
|
t.Fatalf("genre = %q", meta.Genre)
|
|
}
|
|
})
|
|
|
|
t.Run("fills only empty fields", func(t *testing.T) {
|
|
meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}}
|
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
|
"album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator",
|
|
}))
|
|
if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" {
|
|
t.Fatalf("tags must not override earlier sources: %+v", meta)
|
|
}
|
|
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" {
|
|
t.Fatalf("narrators = %v", meta.Narrators)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) {
|
|
abridgedFalse := false
|
|
meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}}
|
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
|
"----:com.apple.iTunes:ABRIDGED": "1",
|
|
}))
|
|
if meta.Abridged {
|
|
t.Fatal("explicit sidecar false must not be overridden")
|
|
}
|
|
})
|
|
|
|
t.Run("nil report is a no-op", func(t *testing.T) {
|
|
meta := &MediaMetadata{}
|
|
applyAudioTagFallbacks(meta, nil)
|
|
if meta.Title != "" {
|
|
t.Fatal("nil report must change nothing")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseTagOrdinal(t *testing.T) {
|
|
cases := map[string]int{
|
|
"2": 2,
|
|
"3/9": 3,
|
|
" 12 ": 12,
|
|
"": 0,
|
|
"x": 0,
|
|
"0": 0,
|
|
"2/": 2,
|
|
"-1": 0,
|
|
}
|
|
for in, want := range cases {
|
|
if got := parseTagOrdinal(in); got != want {
|
|
t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrderTracksByTags(t *testing.T) {
|
|
mk := func(disc, track int, rel string) audioTrackInfo {
|
|
return audioTrackInfo{disc: disc, trackNum: track, relPath: rel}
|
|
}
|
|
|
|
t.Run("tag order wins when complete", func(t *testing.T) {
|
|
probed := []audioTrackInfo{
|
|
mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"),
|
|
}
|
|
orderTracksByTags(probed)
|
|
want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"}
|
|
for i, tr := range probed {
|
|
if tr.relPath != want[i] {
|
|
t.Fatalf("order = %v", probed)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("incomplete coverage keeps natural order", func(t *testing.T) {
|
|
probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")}
|
|
orderTracksByTags(probed)
|
|
if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" {
|
|
t.Fatalf("order = %v", probed)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestTagLookupCaseInsensitive(t *testing.T) {
|
|
r := syntheticReport(map[string]string{
|
|
"ALBUM": "Cased Album",
|
|
"----:com.apple.iTunes:ASIN": "B0CASE",
|
|
})
|
|
if got := r.tag("album"); got != "Cased Album" {
|
|
t.Fatalf("tag(album) = %q", got)
|
|
}
|
|
if got := r.tag("album", "title"); got != "Cased Album" {
|
|
t.Fatalf("multi-key lookup = %q", got)
|
|
}
|
|
if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") {
|
|
t.Fatalf("freeform case-insensitive lookup = %q", got)
|
|
}
|
|
if got := r.tag("missing"); got != "" {
|
|
t.Fatalf("missing key = %q", got)
|
|
}
|
|
}
|