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:
1 parent
902cbe5915
commit
86f0af632e
38 files changed
+3069
-1552
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -75,6 +75,8 @@ type BookInfo struct {
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
HasConflict bool `json:"has_conflict"`
|
||||
// Media-type identifier for card badges/hover icons (A2c)
|
||||
FormatGroup string `json:"format_group"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
@@ -283,6 +285,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
})
|
||||
}
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
@@ -333,6 +336,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
}
|
||||
}
|
||||
bookList = bookCards
|
||||
@@ -349,6 +353,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||
FormatGroup: book.FormatGroup,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,18 +392,18 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
@@ -439,17 +439,17 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
|
||||
@@ -64,7 +64,17 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID)
|
||||
// Per-type wording: system-collection display swaps on audiobook scope.
|
||||
libraryType := ""
|
||||
if libraryID != "" {
|
||||
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
|
||||
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
|
||||
libraryType = lib.TypeName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID, libraryType)
|
||||
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
@@ -168,10 +178,27 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
||||
}
|
||||
|
||||
func BuildSections(sections []services.DashboardSection, currentLibraryID string) []SectionData {
|
||||
// BuildSections maps dashboard service sections to card data. When the
|
||||
// active library is audiobooks, system-collection titles/descriptions
|
||||
// swap to the listening wording at display time — the stored names stay
|
||||
// generic so restores and settings keep working across library types.
|
||||
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string) []SectionData {
|
||||
var result []SectionData
|
||||
|
||||
audioScope := libraryType == "audiobooks"
|
||||
|
||||
for _, ds := range sections {
|
||||
if audioScope && ds.IsSystem {
|
||||
switch ds.Title {
|
||||
case "Continue Reading":
|
||||
ds.Title = "Continue Listening"
|
||||
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
|
||||
case "Recently Read":
|
||||
ds.Title = "Recently Listened"
|
||||
ds.Description = "Audiobooks you've finished (progress >= 1)"
|
||||
}
|
||||
}
|
||||
|
||||
bookCards := make([]BookInfo, len(ds.Items))
|
||||
for i, item := range ds.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
@@ -180,6 +207,7 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ type CreateMediaItemRequest struct {
|
||||
AlternateInfo string `json:"alternate_info"` // JSON string
|
||||
ScanInformation string `json:"scan_information"`
|
||||
Summary string `json:"summary"`
|
||||
// Audio editorial facts (A2a)
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged bool `json:"abridged"`
|
||||
}
|
||||
|
||||
// UpdateMediaItemRequest represents the request for updating a media item
|
||||
@@ -98,6 +101,10 @@ type UpdateMediaItemRequest struct {
|
||||
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
||||
ScanInformation string `form:"scan_information" json:"scan_information"`
|
||||
Summary string `form:"summary" json:"summary"`
|
||||
// Audiobook editorial facts (A2a): narrators verbatim display order;
|
||||
// abridged as the Libation/Audible flag.
|
||||
Narrators []string `form:"narrators" json:"narrators"`
|
||||
Abridged bool `form:"abridged" json:"abridged"`
|
||||
}
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
@@ -897,6 +904,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
"format_group": item.FormatGroup,
|
||||
"format_mimetype": textToString(item.FormatMimetype),
|
||||
"narrators": item.Narrators,
|
||||
"abridged": item.Abridged,
|
||||
// NEW: Reading direction fields
|
||||
"manga_type": textToString(item.MangaType),
|
||||
"reading_direction": textToString(item.ReadingDirection),
|
||||
@@ -1405,6 +1414,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
|
||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
|
||||
|
||||
// Validate and normalize ISBN
|
||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||
@@ -1454,6 +1464,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: req.Narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
@@ -1519,6 +1532,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
|
||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
|
||||
|
||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||
if err != nil && req.ISBN != "" {
|
||||
@@ -1572,6 +1586,9 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: req.Narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
@@ -2427,6 +2444,9 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
if tags := c.QueryParam("tags"); tags != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
|
||||
}
|
||||
if narrator := c.QueryParam("narrator"); narrator != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
|
||||
}
|
||||
if series := c.QueryParam("series"); series != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
||||
}
|
||||
@@ -2468,6 +2488,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
narratorFilter := c.QueryParam("narrator_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
@@ -2494,6 +2515,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
NarratorFilter: narratorFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import "bookhoard/internal/database"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// MediaDetail embeds database.MediaItems for complete book metadata
|
||||
// No field duplication - template gets direct access to all database fields
|
||||
type MediaDetail struct {
|
||||
database.MediaItems // Embedded - ALL book fields available
|
||||
|
||||
// Audio editorial/tech surfaces (A2a): duration parsed out of the
|
||||
// audio_metadata JSONB the scanner wrote (display-only).
|
||||
audioDurationSeconds float64
|
||||
audioParsed bool
|
||||
|
||||
// Absolute on-disk location (library folder + relative path), resolved by
|
||||
// the page handler so the UI can show where the file lives.
|
||||
FileLocation string `json:"file_location"`
|
||||
@@ -37,6 +47,7 @@ type MediaDetail struct {
|
||||
// BookStatsData is the per-book reading-stats card payload.
|
||||
type BookStatsData struct {
|
||||
TotalMinutes int
|
||||
TotalSeconds int // exact wall-clock; audio "Time listened" displays from this
|
||||
TotalPages int
|
||||
SessionCount int
|
||||
MaxProgress float64 // 0..1
|
||||
@@ -44,3 +55,140 @@ type BookStatsData struct {
|
||||
LastSession string
|
||||
FirstSession string
|
||||
}
|
||||
|
||||
// IsAudio reports whether the item's format group is the dedicated
|
||||
// audiobook group (drives every "reading vs listening" wording swap).
|
||||
func (md *MediaDetail) IsAudio() bool {
|
||||
return md.FormatGroup == "audiobook"
|
||||
}
|
||||
|
||||
// --- audio-aware copy helpers (A2c): one home per verb so web + future
|
||||
// clients agree; format_group chooses the string ---
|
||||
|
||||
func (md *MediaDetail) ActionLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Listen Now"
|
||||
}
|
||||
return "Read Now"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) ActionIcon() string {
|
||||
if md.IsAudio() {
|
||||
return "headphones"
|
||||
}
|
||||
return "book-open"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) MarkCompleteLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Mark Complete"
|
||||
}
|
||||
return "Mark Read"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) MarkResetLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Mark Not Complete"
|
||||
}
|
||||
return "Mark Unread"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) ProgressHeader() string {
|
||||
if md.IsAudio() {
|
||||
return "Listening Progress"
|
||||
}
|
||||
return "Reading Progress"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) LastReadLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Last Listen"
|
||||
}
|
||||
return "Last Read"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) StatsHeader() string {
|
||||
if md.IsAudio() {
|
||||
return "Your Listening Stats"
|
||||
}
|
||||
return "Your Reading Stats"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) TimeStatLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Time Listening"
|
||||
}
|
||||
return "Time Reading"
|
||||
}
|
||||
|
||||
// AudioDuration lazily parses duration_seconds out of audio_metadata.
|
||||
func (md *MediaDetail) AudioDuration() float64 {
|
||||
if !md.audioParsed {
|
||||
md.audioParsed = true
|
||||
var payload struct {
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
if len(md.AudioMetadata) > 0 && json.Unmarshal(md.AudioMetadata, &payload) == nil {
|
||||
md.audioDurationSeconds = payload.DurationSeconds
|
||||
}
|
||||
}
|
||||
return md.audioDurationSeconds
|
||||
}
|
||||
|
||||
// AudioDurationLabel renders H:MM:SS (or M:SS under an hour) for the
|
||||
// audio surfaces — same shape the web player shows.
|
||||
func (md *MediaDetail) AudioDurationLabel() string {
|
||||
s := int(md.AudioDuration() + 0.5)
|
||||
if s <= 0 {
|
||||
return "—"
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
// AudioCurrentSeconds is the playhead in seconds: percentage of the
|
||||
// whole-book duration (chapter-exact granularity lives in the player;
|
||||
// the detail page shows shelf-level position).
|
||||
func (md *MediaDetail) AudioCurrentSeconds() int {
|
||||
dur := md.AudioDuration()
|
||||
if dur <= 0 || md.ReadingProgress == nil || !md.ReadingProgress.Percentage.Valid {
|
||||
return 0
|
||||
}
|
||||
return int(md.ReadingProgress.Percentage.Float64*dur + 0.5)
|
||||
}
|
||||
|
||||
// AudioCurrentLabel is the playhead rendered H:MM:SS.
|
||||
func (md *MediaDetail) AudioCurrentLabel() string {
|
||||
s := md.AudioCurrentSeconds()
|
||||
if s <= 0 {
|
||||
return "0:00"
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
// AudioStatsLabel is "Time listened" for audio items (H:MM:SS), honoring
|
||||
// the plan's swap of the Pages Synced tile.
|
||||
func (md *MediaDetail) TimeListenedLabel() string {
|
||||
s := 0
|
||||
if md.BookStats != nil {
|
||||
s = md.BookStats.TotalSeconds
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -148,6 +149,23 @@ type ProgressWithMedia struct {
|
||||
LastUpdated string `json:"-"`
|
||||
FormatGroup string `json:"format_group"`
|
||||
EstimatedPages int `json:"estimated_pages"`
|
||||
// Audiobook whole-book duration (seconds) for H:M:S progress display
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// audioDurationFrom pulls duration_seconds from the scan's audio_metadata
|
||||
// JSONB (0 when the item isn't audio or hasn't been probed).
|
||||
func audioDurationFrom(raw []byte) float64 {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
var payload struct {
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
return 0
|
||||
}
|
||||
return payload.DurationSeconds
|
||||
}
|
||||
|
||||
// GetAllProgress retrieves all progress for a user with sync source info
|
||||
@@ -282,7 +300,10 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
DeviceName: deviceName,
|
||||
DeviceType: deviceName,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
// Audiobook "current position" needs whole-book duration
|
||||
// (H:M:S display); parsed from the scanner's audio_metadata.
|
||||
DurationSeconds: audioDurationFrom(mediaItem.AudioMetadata),
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -469,9 +469,22 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
// Active library type drives the per-type wording (A2c): system
|
||||
// collection titles and stat labels swap on audiobooks scope; the
|
||||
// "All libraries" view keeps generic wording.
|
||||
activeLibType := ""
|
||||
if !libRes.IsAll {
|
||||
for _, ld := range libRes.Libraries {
|
||||
if ld.ID == libraryID {
|
||||
activeLibType = ld.TypeValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType)
|
||||
|
||||
// "This week" widget: the user's rolling 7-day reading pulse for
|
||||
// the CURRENTLY SELECTED library (the same resolution the sections
|
||||
@@ -502,7 +515,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -870,6 +883,15 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
widgetLibType := ""
|
||||
if !libRes.IsAll {
|
||||
for _, ld := range libRes.Libraries {
|
||||
if ld.ID == libRes.LibraryID {
|
||||
widgetLibType = ld.TypeValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err = templates.DashboardWeekWidget(templates.WeekStatsData{
|
||||
WeekMinutes: weekSvc.WeekMinutes,
|
||||
WeekPages: weekSvc.WeekPages,
|
||||
@@ -879,7 +901,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
ActiveToday: weekSvc.ActiveToday,
|
||||
CurrentStreak: weekSvc.CurrentStreak,
|
||||
LibraryID: libRes.LibraryID,
|
||||
}).Render(c.Request().Context(), &buf)
|
||||
}, widgetLibType).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1672,6 +1694,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
|
||||
bookStats = &handlers.BookStatsData{
|
||||
TotalMinutes: svcStats.TotalMinutes,
|
||||
TotalSeconds: svcStats.TotalSeconds,
|
||||
TotalPages: svcStats.TotalPages,
|
||||
SessionCount: svcStats.SessionCount,
|
||||
MaxProgress: svcStats.MaxProgress,
|
||||
|
||||
@@ -197,6 +197,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
TypeValue: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
Offline: offline,
|
||||
}
|
||||
|
||||
@@ -93,6 +93,12 @@ type MediaMetadata struct {
|
||||
// Audiobook fields (media_items.audio_metadata JSONB — only the
|
||||
// audio paths read it; see media_scanner_audio.go)
|
||||
AudioMeta *AudioMetadataPayload
|
||||
|
||||
// Narrators/abridged land in real media_items columns (A2a): the
|
||||
// editorial surface of audiobooks (edit form, filters, search),
|
||||
// while AudioMeta stays scan-derived.
|
||||
Narrators []string
|
||||
Abridged bool
|
||||
}
|
||||
|
||||
type HashInfo struct {
|
||||
@@ -941,6 +947,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Contributors: metadata.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Narrators: metadata.Narrators,
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
AddedByAdminID: s.adminID,
|
||||
@@ -1095,6 +1104,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged *bool `json:"abridged"`
|
||||
Series json.RawMessage `json:"series"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []string `json:"tags"`
|
||||
@@ -1173,6 +1184,14 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
if sidecar.ASIN != nil {
|
||||
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
||||
}
|
||||
// Audiobook editorial facts land in the real columns (A2a):
|
||||
// narrators verbatim (display), abridged as the Libation/Audible flag.
|
||||
if len(sidecar.Narrators) > 0 {
|
||||
metadata.Narrators = sidecar.Narrators
|
||||
}
|
||||
if sidecar.Abridged != nil {
|
||||
metadata.Abridged = *sidecar.Abridged
|
||||
}
|
||||
if sidecar.Language != nil {
|
||||
metadata.Language = strings.TrimSpace(*sidecar.Language)
|
||||
}
|
||||
@@ -3191,6 +3210,9 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Narrators: metadata.Narrators,
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
|
||||
@@ -42,6 +42,7 @@ type AudioMetadataPayload struct {
|
||||
CodecProfile string `json:"codec_profile,omitempty"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"`
|
||||
ASIN string `json:"asin,omitempty"`
|
||||
Abridged *bool `json:"abridged,omitempty"`
|
||||
Chapters []AudioChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
@@ -182,14 +183,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
|
||||
if payload.ASIN == "" {
|
||||
payload.ASIN = metadata.ASIN
|
||||
}
|
||||
|
||||
// Promote the editorial facts (A2a columns) when the sidecar's audio
|
||||
// payload carries them and the common-field parse left them empty.
|
||||
if len(metadata.Narrators) == 0 && len(payload.Narrators) > 0 {
|
||||
metadata.Narrators = payload.Narrators
|
||||
}
|
||||
if payload.Abridged != nil {
|
||||
metadata.Abridged = *payload.Abridged
|
||||
}
|
||||
}
|
||||
|
||||
// parseSidecarAudioFields extracts the audio-only fields from a
|
||||
// Libation/Audiobookshelf metadata.json into the payload (narrators and
|
||||
// the curated chapter list).
|
||||
// Libation/Audiobookshelf metadata.json into the payload (narrators,
|
||||
// abridged and the curated chapter list).
|
||||
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
|
||||
var sidecar struct {
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged *bool `json:"abridged"`
|
||||
ASIN *string `json:"asin"`
|
||||
Chapters []struct {
|
||||
Start float64 `json:"start"`
|
||||
@@ -201,11 +212,12 @@ func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
|
||||
if err := json.Unmarshal(data, &sidecar); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil {
|
||||
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil && sidecar.Abridged == nil {
|
||||
return nil
|
||||
}
|
||||
payload := &AudioMetadataPayload{
|
||||
Narrators: sidecar.Narrators,
|
||||
Abridged: sidecar.Abridged,
|
||||
}
|
||||
if sidecar.ASIN != nil {
|
||||
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
||||
|
||||
@@ -29,6 +29,7 @@ type SearchParams struct {
|
||||
GenreFilter string
|
||||
PublisherFilter string
|
||||
TagsFilter string
|
||||
NarratorFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
YearMax int
|
||||
@@ -76,6 +77,7 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
@@ -174,6 +176,23 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
return fieldValues, nil
|
||||
|
||||
case "narrator":
|
||||
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
|
||||
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
|
||||
UserID: params.UserID,
|
||||
LibraryID: params.LibraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldValues := make([]FieldValue, len(results))
|
||||
for i, r := range results {
|
||||
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
|
||||
}
|
||||
return fieldValues, nil
|
||||
|
||||
case "series":
|
||||
results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{
|
||||
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
|
||||
|
||||
@@ -163,6 +163,7 @@ type WeekSummaryStats struct {
|
||||
type BookStats struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalSeconds int `json:"total_seconds"` // exact wall-clock (audio "Time listened"; minutes lose the remainder)
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
MaxProgress float64 `json:"max_progress"`
|
||||
@@ -542,6 +543,7 @@ func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, m
|
||||
}
|
||||
for i, sess := range sessions {
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
out.TotalSeconds += int(sess.TimeSpentSeconds.Int32)
|
||||
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
OverrideDescription = "description"
|
||||
OverrideCoverImagePath = "cover_image_path"
|
||||
OverrideSeries = "series"
|
||||
OverrideNarrators = "narrators"
|
||||
OverrideAbridged = "abridged"
|
||||
OverrideSeriesNumber = "series_number"
|
||||
OverrideTags = "tags"
|
||||
OverrideAsin = "asin"
|
||||
@@ -154,6 +156,8 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
|
||||
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
||||
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
||||
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
|
||||
add(OverrideAbridged, params.Abridged != existing.Abridged)
|
||||
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
||||
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
||||
@@ -223,6 +227,13 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
||||
params.Tags = existing.Tags
|
||||
params.TagsSearch = existing.TagsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideNarrators) {
|
||||
params.Narrators = existing.Narrators
|
||||
params.NarratorsSearch = existing.NarratorsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideAbridged) {
|
||||
params.Abridged = existing.Abridged
|
||||
}
|
||||
if hasOverride(o, OverrideAsin) {
|
||||
params.Asin = existing.Asin
|
||||
}
|
||||
|
||||
@@ -176,6 +176,13 @@ func NormalizeTagsSearch(tags []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// NormalizeNarratorsSearch mirrors NormalizeTagsSearch for narrator names —
|
||||
// the trigram-search TEXT[] paired with media_items.narrators (filters and
|
||||
// free-text search hit this).
|
||||
func NormalizeNarratorsSearch(narrators []string) []string {
|
||||
return NormalizeTagsSearch(narrators)
|
||||
}
|
||||
|
||||
// NormalizeContributors normalizes an array of contributors for display:
|
||||
// 1. Trim whitespace
|
||||
// 2. Preserve original casing (including CAPSLOCK companies)
|
||||
|
||||
Reference in new issue
Block a user