Files
silo-server/internal/recommendations/embeddings/text_test.go
T
RXWatcherandClaude Opus 4.7 14a05bba9f feat(audiobooks): comprehensive UX, scanner, and collections work
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>
2026-05-25 19:47:54 +02:00

87 lines
3.4 KiB
Go

package embeddings
import (
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestBuildEmbeddingTextSortsCrewDeterministically(t *testing.T) {
item := &models.MediaItem{
Title: "Example",
Type: "movie",
People: []models.ItemPerson{
{Person: models.Person{ID: 3, Name: "Charlie"}, Kind: models.PersonKindWriter, SortOrder: 0},
{Person: models.Person{ID: 1, Name: "Alice"}, Kind: models.PersonKindWriter, SortOrder: 0},
{Person: models.Person{ID: 2, Name: "Bob"}, Kind: models.PersonKindWriter, SortOrder: 0},
{Person: models.Person{ID: 5, Name: "Zed"}, Kind: models.PersonKindDirector, SortOrder: 0},
{Person: models.Person{ID: 4, Name: "Ann"}, Kind: models.PersonKindDirector, SortOrder: 0},
},
}
text := BuildEmbeddingText(item)
if !strings.Contains(text, "Directed by Ann, Zed") {
t.Fatalf("director order was not deterministic by name: %q", text)
}
if !strings.Contains(text, "Written by Alice, Bob, Charlie") {
t.Fatalf("writer order was not deterministic by name: %q", text)
}
}
func TestBuildEmbeddingTextAudiobookUsesAuthorAndNarrator(t *testing.T) {
item := &models.MediaItem{
Title: "The Ice Angels",
Type: "audiobook",
Year: 2026,
Genres: []string{"Thriller", "Mystery"},
Overview: "Detective Elea Baker hunts a child killer in Lincoln.",
People: []models.ItemPerson{
{Person: models.Person{ID: 1, Name: "Caroline Mitchell"}, Kind: models.PersonKindAuthor},
{Person: models.Person{ID: 2, Name: "Aryana Ramkhalawon"}, Kind: models.PersonKindNarrator},
// Cast/director/writer rows must be ignored for audiobooks.
{Person: models.Person{ID: 3, Name: "Bystander"}, Kind: models.PersonKindActor, Character: "Detective"},
},
}
text := BuildEmbeddingText(item)
if !strings.Contains(text, "Thriller, Mystery audiobook about Detective Elea Baker") {
t.Fatalf("audiobook lede missing or wrong: %q", text)
}
if !strings.Contains(text, "Written by Caroline Mitchell") {
t.Fatalf("author missing: %q", text)
}
if !strings.Contains(text, "Narrated by Aryana Ramkhalawon") {
t.Fatalf("narrator missing: %q", text)
}
if strings.Contains(text, "Cast:") || strings.Contains(text, "Directed by") {
t.Fatalf("audiobook text should not contain cast/director credits: %q", text)
}
}
func TestBuildEmbeddingTextSortsActorsBeforeTopFive(t *testing.T) {
item := &models.MediaItem{
Title: "Example",
Type: "movie",
People: []models.ItemPerson{
{Person: models.Person{ID: 6, Name: "F"}, Kind: models.PersonKindActor, Character: "Six", SortOrder: 6},
{Person: models.Person{ID: 2, Name: "B"}, Kind: models.PersonKindActor, Character: "Two", SortOrder: 2},
{Person: models.Person{ID: 1, Name: "A"}, Kind: models.PersonKindActor, Character: "One", SortOrder: 1},
{Person: models.Person{ID: 5, Name: "E"}, Kind: models.PersonKindActor, Character: "Five", SortOrder: 5},
{Person: models.Person{ID: 4, Name: "D"}, Kind: models.PersonKindActor, Character: "Four", SortOrder: 4},
{Person: models.Person{ID: 3, Name: "C"}, Kind: models.PersonKindActor, Character: "Three", SortOrder: 3},
},
}
text := BuildEmbeddingText(item)
if strings.Contains(text, "F as Six") {
t.Fatalf("sixth actor should not be included after sorting: %q", text)
}
if !strings.Contains(text, "Cast: A as One, B as Two, C as Three, D as Four, E as Five") {
t.Fatalf("actor order was not deterministic by sort order: %q", text)
}
}