feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks

A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:03:35 -04:00
1 parent 902cbe5915
commit 86f0af632e
38 files changed
+3069 -1552

No files matched your search

+3
View File
@@ -245,6 +245,8 @@ type MediaItems struct {
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Narrators []string `db:"narrators" json:"narrators"`
Abridged bool `db:"abridged" json:"abridged"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
@@ -315,6 +317,7 @@ type MediaItems struct {
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
+1
View File
@@ -503,6 +503,7 @@ type Querier interface {
// Search Media Items queries
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
// Library Visibility queries
File diff suppressed because it is too large. Load diff
+38 -6
View File
@@ -149,8 +149,8 @@ GROUP BY l.id;
-- Media Items queries
-- name: CreateMediaItem :one
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45)
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *;
@@ -159,8 +159,8 @@ RETURNING *;
-- (library_id, file_path) this inserts NOTHING and returns no row
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
-- and a duplicate flag instead of pretending it created something.
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45)
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING *;
@@ -314,6 +314,9 @@ UPDATE media_items SET
summary = $37,
metadata_overrides = $38,
audio_metadata = $39,
narrators = $40,
narrators_search = $41,
abridged = $42,
updated_at = NOW()
WHERE id = $1
RETURNING *;
@@ -649,6 +652,7 @@ SELECT
mi.kobo_metadata,
mi.tags_search,
mi.contributors_search,
mi.narrators,
mi.file_sha256,
mi.opf_identifier,
mi.opf_uuid,
@@ -679,6 +683,11 @@ WHERE COALESCE(lv.is_visible, true) = true
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
))
-- Narrator filter (A2a — arrays use the *_search column)
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
))
-- Year range (exact) - prioritize date_published, fallback to copyright_year
AND (
sqlc.narg('year_min') = 0 OR
@@ -713,6 +722,11 @@ WHERE COALESCE(lv.is_visible, true) = true
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
WHERE word_similarity(sqlc.narg('search_query'), narrator) > 0.3
LIMIT 1
)
) OR
-- Exact search (with quotes)
@@ -730,7 +744,8 @@ WHERE COALESCE(lv.is_visible, true) = true
COALESCE(mi.author, '') = sqlc.narg('search_query') OR
COALESCE(mi.series, '') = sqlc.narg('search_query') OR
sqlc.narg('search_query') = ANY(mi.tags_search) OR
sqlc.narg('search_query') = ANY(mi.contributors_search)
sqlc.narg('search_query') = ANY(mi.contributors_search) OR
sqlc.narg('search_query') = ANY(mi.narrators_search)
)
)
ORDER BY
@@ -844,6 +859,23 @@ GROUP BY tag, word_similarity(sqlc.narg('search_query'), tag)::float8
ORDER BY word_similarity(sqlc.narg('search_query'), tag)::float8 DESC, COUNT DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchNarratorValues :many
SELECT
narrator::TEXT as value,
COUNT(*) as count,
word_similarity(sqlc.narg('search_query'), narrator)::float8 as score
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
WHERE COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND narrator IS NOT NULL
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
GROUP BY narrator, word_similarity(sqlc.narg('search_query'), narrator)::float8
ORDER BY word_similarity(sqlc.narg('search_query'), narrator)::float8 DESC, COUNT DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchSeriesValues :many
SELECT
mi.series as value,
@@ -2367,7 +2399,7 @@ DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
-- Get collection items
-- name: GetCollectionItems :many
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1