feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player

Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).

Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).

Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.

Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.

Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
This commit is contained in:
John O'Keefe committed 2026-10-15 16:14:34 -04:00
1 parent a093529b80
commit 902cbe5915
20 files changed
+1425 -212

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
# Final stage # Final stage
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates curl poppler-utils RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
# Install kepubify for EPUB→KEPUB conversion # Install kepubify for EPUB→KEPUB conversion
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
+14 -2
View File
@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS library_types (
INSERT INTO library_types (name, description, allowed_extensions) VALUES INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']), ('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']), ('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']) ('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac'])
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
-- Create users table -- Create users table
@@ -235,7 +236,15 @@ CREATE TABLE IF NOT EXISTS media_items (
-- } -- }
chapter_metadata JSONB, chapter_metadata JSONB,
-- Need library_type_name for checks -- Need library_type_name for checks
library_type_name VARCHAR(50) library_type_name VARCHAR(50),
-- Audiobook metadata (format-scoped JSONB, the kobo_metadata
-- precedent: only the audio paths read it — ebook paths never do).
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
-- "bit_rate": N, "asin": "...", "chapters": [
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
-- The chapters array IS the format abstraction: embedded m4b chapters
-- and (later) folder-as-book files both write this exact shape.
audio_metadata JSONB
); );
-- Add GIN indexes for fast array searches -- Add GIN indexes for fast array searches
@@ -319,6 +328,9 @@ CREATE TABLE IF NOT EXISTS reading_progress (
context_text TEXT, context_text TEXT,
chapter INTEGER, chapter INTEGER,
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1), chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
-- Audiobook locator: seconds into the chapter (honest units, not an
-- overloaded fraction — a 2h chapter at float precision drifts).
audio_offset_seconds INTEGER CHECK (audio_offset_seconds >= 0),
viewport_x FLOAT DEFAULT 0, viewport_x FLOAT DEFAULT 0,
viewport_y FLOAT DEFAULT 0, viewport_y FLOAT DEFAULT 0,
zoom_level FLOAT DEFAULT 1.0, zoom_level FLOAT DEFAULT 1.0,
+26 -24
View File
@@ -312,6 +312,7 @@ type MediaItems struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -416,30 +417,31 @@ type ReadingHistory struct {
} }
type ReadingProgress struct { type ReadingProgress struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"` CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"` TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"` Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"` CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"` Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
ContextText pgtype.Text `db:"context_text" json:"context_text"` ContextText pgtype.Text `db:"context_text" json:"context_text"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"` Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"` ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"` AudioOffsetSeconds pgtype.Int4 `db:"audio_offset_seconds" json:"audio_offset_seconds"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"` ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"` ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"` ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"` ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"` PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"` ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"` LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"` LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"` LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"` ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
} }
type ReadingSpeed struct { type ReadingSpeed struct {
+154 -93
View File
@@ -979,10 +979,10 @@ func (q *Queries) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaH
} }
const CreateMediaItem = `-- name: CreateMediaItem :one const CreateMediaItem = `-- 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) 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) 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)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type CreateMediaItemParams struct { type CreateMediaItemParams struct {
@@ -1030,6 +1030,7 @@ type CreateMediaItemParams struct {
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
} }
// Media Items queries // Media Items queries
@@ -1079,6 +1080,7 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams
arg.ScanInformation, arg.ScanInformation,
arg.Summary, arg.Summary,
arg.LibraryTypeName, arg.LibraryTypeName,
arg.AudioMetadata,
) )
var i MediaItems var i MediaItems
err := row.Scan( err := row.Scan(
@@ -1140,6 +1142,7 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -1200,10 +1203,10 @@ func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItem
} }
const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :one const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :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) 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) 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)
ON CONFLICT (library_id, file_path) DO NOTHING ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type CreateMediaItemIfNewParams struct { type CreateMediaItemIfNewParams struct {
@@ -1251,6 +1254,7 @@ type CreateMediaItemIfNewParams struct {
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
} }
// API create path: race-safe create-or-detect. On a duplicate // API create path: race-safe create-or-detect. On a duplicate
@@ -1303,6 +1307,7 @@ func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemI
arg.ScanInformation, arg.ScanInformation,
arg.Summary, arg.Summary,
arg.LibraryTypeName, arg.LibraryTypeName,
arg.AudioMetadata,
) )
var i MediaItems var i MediaItems
err := row.Scan( err := row.Scan(
@@ -1364,6 +1369,7 @@ func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemI
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -2822,7 +2828,7 @@ func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReading
} }
const GetBooksByTag = `-- name: GetBooksByTag :many const GetBooksByTag = `-- name: GetBooksByTag :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE library_id = $1 AND tags @> ARRAY[$2::text] WHERE library_id = $1 AND tags @> ARRAY[$2::text]
ORDER BY title ASC ORDER BY title ASC
` `
@@ -2900,6 +2906,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -3000,7 +3007,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
} }
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1 WHERE ci.collection_id = $1
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
@@ -3075,6 +3082,7 @@ type GetCollectionItemsForDashboardRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -3152,6 +3160,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -3307,7 +3316,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
} }
const GetContinueReadingItems = `-- name: GetContinueReadingItems :many const GetContinueReadingItems = `-- name: GetContinueReadingItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
INNER JOIN ( INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress FROM reading_progress
@@ -3398,6 +3407,7 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -3429,7 +3439,7 @@ WITH user_series_progress AS (
GROUP BY mi.series GROUP BY mi.series
), ),
next_books AS ( next_books AS (
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence,
usp.last_read_at usp.last_read_at
FROM media_items mi FROM media_items mi
JOIN user_series_progress usp ON mi.series = usp.series JOIN user_series_progress usp ON mi.series = usp.series
@@ -3439,7 +3449,7 @@ next_books AS (
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL) AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
ORDER BY mi.series, mi.series_number ASC NULLS LAST ORDER BY mi.series, mi.series_number ASC NULLS LAST
) )
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books
ORDER BY last_read_at DESC NULLS LAST ORDER BY last_read_at DESC NULLS LAST
LIMIT $1 LIMIT $1
` `
@@ -3509,6 +3519,7 @@ type GetContinueSeriesItemsRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -3586,6 +3597,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -4785,7 +4797,7 @@ func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID)
} }
const GetLibraryItems = `-- name: GetLibraryItems :many const GetLibraryItems = `-- name: GetLibraryItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
@@ -4860,6 +4872,7 @@ func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5289,7 +5302,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights
} }
const GetMediaItem = `-- name: GetMediaItem :one const GetMediaItem = `-- name: GetMediaItem :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1
` `
func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) { func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
@@ -5354,6 +5367,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5365,7 +5379,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
} }
const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2
` `
type GetMediaItemByFilePathParams struct { type GetMediaItemByFilePathParams struct {
@@ -5435,6 +5449,7 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5446,7 +5461,7 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
} }
const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1
` `
func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) { func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) {
@@ -5511,6 +5526,7 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5523,7 +5539,7 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
` `
// ============================================ // ============================================
@@ -5591,6 +5607,7 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5602,7 +5619,7 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
} }
const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1
` `
func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) { func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) {
@@ -5667,6 +5684,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5678,7 +5696,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
} }
const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE library_id = $1 AND file_path = $2 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE library_id = $1 AND file_path = $2
` `
type GetMediaItemByLibraryAndPathParams struct { type GetMediaItemByLibraryAndPathParams struct {
@@ -5748,6 +5766,7 @@ func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMedia
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5759,7 +5778,7 @@ func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMedia
} }
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
` `
// Get media item by OPF identifier // Get media item by OPF identifier
@@ -5825,6 +5844,7 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5836,7 +5856,7 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
} }
const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1
` `
// Get media item by OPF UUID // Get media item by OPF UUID
@@ -5902,6 +5922,7 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5913,7 +5934,7 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
} }
const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1
` `
// Get media item by SHA-256 hash // Get media item by SHA-256 hash
@@ -5979,6 +6000,7 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -5990,7 +6012,7 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
} }
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
` `
type GetMediaItemBySHA256AndLibraryParams struct { type GetMediaItemBySHA256AndLibraryParams struct {
@@ -6061,6 +6083,7 @@ func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMed
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -6187,7 +6210,7 @@ func (q *Queries) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtyp
} }
const GetMediaItemsBySHA256AndSameLibraryType = `-- name: GetMediaItemsBySHA256AndSameLibraryType :many const GetMediaItemsBySHA256AndSameLibraryType = `-- name: GetMediaItemsBySHA256AndSameLibraryType :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, lt.name AS library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, lt.name AS library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -6264,6 +6287,7 @@ type GetMediaItemsBySHA256AndSameLibraryTypeRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -6346,6 +6370,7 @@ func (q *Queries) GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, a
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -6600,7 +6625,7 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) {
} }
const GetNotStartedItems = `-- name: GetNotStartedItems :many const GetNotStartedItems = `-- name: GetNotStartedItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
@@ -6688,6 +6713,7 @@ func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItems
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -6937,7 +6963,7 @@ func (q *Queries) GetReadingHistory(ctx context.Context, arg GetReadingHistoryPa
} }
const GetReadingProgress = `-- name: GetReadingProgress :one const GetReadingProgress = `-- name: GetReadingProgress :one
SELECT id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved FROM reading_progress WHERE media_item_id = $1 AND user_id = $2 SELECT id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved FROM reading_progress WHERE media_item_id = $1 AND user_id = $2
` `
type GetReadingProgressParams struct { type GetReadingProgressParams struct {
@@ -6961,6 +6987,7 @@ func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgress
&i.ContextText, &i.ContextText,
&i.Chapter, &i.Chapter,
&i.ChapterProgress, &i.ChapterProgress,
&i.AudioOffsetSeconds,
&i.ViewportX, &i.ViewportX,
&i.ViewportY, &i.ViewportY,
&i.ZoomLevel, &i.ZoomLevel,
@@ -7005,7 +7032,7 @@ func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams
} }
const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
@@ -7086,6 +7113,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -7104,7 +7132,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
} }
const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
INNER JOIN ( INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress FROM reading_progress
@@ -7193,6 +7221,7 @@ func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadI
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -7311,7 +7340,7 @@ func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams
} }
const GetSeriesBooks = `-- name: GetSeriesBooks :many const GetSeriesBooks = `-- name: GetSeriesBooks :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE series = $1 WHERE series = $1
AND library_id = $2 AND library_id = $2
AND archived_at IS NULL AND archived_at IS NULL
@@ -7395,6 +7424,7 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams)
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -7910,6 +7940,7 @@ SELECT
rp.context_text, rp.context_text,
rp.chapter, rp.chapter,
rp.chapter_progress, rp.chapter_progress,
rp.audio_offset_seconds,
rp.viewport_x, rp.viewport_x,
rp.viewport_y, rp.viewport_y,
rp.zoom_level, rp.zoom_level,
@@ -7939,36 +7970,37 @@ type GetUniversalProgressParams struct {
} }
type GetUniversalProgressRow struct { type GetUniversalProgressRow struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"` CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"` TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"` Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"` CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"` Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
ContextText pgtype.Text `db:"context_text" json:"context_text"` ContextText pgtype.Text `db:"context_text" json:"context_text"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"` Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"` ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"` AudioOffsetSeconds pgtype.Int4 `db:"audio_offset_seconds" json:"audio_offset_seconds"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"` ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"` ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"` ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"` ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"` PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"` ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"` LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"` LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"` LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"` ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
FormatGroup string `db:"format_group" json:"format_group"` ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"` FormatGroup string `db:"format_group" json:"format_group"`
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"` FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"` IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"` HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"` TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
} }
// Get universal progress for a book // Get universal progress for a book
@@ -7988,6 +8020,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
&i.ContextText, &i.ContextText,
&i.Chapter, &i.Chapter,
&i.ChapterProgress, &i.ChapterProgress,
&i.AudioOffsetSeconds,
&i.ViewportX, &i.ViewportX,
&i.ViewportY, &i.ViewportY,
&i.ZoomLevel, &i.ZoomLevel,
@@ -9040,7 +9073,7 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
} }
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[])) AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
@@ -9126,6 +9159,7 @@ func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibl
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -9646,7 +9680,7 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]
} }
const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :many const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name, SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name,
twin.id AS match_id, COALESCE(twin.title, '') AS match_title, twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
twin_library.name AS match_library_name twin_library.name AS match_library_name
FROM media_items mi FROM media_items mi
@@ -9731,6 +9765,7 @@ type ListHiddenMediaItemsRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -9818,6 +9853,7 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -9890,7 +9926,7 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
} }
const ListMediaItems = `-- name: ListMediaItems :many const ListMediaItems = `-- name: ListMediaItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -9965,6 +10001,7 @@ type ListMediaItemsRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -10043,6 +10080,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -10063,7 +10101,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
} }
const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -10132,6 +10170,7 @@ type ListMediaItemsByLibraryRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -10210,6 +10249,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -10230,7 +10270,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
} }
const ListMediaItemsByLibraryIncludingArchived = `-- name: ListMediaItemsByLibraryIncludingArchived :many const ListMediaItemsByLibraryIncludingArchived = `-- name: ListMediaItemsByLibraryIncludingArchived :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -10297,6 +10337,7 @@ type ListMediaItemsByLibraryIncludingArchivedRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -10375,6 +10416,7 @@ func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context,
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -10395,7 +10437,7 @@ func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context,
} }
const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
` `
type ListMediaItemsBySHA256AndLibraryParams struct { type ListMediaItemsBySHA256AndLibraryParams struct {
@@ -10472,6 +10514,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -10490,7 +10533,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
} }
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
` `
// List media items that have no stored SHA-256 (imported before hashing existed) // List media items that have no stored SHA-256 (imported before hashing existed)
@@ -10562,6 +10605,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -10580,7 +10624,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
} }
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -10722,6 +10766,7 @@ type ListMediaItemsSortedRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -10805,6 +10850,7 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -12113,7 +12159,7 @@ func (q *Queries) SearchLanguageValues(ctx context.Context, arg SearchLanguageVa
} }
const SearchMediaItems = `-- name: SearchMediaItems :many const SearchMediaItems = `-- name: SearchMediaItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
@@ -12208,6 +12254,7 @@ type SearchMediaItemsRow struct {
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
@@ -12293,6 +12340,7 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -13836,9 +13884,10 @@ UPDATE media_items SET
scan_information = $36, scan_information = $36,
summary = $37, summary = $37,
metadata_overrides = $38, metadata_overrides = $38,
audio_metadata = $39,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemParams struct { type UpdateMediaItemParams struct {
@@ -13880,6 +13929,7 @@ type UpdateMediaItemParams struct {
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"` MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
} }
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) { func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
@@ -13922,6 +13972,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
arg.ScanInformation, arg.ScanInformation,
arg.Summary, arg.Summary,
arg.MetadataOverrides, arg.MetadataOverrides,
arg.AudioMetadata,
) )
var i MediaItems var i MediaItems
err := row.Scan( err := row.Scan(
@@ -13983,6 +14034,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -13997,7 +14049,7 @@ const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata
UPDATE media_items UPDATE media_items
SET chapter_metadata = $2, updated_at = NOW() SET chapter_metadata = $2, updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemChapterMetadataParams struct { type UpdateMediaItemChapterMetadataParams struct {
@@ -14067,6 +14119,7 @@ func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg Update
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -14172,7 +14225,7 @@ SET
hash_confidence = $5, hash_confidence = $5,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemIdentifiersParams struct { type UpdateMediaItemIdentifiersParams struct {
@@ -14256,6 +14309,7 @@ func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMedi
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -14274,7 +14328,7 @@ SET entitlement_id = $2,
kobo_metadata = $5, kobo_metadata = $5,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, 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, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemKoboMetadataParams struct { type UpdateMediaItemKoboMetadataParams struct {
@@ -14353,6 +14407,7 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed
&i.ArchivedAt, &i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch, &i.TagsSearch,
&i.ContributorsSearch, &i.ContributorsSearch,
&i.FileSha256, &i.FileSha256,
@@ -14538,7 +14593,7 @@ DO UPDATE SET
current_page = EXCLUDED.current_page, current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages, total_pages = EXCLUDED.total_pages,
last_read_at = NOW() last_read_at = NOW()
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
` `
type UpdateReadingProgressParams struct { type UpdateReadingProgressParams struct {
@@ -14569,6 +14624,7 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr
&i.ContextText, &i.ContextText,
&i.Chapter, &i.Chapter,
&i.ChapterProgress, &i.ChapterProgress,
&i.AudioOffsetSeconds,
&i.ViewportX, &i.ViewportX,
&i.ViewportY, &i.ViewportY,
&i.ZoomLevel, &i.ZoomLevel,
@@ -14739,10 +14795,11 @@ INSERT INTO reading_progress (
last_sync_timestamp, last_sync_timestamp,
current_page, current_page,
total_pages, total_pages,
audio_offset_seconds,
last_read_at last_read_at
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW() $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, NOW()
) )
ON CONFLICT (media_item_id, user_id) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET DO UPDATE SET
@@ -14764,30 +14821,32 @@ DO UPDATE SET
last_sync_timestamp = EXCLUDED.last_sync_timestamp, last_sync_timestamp = EXCLUDED.last_sync_timestamp,
current_page = EXCLUDED.current_page, current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages, total_pages = EXCLUDED.total_pages,
audio_offset_seconds = EXCLUDED.audio_offset_seconds,
last_read_at = NOW() last_read_at = NOW()
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
` `
type UpdateUniversalProgressParams struct { type UpdateUniversalProgressParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"` Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"` CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"` Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
ContextText pgtype.Text `db:"context_text" json:"context_text"` ContextText pgtype.Text `db:"context_text" json:"context_text"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"` Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"` ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"` ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"` ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"` ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"` ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"` ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"` ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"` LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"` LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"` CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"` TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
AudioOffsetSeconds pgtype.Int4 `db:"audio_offset_seconds" json:"audio_offset_seconds"`
} }
// Update universal progress // Update universal progress
@@ -14812,6 +14871,7 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
arg.LastSyncSource, arg.LastSyncSource,
arg.CurrentPage, arg.CurrentPage,
arg.TotalPages, arg.TotalPages,
arg.AudioOffsetSeconds,
) )
var i ReadingProgress var i ReadingProgress
err := row.Scan( err := row.Scan(
@@ -14827,6 +14887,7 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
&i.ContextText, &i.ContextText,
&i.Chapter, &i.Chapter,
&i.ChapterProgress, &i.ChapterProgress,
&i.AudioOffsetSeconds,
&i.ViewportX, &i.ViewportX,
&i.ViewportY, &i.ViewportY,
&i.ZoomLevel, &i.ZoomLevel,
+9 -5
View File
@@ -149,8 +149,8 @@ GROUP BY l.id;
-- Media Items queries -- Media Items queries
-- name: CreateMediaItem :one -- 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) 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) 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)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *; RETURNING *;
@@ -159,8 +159,8 @@ RETURNING *;
-- (library_id, file_path) this inserts NOTHING and returns no row -- (library_id, file_path) this inserts NOTHING and returns no row
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row -- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
-- and a duplicate flag instead of pretending it created something. -- 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) 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) 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)
ON CONFLICT (library_id, file_path) DO NOTHING ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING *; RETURNING *;
@@ -313,6 +313,7 @@ UPDATE media_items SET
scan_information = $36, scan_information = $36,
summary = $37, summary = $37,
metadata_overrides = $38, metadata_overrides = $38,
audio_metadata = $39,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
@@ -1338,6 +1339,7 @@ SELECT
rp.context_text, rp.context_text,
rp.chapter, rp.chapter,
rp.chapter_progress, rp.chapter_progress,
rp.audio_offset_seconds,
rp.viewport_x, rp.viewport_x,
rp.viewport_y, rp.viewport_y,
rp.zoom_level, rp.zoom_level,
@@ -1383,10 +1385,11 @@ INSERT INTO reading_progress (
last_sync_timestamp, last_sync_timestamp,
current_page, current_page,
total_pages, total_pages,
audio_offset_seconds,
last_read_at last_read_at
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW() $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, NOW()
) )
ON CONFLICT (media_item_id, user_id) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET DO UPDATE SET
@@ -1408,6 +1411,7 @@ DO UPDATE SET
last_sync_timestamp = EXCLUDED.last_sync_timestamp, last_sync_timestamp = EXCLUDED.last_sync_timestamp,
current_page = EXCLUDED.current_page, current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages, total_pages = EXCLUDED.total_pages,
audio_offset_seconds = EXCLUDED.audio_offset_seconds,
last_read_at = NOW() last_read_at = NOW()
RETURNING *; RETURNING *;
+58 -20
View File
@@ -1237,7 +1237,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
} }
} }
var req struct { var req struct {
CurrentPage *int32 `json:"current_page"` CurrentPage *int32 `json:"current_page"`
TotalPages *int32 `json:"total_pages"` TotalPages *int32 `json:"total_pages"`
@@ -1251,6 +1250,11 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
ZoomLevel *float64 `json:"zoom_level"` ZoomLevel *float64 `json:"zoom_level"`
ScrollX *float64 `json:"scroll_position_x"` ScrollX *float64 `json:"scroll_position_x"`
ScrollY *float64 `json:"scroll_position_y"` ScrollY *float64 `json:"scroll_position_y"`
// Audiobook locator: seconds into the current chapter
AudioOffsetSeconds *int `json:"audio_offset_seconds"`
// Honest wall-clock playing seconds since the last push (audio
// pushes fire while playing; pauses don't count)
ElapsedSeconds *int32 `json:"elapsed_seconds"`
} }
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -1258,25 +1262,27 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
if mh.progressSvc != nil { if mh.progressSvc != nil {
saveReq := wsync.SaveProgressRequest{ saveReq := wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Source: deviceSource, Source: deviceSource,
DeviceID: deviceID, DeviceID: deviceID,
DeviceType: deviceType, DeviceType: deviceType,
DeviceName: deviceLabel, DeviceName: deviceLabel,
Percentage: req.Percentage, Percentage: req.Percentage,
Epubcfi: req.Epubcfi, Epubcfi: req.Epubcfi,
ContextText: req.ContextText, ContextText: req.ContextText,
CharacterOffset: req.CharacterOffset, CharacterOffset: req.CharacterOffset,
Chapter: req.Chapter, Chapter: req.Chapter,
ChapterProgress: req.ChapterProgress, ChapterProgress: req.ChapterProgress,
CurrentPage: nil, AudioOffsetSeconds: req.AudioOffsetSeconds,
TotalPages: nil, AudioElapsedSeconds: req.ElapsedSeconds,
ZoomLevel: req.ZoomLevel, CurrentPage: nil,
ScrollX: req.ScrollX, TotalPages: nil,
ScrollY: req.ScrollY, ZoomLevel: req.ZoomLevel,
ReadingMode: req.ReadingMode, ScrollX: req.ScrollX,
Broadcast: true, ScrollY: req.ScrollY,
ReadingMode: req.ReadingMode,
Broadcast: true,
} }
if req.CurrentPage != nil { if req.CurrentPage != nil {
cp := int(*req.CurrentPage) cp := int(*req.CurrentPage)
@@ -2759,3 +2765,35 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
return coverRelPath, nil return coverRelPath, nil
} }
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
// format-abstraction chapter list for audiobooks (media_items
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
// the same shape. Empty list when the item isn't audio or hasn't been
// probed.
func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
var payload services.AudioMetadataPayload
if len(item.AudioMetadata) > 0 {
if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
})
}
+4
View File
@@ -18,6 +18,10 @@ func registerMediaRoutes(cfg *Config) {
// Book download endpoint - same ServeFile flow as /uploads/library-:id/* // Book download endpoint - same ServeFile flow as /uploads/library-:id/*
// (JWT + library-visibility gated), addressed by media item ID. // (JWT + library-visibility gated), addressed by media item ID.
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile) protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
// The format-abstraction chapter list (audio_metadata). Streaming
// itself rides the existing /uploads Range-serving path with cookie
// auth — no separate endpoint.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
// Media rating routes (all authenticated users) // Media rating routes (all authenticated users)
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating) protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
+11 -2
View File
@@ -83,7 +83,10 @@ func registerReaderRoutes(cfg *Config) {
MediaItemID: mediaUUID.String(), MediaItemID: mediaUUID.String(),
Title: mediaItem.Title, Title: mediaItem.Title,
Author: textToString(mediaItem.Author), Author: textToString(mediaItem.Author),
CoverImagePath: textToString(mediaItem.CoverImagePath), // Sidecar covers store a raw library-relative path; resolve to
// the cookie-authed /uploads URL (idempotent for items whose
// stored value is already a servable path).
CoverImagePath: utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath),
LibraryType: mediaItem.FormatGroup, LibraryType: mediaItem.FormatGroup,
MimeType: textToString(mediaItem.MimeType), MimeType: textToString(mediaItem.MimeType),
FilePath: mediaItem.FilePath, FilePath: mediaItem.FilePath,
@@ -100,7 +103,13 @@ func registerReaderRoutes(cfg *Config) {
} }
// Render template // Render template
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf) // Audiobooks get the audio player page — the foliate document
// engine is deliberately not involved (handoff item 3 Batch A).
if mediaItem.FormatGroup == "audiobook" {
err = templates.AudioReader(user, metadata).Render(c.Request().Context(), &buf)
} else {
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
}
if err != nil { if err != nil {
return renderErrorPage(c, "Error rendering reader", "render_error") return renderErrorPage(c, "Error rendering reader", "render_error")
} }
+8
View File
@@ -12,6 +12,7 @@ import (
"bytes" "bytes"
"context" "context"
"log" "log"
"mime"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -209,6 +210,13 @@ func wantsHTML(header http.Header) bool {
// RegisterRoutes registers all application routes // RegisterRoutes registers all application routes
func RegisterRoutes(cfg *Config) *handlers.Handler { func RegisterRoutes(cfg *Config) *handlers.Handler {
// Go's builtin mime table doesn't know the audiobook containers; the
// /uploads file serving and any ServeContent path needs real audio
// content types or media elements refuse the stream.
mime.AddExtensionType(".m4b", "audio/mp4")
mime.AddExtensionType(".m4a", "audio/mp4")
mime.AddExtensionType(".mp3", "audio/mpeg")
mime.AddExtensionType(".flac", "audio/flac")
e := cfg.Echo e := cfg.Echo
// Set up validator // Set up validator
+14 -3
View File
@@ -24,15 +24,20 @@ func NewLibraryService(db *database.Queries) *LibraryService {
// Library type definitions and file extensions // Library type definitions and file extensions
const ( const (
LibraryTypeEbooks = "ebooks" LibraryTypeEbooks = "ebooks"
LibraryTypeComics = "comics" LibraryTypeComics = "comics"
LibraryTypeManga = "manga" LibraryTypeManga = "manga"
LibraryTypeAudiobooks = "audiobooks"
) )
var AllowedExtensions = map[string][]string{ var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"}, LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"}, LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"}, LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
// All four decided breadth formats registered from day one (the
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
// scans richly in Batch A — the chapter abstraction carries the rest.
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
} }
var MimeTypes = map[string]string{ var MimeTypes = map[string]string{
@@ -62,6 +67,12 @@ var MimeTypes = map[string]string{
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".fb2": "application/x-fictionbook+xml", ".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm", ".pdb": "application/vnd.palm",
// Audiobooks — audio/mp4 for the MP4-family containers is what
// every platform decoder expects from an <audio>/ExoPlayer source.
".m4b": "audio/mp4",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".flac": "audio/flac",
} }
// GetLibraryTypes retrieves all available library types // GetLibraryTypes retrieves all available library types
+103 -26
View File
@@ -89,6 +89,10 @@ type MediaMetadata struct {
AlternateInfo string // JSONB string of alternate series info AlternateInfo string // JSONB string of alternate series info
ScanInformation string // Scan information ScanInformation string // Scan information
Summary string // Summary from ComicInfo.xml Summary string // Summary from ComicInfo.xml
// Audiobook fields (media_items.audio_metadata JSONB — only the
// audio paths read it; see media_scanner_audio.go)
AudioMeta *AudioMetadataPayload
} }
type HashInfo struct { type HashInfo struct {
@@ -774,6 +778,16 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
metadata = &MediaMetadata{} metadata = &MediaMetadata{}
} }
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
// shape is the chapter abstraction every format writes).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Extract hash information during metadata extraction // Extract hash information during metadata extraction
hashInfo, formatInfo, err := s.extractHashInfo(path) hashInfo, formatInfo, err := s.extractHashInfo(path)
if err != nil { if err != nil {
@@ -952,6 +966,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""}, Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0}, CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0}, PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
}) })
if err != nil { if err != nil {
return false, fmt.Errorf("failed to create media item: %v", err) return false, fmt.Errorf("failed to create media item: %v", err)
@@ -1055,9 +1070,10 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
} }
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style // extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
// metadata.json sidecar next to the media file. Only fields with a matching // metadata.json sidecar next to the media file. Common fields map to
// media_items column are mapped; narrators, subtitle, explicit, abridged and // media_items columns; the audio-only fields (narrators, curated chapters)
// chapters are deliberately skipped. Returns nil when no sidecar exists. // travel through the audio payload separately (readSidecarAudio). Returns
// nil when no sidecar exists.
func extractAudiobookshelfSidecar(path string) *MediaMetadata { func extractAudiobookshelfSidecar(path string) *MediaMetadata {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json") jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
if _, err := os.Stat(jsonPath); os.IsNotExist(err) { if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
@@ -1070,29 +1086,49 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
return nil return nil
} }
// Series is tolerant: ABS/Calibre sidecars write an ARRAY of
// {series, sequence} objects; Libation writes a plain STRING (or
// null). An array-typed field made Libation's whole sidecar fail to
// parse and silently fall to folder-structure metadata (found by
// the audiobook ingest).
var sidecar struct { var sidecar struct {
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"` Subtitle string `json:"subtitle"`
Authors []string `json:"authors"` Authors []string `json:"authors"`
Series []struct { Series json.RawMessage `json:"series"`
Series string `json:"series"` Genres []string `json:"genres"`
Sequence string `json:"sequence"` Tags []string `json:"tags"`
} `json:"series"` PublishedYear json.RawMessage `json:"publishedYear"`
Genres []string `json:"genres"` PublishedDate *string `json:"publishedDate"`
Tags []string `json:"tags"` Publisher *string `json:"publisher"`
PublishedYear *int `json:"publishedYear"` Description *string `json:"description"`
PublishedDate *string `json:"publishedDate"` ISBN *string `json:"isbn"`
Publisher *string `json:"publisher"` ASIN *string `json:"asin"`
Description *string `json:"description"` Language *string `json:"language"`
ISBN *string `json:"isbn"`
ASIN *string `json:"asin"`
Language *string `json:"language"`
} }
if err := json.Unmarshal(data, &sidecar); err != nil { if err := json.Unmarshal(data, &sidecar); err != nil {
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err) fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
return nil return nil
} }
var seriesName, seriesSequence string
var seriesArr []struct {
Series string `json:"series"`
Sequence string `json:"sequence"`
}
if len(sidecar.Series) > 0 {
trimmed := strings.TrimSpace(string(sidecar.Series))
if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") {
if err := json.Unmarshal(sidecar.Series, &seriesArr); err == nil && len(seriesArr) > 0 {
seriesName = seriesArr[0].Series
seriesSequence = seriesArr[0].Sequence
}
} else if trimmed != "null" && trimmed != "" {
// Plain string form (Libation)
seriesName = strings.Trim(trimmed, "\"")
}
}
metadata := &MediaMetadata{ metadata := &MediaMetadata{
Title: strings.TrimSpace(sidecar.Title), Title: strings.TrimSpace(sidecar.Title),
} }
@@ -1104,9 +1140,9 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
if len(sidecar.Authors) > 0 { if len(sidecar.Authors) > 0 {
metadata.Author = strings.TrimSpace(sidecar.Authors[0]) metadata.Author = strings.TrimSpace(sidecar.Authors[0])
} }
if len(sidecar.Series) > 0 { if seriesName != "" {
metadata.Series = strings.TrimSpace(sidecar.Series[0].Series) metadata.Series = strings.TrimSpace(seriesName)
if index, err := strconv.ParseFloat(strings.TrimSpace(sidecar.Series[0].Sequence), 32); err == nil { if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
metadata.SeriesNumber = int32(index) metadata.SeriesNumber = int32(index)
} }
} }
@@ -1118,8 +1154,12 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
metadata.PublishDate = date metadata.PublishDate = date
} }
} }
if metadata.PublishDate.IsZero() && sidecar.PublishedYear != nil && *sidecar.PublishedYear > 0 { if metadata.PublishDate.IsZero() {
metadata.PublishDate = time.Date(*sidecar.PublishedYear, 1, 1, 0, 0, 0, 0, time.UTC) // publishedYear tolerates number AND string forms ("2009" from
// Libation lives as a string).
if pubYear := parseJSONInt(sidecar.PublishedYear); pubYear > 0 {
metadata.PublishDate = time.Date(pubYear, 1, 1, 0, 0, 0, 0, time.UTC)
}
} }
if sidecar.Publisher != nil { if sidecar.Publisher != nil {
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher) metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
@@ -1140,8 +1180,6 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
return metadata return metadata
} }
// extractAudiobookshelfSidecar-TMP-END
// mergeMetadata intelligently merges metadata from multiple sources // mergeMetadata intelligently merges metadata from multiple sources
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename // Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename // For comics: metadata.opf → ComicInfo.xml → folder structure → filename
@@ -3100,6 +3138,39 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
metadata = &MediaMetadata{} metadata = &MediaMetadata{}
} }
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Folder-structure fallback, mirroring the CREATE path
// (processMediaFile): the update path skipped it, so a force rescan
// could clear series/author that a sidecar didn't carry (Libation
// writes "series": null while the folder layout knows the series).
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
}
if metadata.Author == "" && folderMetadata.Author != "" {
metadata.Author = folderMetadata.Author
}
if metadata.Series == "" && folderMetadata.Series != "" {
metadata.Series = folderMetadata.Series
}
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
metadata.SeriesNumber = folderMetadata.SeriesNumber
}
}
break
}
}
// Normalize metadata fields // Normalize metadata fields
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors) metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
metadata.Tags = utils.NormalizeTags(metadata.Tags) metadata.Tags = utils.NormalizeTags(metadata.Tags)
@@ -3144,6 +3215,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
AlternateInfo: alternateInfoBytes, AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""}, ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""}, Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
} }
// Keep user-customized fields, and keep the override set itself intact. // Keep user-customized fields, and keep the override set itself intact.
@@ -3905,6 +3977,11 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
return "fixed_layout", false, true return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt": case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true return "comic_archive", false, true
case ".m4b", ".m4a", ".mp3", ".flac":
// Audio is neither reflowable text nor fixed-layout pages; the
// "audiobook" group routes readers to the audio player instead
// of any document engine.
return "audiobook", false, false
default: default:
return "unknown", false, false return "unknown", false, false
} }
+278
View File
@@ -0,0 +1,278 @@
package services
// Audiobook ingestion (handoff item 3, Batch A). The m4b path is
// SIDECAR-FIRST: Libation (and Audiobookshelf-compatible tools) write a
// metadata.json next to the file carrying narrators/series/ASIN/chapters;
// ffprobe is the fallback (and the filler) for duration, codec profile,
// bitrate and embedded chapters. ffprobe is an OPTIONAL dependency — when
// absent the book still ingests (filename/folder metadata, no chapters
// until a rescan runs with ffprobe present).
//
// The chapters array written into media_items.audio_metadata IS the
// format abstraction: embedded m4b chapters and (later) folder-as-book
// files both produce {index, title, start, end} entries, so the locator
// {chapter, offset_seconds} and the /chapters endpoint never learn which
// file model produced them.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// AudioChapter is the uniform chapter entry for every audiobook file model.
type AudioChapter struct {
Index int `json:"index"`
Title string `json:"title"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
// Only the audio paths read it — ebook paths never do (the kobo_metadata
// precedent).
type AudioMetadataPayload struct {
Narrators []string `json:"narrators,omitempty"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
CodecProfile string `json:"codec_profile,omitempty"`
BitRate int64 `json:"bit_rate,omitempty"`
ASIN string `json:"asin,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
}
func isAudioFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".m4b", ".m4a", ".mp3", ".flac":
return true
}
return false
}
// ffprobeAvailable is resolved once per scan process; a missing binary is
// a degradation, not an error.
var ffprobeAvailable = func() bool {
_, err := exec.LookPath("ffprobe")
return err == nil
}()
type ffprobeReport struct {
Format struct {
Duration flexFloat `json:"duration"`
BitRate flexFloat `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
SampleRate string `json:"sample_rate"`
} `json:"streams"`
Chapters []struct {
ID int `json:"id"`
StartTime flexFloat `json:"start_time"`
EndTime flexFloat `json:"end_time"`
Tags struct {
Title string `json:"title"`
} `json:"tags"`
} `json:"chapters"`
}
// flexFloat accepts JSON numbers OR number-strings: ffprobe's output
// switched to strings for chapter timings in the ffmpeg 8 build
// (Libation ingest exposed both shapes across formats).
type flexFloat float64
func (f *flexFloat) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(string(data))
s = strings.Trim(s, "\"")
if s == "" || s == "null" || s == "N/A" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
*f = flexFloat(v)
return nil
}
// runFFProbe shells out to ffprobe for the container facts the sidecar
// doesn't carry (duration/bitrate/codec). Returns nil when ffprobe is
// absent or the probe fails — callers degrade gracefully. Failures log:
// a silent nil made a real exec failure indistinguishable from a missing
// binary (found by the Libation ingest).
func runFFProbe(path string) *ffprobeReport {
if !ffprobeAvailable {
fmt.Printf("ffprobe unavailable (not on PATH); audio facts degrade to sidecar-only for %s\n", path)
return nil
}
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams", "-show_chapters",
path,
)
out, err := cmd.Output()
if err != nil {
fmt.Printf("ffprobe failed for %s: %v\n", path, err)
return nil
}
if len(out) == 0 {
fmt.Printf("ffprobe produced no output for %s\n", path)
return nil
}
var report ffprobeReport
if err := json.Unmarshal(out, &report); err != nil {
fmt.Printf("ffprobe output unparseable for %s: %v\n", path, err)
return nil
}
return &report
}
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
// seeded) audio payload, creating it when no sidecar existed. Sidecar
// narrators/chapters win (curated); ffprobe fills duration, codec,
// bitrate, and chapters only when the sidecar carried none.
func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata) {
if metadata == nil {
return
}
payload := metadata.AudioMeta
if payload == nil {
payload = &AudioMetadataPayload{}
metadata.AudioMeta = payload
}
report := runFFProbe(path)
if report == nil {
fmt.Printf("Audio metadata for %s: sidecar only (ffprobe absent or failed)\n", path)
} else {
if payload.DurationSeconds == 0 && report.Format.Duration > 0 {
payload.DurationSeconds = float64(report.Format.Duration)
}
if payload.BitRate == 0 && report.Format.BitRate > 0 {
payload.BitRate = int64(report.Format.BitRate)
}
if payload.CodecProfile == "" && len(report.Streams) > 0 {
st := report.Streams[0]
profile := st.CodecName
if st.Profile != "" && st.Profile != "unknown" {
profile += " / " + st.Profile
}
payload.CodecProfile = profile
}
if len(payload.Chapters) == 0 {
for _, ch := range report.Chapters {
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: ch.ID,
Title: ch.Tags.Title,
Start: float64(ch.StartTime),
End: float64(ch.EndTime),
})
}
}
}
// The payload's ASIN mirrors the item column for the resolver; keep
// them in sync from whichever source set it.
if payload.ASIN == "" {
payload.ASIN = metadata.ASIN
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
// Libation/Audiobookshelf metadata.json into the payload (narrators and
// the curated chapter list).
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
var sidecar struct {
Narrators []string `json:"narrators"`
ASIN *string `json:"asin"`
Chapters []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
ID int `json:"id"`
} `json:"chapters"`
}
if err := json.Unmarshal(data, &sidecar); err != nil {
return nil
}
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil {
return nil
}
payload := &AudioMetadataPayload{
Narrators: sidecar.Narrators,
}
if sidecar.ASIN != nil {
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
for i, ch := range sidecar.Chapters {
idx := ch.ID
if idx == 0 {
idx = i
}
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: idx,
Title: strings.TrimSpace(ch.Title),
Start: ch.Start,
End: ch.End,
})
}
return payload
}
// readSidecarAudio is the sidecar hook used by the ABS metadata.json
// path: same file the common-field parser already read, audio fields only.
func readSidecarAudio(path string) *AudioMetadataPayload {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
data, err := os.ReadFile(jsonPath)
if err != nil {
return nil
}
return parseSidecarAudioFields(path, data)
}
// marshalAudioMetadata serializes the payload for the DB column; nil
// payload → nil bytes (NULL column).
func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
if payload == nil {
return nil
}
out, err := json.Marshal(payload)
if err != nil {
return nil
}
return out
}
// audioDurationLabel renders the duration for logs/detail surfaces.
func audioDurationLabel(seconds float64) string {
if seconds <= 0 {
return "—"
}
d := time.Duration(seconds) * time.Second
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}
// parseJSONInt reads a RawMessage that may hold a JSON number or a
// number-string; 0 for anything else. Used for sidecar fields whose
// type differs across producing tools (Libation vs Calibre/ABS).
func parseJSONInt(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
trimmed := strings.TrimSpace(string(raw))
if v, err := strconv.Atoi(strings.Trim(trimmed, "\"")); err == nil {
return v
}
return 0
}
+30 -30
View File
@@ -83,36 +83,36 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
libUUID := uuid.New() libUUID := uuid.New()
row := database.GetContinueSeriesItemsRow{ row := database.GetContinueSeriesItemsRow{
ID: pgtype.UUID{Bytes: itemUUID, Valid: true}, ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Full Book", Title: "Full Book",
Author: pgtype.Text{String: "Author", Valid: true}, Author: pgtype.Text{String: "Author", Valid: true},
Isbn: pgtype.Text{String: "ISBN", Valid: true}, Isbn: pgtype.Text{String: "ISBN", Valid: true},
Description: pgtype.Text{String: "Desc", Valid: true}, Description: pgtype.Text{String: "Desc", Valid: true},
FilePath: "/book.epub", FilePath: "/book.epub",
FileSize: pgtype.Int8{Int64: 2048, Valid: true}, FileSize: pgtype.Int8{Int64: 2048, Valid: true},
MimeType: pgtype.Text{String: "epub", Valid: true}, MimeType: pgtype.Text{String: "epub", Valid: true},
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true}, CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
Series: pgtype.Text{String: "Series", Valid: true}, Series: pgtype.Text{String: "Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true}, SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
Tags: []string{"tag1"}, Tags: []string{"tag1"},
Asin: pgtype.Text{String: "ASIN", Valid: true}, Asin: pgtype.Text{String: "ASIN", Valid: true},
Publisher: pgtype.Text{String: "Pub", Valid: true}, Publisher: pgtype.Text{String: "Pub", Valid: true},
Language: pgtype.Text{String: "en", Valid: true}, Language: pgtype.Text{String: "en", Valid: true},
Edition: pgtype.Text{String: "1st", Valid: true}, Edition: pgtype.Text{String: "1st", Valid: true},
Genre: pgtype.Text{String: "Fiction", Valid: true}, Genre: pgtype.Text{String: "Fiction", Valid: true},
FormatGroup: "epub", FormatGroup: "epub",
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true}, FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
IsReflowable: pgtype.Bool{Bool: true, Valid: true}, IsReflowable: pgtype.Bool{Bool: true, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true}, HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true}, TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
ChapterCount: pgtype.Int4{Int32: 20, Valid: true}, ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
MangaType: pgtype.Text{String: "manga", Valid: true}, MangaType: pgtype.Text{String: "manga", Valid: true},
ReadingDirection: pgtype.Text{String: "rtl", Valid: true}, ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
SeriesCount: pgtype.Int4{Int32: 10, Valid: true}, SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
Volume: pgtype.Int4{Int32: 1, Valid: true}, Volume: pgtype.Int4{Int32: 1, Valid: true},
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true}, LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
FileSha256: pgtype.Text{String: "abc123", Valid: true}, FileSha256: pgtype.Text{String: "abc123", Valid: true},
} }
result := continueSeriesRowToMediaItems(row) result := continueSeriesRowToMediaItems(row)
+3 -3
View File
@@ -143,9 +143,9 @@ type UserSummaryStats struct {
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
MostActiveDay string `json:"most_active_day"` // date with the most minutes MostActiveDay string `json:"most_active_day"` // date with the most minutes
MostActiveMinutes int `json:"most_active_day_minutes"` MostActiveMinutes int `json:"most_active_day_minutes"`
TopBooks []TopBookStats `json:"top_books"` TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"` LibraryBreakdown []LibraryStats `json:"library_breakdown"`
DeviceBreakdown []DeviceStats `json:"device_breakdown"` DeviceBreakdown []DeviceStats `json:"device_breakdown"`
} }
type WeekSummaryStats struct { type WeekSummaryStats struct {
+2 -3
View File
@@ -73,8 +73,8 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in
} }
type JobResult struct { type JobResult struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
Status JobStatus `json:"status"` Status JobStatus `json:"status"`
// LibraryID carries the scan job's library (from Params) so clients // LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original // can label progress rows after a page refresh, when the original
// start-request context is gone. // start-request context is gone.
@@ -1026,4 +1026,3 @@ func (w *Worker) Shutdown() {
close(w.jobQueue) close(w.jobQueue)
w.wg.Wait() w.wg.Wait()
} }
+22
View File
@@ -31,6 +31,10 @@ type ProgressData struct {
Panel *int `json:"panel,omitempty"` Panel *int `json:"panel,omitempty"`
ReadingMode *string `json:"reading_mode,omitempty"` ReadingMode *string `json:"reading_mode,omitempty"`
TotalCharacters *int64 `json:"total_characters,omitempty"` TotalCharacters *int64 `json:"total_characters,omitempty"`
// Audiobook locator: seconds into the current chapter. Pairs with
// Chapter; the anti-reset guard still owns pushes that would drag
// the percentage backwards.
AudioOffsetSeconds *int `json:"audio_offset_seconds,omitempty"`
} }
// DeviceProgress represents progress from a specific device // DeviceProgress represents progress from a specific device
@@ -311,6 +315,14 @@ type SaveProgressRequest struct {
ScrollY *float64 ScrollY *float64
PanelNumber *int PanelNumber *int
ReadingMode *string ReadingMode *string
// Audiobook locator (seconds into the current chapter)
AudioOffsetSeconds *int
// AudioElapsedSeconds: the player's honest wall-clock PLAYING time
// since the last push (audio pushes fire while playing; pauses don't
// count). The capture prefers it over the inter-push delta, capped to
// the same 2h attributed ceiling.
AudioElapsedSeconds *int32
DeviceType string DeviceType string
DeviceName string DeviceName string
@@ -358,6 +370,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.ContextText = existing.ContextText params.ContextText = existing.ContextText
params.Chapter = existing.Chapter params.Chapter = existing.Chapter
params.ChapterProgress = existing.ChapterProgress params.ChapterProgress = existing.ChapterProgress
params.AudioOffsetSeconds = existing.AudioOffsetSeconds
params.ViewportX = existing.ViewportX params.ViewportX = existing.ViewportX
params.ViewportY = existing.ViewportY params.ViewportY = existing.ViewportY
params.ZoomLevel = existing.ZoomLevel params.ZoomLevel = existing.ZoomLevel
@@ -389,6 +402,9 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
if req.ChapterProgress != nil { if req.ChapterProgress != nil {
params.ChapterProgress = pgtype.Float8{Float64: *req.ChapterProgress, Valid: true} params.ChapterProgress = pgtype.Float8{Float64: *req.ChapterProgress, Valid: true}
} }
if req.AudioOffsetSeconds != nil {
params.AudioOffsetSeconds = pgtype.Int4{Int32: int32(*req.AudioOffsetSeconds), Valid: true}
}
if req.CurrentPage != nil { if req.CurrentPage != nil {
params.CurrentPage = pgtype.Int4{Int32: int32(*req.CurrentPage), Valid: true} params.CurrentPage = pgtype.Int4{Int32: int32(*req.CurrentPage), Valid: true}
} }
@@ -628,6 +644,12 @@ func (s *ProgressService) captureReadingSession(ctx context.Context, req SavePro
seconds = elapsed // verified-continuous span seconds = elapsed // verified-continuous span
} // else: idle gap — marker row, zero time } // else: idle gap — marker row, zero time
} }
// Wall-clock override: honest player-reported playing time beats
// the inter-push delta (pauses shouldn't count as reading). Same
// 2h ceiling applies.
if req.AudioElapsedSeconds != nil && *req.AudioElapsedSeconds > 0 && *req.AudioElapsedSeconds <= maxAttributedSessionSeconds {
seconds = *req.AudioElapsedSeconds
}
} }
// Pages read: only when both sides carry a real page index (0 means // Pages read: only when both sides carry a real page index (0 means
+167
View File
@@ -0,0 +1,167 @@
package templates
// AudioReader is the reader surface for audiobooks (media_items with
// format_group = "audiobook"): cover, chapter, scrubber, skip/speed
// controls and the chapter list. Standalone page — the foliate document
// engine is deliberately not involved (handoff item 3 Batch A); the
// <audio> element streams the m4b through the existing cookie-authed
// Range-serving /uploads path.
import "fmt"
templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html/>
<html lang="en">
<head>
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="/static/style.css" rel="stylesheet"/>
<script type="module" src="/static/main.js"></script>
</head>
<body
x-data="audioReader"
x-init="init()"
class="theme-{ user.Theme } h-screen overflow-hidden"
style="background-color: var(--bg-primary);"
data-media-item-id={ metadata.MediaItemID }
data-title={ metadata.Title }
data-cover={ metadata.CoverImagePath }
data-file-url={ metadata.FileURL }
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress") }
>
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
<!-- Cover + title -->
<div class="flex flex-col items-center gap-3 text-center">
<div class="w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center" style="background-color: var(--surface-hover);">
if metadata.CoverImagePath != "" {
<img
src={ metadata.CoverImagePath }
alt={ metadata.Title }
class="w-full h-full object-cover"
onerror="this.style.display='none'"
/>
} else {
<span class="material-symbols-rounded text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">headphones</span>
}
</div>
<div>
<h1 class="font-semibold text-lg leading-snug" style="color: var(--text-primary);">{ metadata.Title }</h1>
<p class="text-sm mt-0.5" style="color: var(--text-secondary);" x-text="chapterTitle()">—</p>
</div>
</div>
<!-- Scrubber + transports -->
<div class="w-full max-w-xl flex flex-col gap-2">
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
<span x-text="fmtTime(currentTime)">0:00</span>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:value="chaptersDuration() || 1"
class="flex-1 accent-current"
@input.debounce.300ms="seekTo($event.target.value)"
/>
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
</div>
<div class="flex items-center justify-center gap-6">
<button
type="button"
@click="skip(-1)"
class="rounded-full p-3 hover:bg-surface-hover transition-colors"
:aria-label="'Skip back ' + skipSeconds + 's'"
style="color: var(--text-primary);"
>
<span class="text-lg font-semibold">-<span x-text="skipSeconds">10</span>s</span>
</button>
<button
id="audio-play"
type="button"
@click="togglePlay()"
class="rounded-full h-16 w-16 grid place-items-center"
style="background-color: var(--accent); color: var(--bg-primary);"
aria-label="Play or pause"
>
<span x-show="!playing" class="text-3xl leading-none">▶</span>
<span x-show="playing" class="text-3xl leading-none" x-cloak>❚❚</span>
</button>
<button
type="button"
@click="skip(1)"
class="rounded-full p-3 hover:bg-surface-hover transition-colors"
:aria-label="'Skip forward ' + skipSeconds + 's'"
style="color: var(--text-primary);"
>
<span class="text-lg font-semibold">+<span x-text="skipSeconds">10</span>s</span>
</button>
</div>
</div>
<!-- Chapter list + speed -->
<div class="w-full max-w-xl">
<div class="flex items-center justify-between">
<button
type="button"
@click="chaptersOpen = !chaptersOpen"
class="text-sm px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors"
style="color: var(--text-secondary);"
>
Chapters <span x-text="'(' + chapters.length + ')'"></span>
</button>
<div class="flex items-center gap-1.5">
<span class="text-xs" style="color: var(--text-secondary);">Skip</span>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs)"
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
style="color: var(--text-secondary);"
x-text="secs + 's'"
></button>
</template>
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span class="text-xs" style="color: var(--text-secondary);">Speed</span>
<div class="flex items-center gap-1.5">
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="rate">
<button
type="button"
@click="setSpeed(rate)"
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
style="color: var(--text-secondary);"
x-text="rate + '×'"
></button>
</template>
</div>
</div>
<div x-show="chaptersOpen" x-cloak class="mt-2 max-h-64 overflow-y-auto rounded-xl border p-2" style="border-color: var(--border); background-color: var(--bg-secondary);">
<template x-for="ch in chapters" :key="ch.index">
<button
type="button"
@click="jumpToChapter(ch.index)"
class="w-full text-left px-3 py-2 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors"
:class="chapters.length && currentChapter() === ch.index ? 'font-semibold' : ''"
style="color: var(--text-secondary);"
>
<span class="truncate" x-text="ch.title || ('Chapter ' + (ch.index + 1))"></span>
<span class="shrink-0 text-xs tabular-nums" x-text="fmtTime(ch.start)"></span>
</button>
</template>
<p x-show="!chapters.length" class="text-sm text-center py-4" style="color: var(--text-secondary);">
No chapters detected for this audiobook.
</p>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>
</html>
}
+183
View File
@@ -0,0 +1,183 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// AudioReader is the reader surface for audiobooks (media_items with
// format_group = "audiobook"): cover, chapter, scrubber, skip/speed
// controls and the chapter list. Standalone page — the foliate document
// engine is deliberately not involved (handoff item 3 Batch A); the
// <audio> element streams the m4b through the existing cookie-authed
// Range-serving /uploads path.
import "fmt"
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 16, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head><body x-data=\"audioReader\" x-init=\"init()\" class=\"theme-{ user.Theme } h-screen overflow-hidden\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 27, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 28, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-cover=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 29, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-file-url=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 30, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-json=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 33, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 41, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 42, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"material-symbols-rounded text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">headphones</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div><h1 class=\"font-semibold text-lg leading-snug\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 51, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :value=\"chaptersDuration() || 1\" class=\"flex-1 accent-current\" @input.debounce.300ms=\"seekTo($event.target.value)\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">-<span x-text=\"skipSeconds\">10</span>s</span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</span></button></div></div><!-- Chapter list + speed --><div class=\"w-full max-w-xl\"><div class=\"flex items-center justify-between\"><button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors\" style=\"color: var(--text-secondary);\">Chapters <span x-text=\"'(' + chapters.length + ')'\"></span></button><div class=\"flex items-center gap-1.5\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Skip</span><template x-for=\"secs in [1, 5, 10, 15, 30]\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"setSkip(secs)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"secs + 's'\"></button></template></div></div><div class=\"flex items-center justify-between mt-1\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Speed</span><div class=\"flex items-center gap-1.5\"><template x-for=\"rate in [0.75, 1, 1.25, 1.5, 2]\" :key=\"rate\"><button type=\"button\" @click=\"setSpeed(rate)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"rate + '×'\"></button></template></div></div><div x-show=\"chaptersOpen\" x-cloak class=\"mt-2 max-h-64 overflow-y-auto rounded-xl border p-2\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><template x-for=\"ch in chapters\" :key=\"ch.index\"><button type=\"button\" @click=\"jumpToChapter(ch.index)\" class=\"w-full text-left px-3 py-2 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors\" :class=\"chapters.length && currentChapter() === ch.index ? 'font-semibold' : ''\" style=\"color: var(--text-secondary);\"><span class=\"truncate\" x-text=\"ch.title || ('Chapter ' + (ch.index + 1))\"></span> <span class=\"shrink-0 text-xs tabular-nums\" x-text=\"fmtTime(ch.start)\"></span></button></template><p x-show=\"!chapters.length\" class=\"text-sm text-center py-4\" style=\"color: var(--text-secondary);\">No chapters detected for this audiobook.</p></div></div><audio id=\"audio-element\" preload=\"auto\" class=\"hidden\"></audio></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+337
View File
@@ -0,0 +1,337 @@
// Audio reader (web audiobook player — handoff item 3 Batch A).
// Player settings live IN the player as quick controls and persist via
// localStorage (skip duration, playback speed); the settings-section
// placement question stays open in the handoff.
//
// Progress sync: pushes {percentage, chapter, audio_offset_seconds,
// elapsed_seconds} every 30s WHILE PLAYING and on pause/hide. The
// elapsed seconds are honest wall-clock playing time — pauses never
// count — and the server keeps the 2h attributed ceiling.
//
// Media Session: lockscreen/notification transport controls (play,
// pause, skip backward/forward honoring the skip setting, chapter
// tracks) — the mobile-web native feel from handoff item 6.
import { Alpine } from "./alpine";
import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number };
const PROGRESS_INTERVAL_MS = 30_000;
const PLAYING_THRESHOLD = 0.5; // seconds of audio that count as "playing"
interface AudioConfig {
chapterEndpoint: string;
progressEndpoint: string;
}
function readConfig(): AudioConfig {
const body = document.body as HTMLElement;
return JSON.parse(body.dataset.json || "{}");
}
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
function fmtTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
Alpine.data("audioReader", () => ({
mediaItemId: "",
title: "",
chapters: [] as AudioChapter[],
chaptersOpen: false,
currentTime: 0,
playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1),
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// chapters are absolute seconds into the book; the scrubber spans the
// whole book, not per-chapter
bookDuration: 0,
currentChapterIdx: 0,
// progress-push bookkeeping
pendingElapsed: 0,
lastPush: Date.now(),
pushTimer: undefined as ReturnType<typeof setInterval> | undefined,
errorMessage: "",
initialized: false,
init() {
if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true;
window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown");
});
window.addEventListener("unhandledrejection", (e) => {
document.title = "REJ: " + (e.reason?.message || String(e.reason));
});
const body = document.body as HTMLElement;
const config = readConfig();
const mediaId = body.getAttribute("data-media-item-id") || "";
const title = body.getAttribute("data-title") || "";
const cover = body.getAttribute("data-cover") || "";
const fileUrl = body.getAttribute("data-file-url") || "";
this.mediaItemId = mediaId;
this.title = title;
const audio = this.audio();
if (!audio) return;
audio.src = fileUrl;
audio.playbackRate = Number(this.speed) || 1;
audio.preload = "auto";
// Restore position: chapters + progress from the server.
this.restorePosition();
// Periodic progress push (playing-only; pause/hide push too)
this.startPushTimer();
// Media Session (lockscreen/notification transport).
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
album: "Bookhoard",
artwork: cover ? [{ src: cover, sizes: "512x512" }] : [],
});
navigator.mediaSession.setActionHandler("play", () => this.togglePlay());
navigator.mediaSession.setActionHandler("pause", () => this.togglePlay());
navigator.mediaSession.setActionHandler("seekbackward", () => this.skip(-1));
navigator.mediaSession.setActionHandler("seekforward", () => this.skip(1));
navigator.mediaSession.setActionHandler("previoustrack", () => this.stepChapter(-1));
navigator.mediaSession.setActionHandler("nexttrack", () => this.stepChapter(1));
}
// Element events.
audio.addEventListener("timeupdate", () => {
this.currentTime = audio.currentTime;
this.tickChapter();
this.pendingElapsed = Math.max(
this.pendingElapsed,
Math.round(audio.currentTime - this.sessionStart),
);
});
audio.addEventListener("play", () => {
this.playing = true;
this.sessionStart = audio.currentTime;
this.updateMediaSessionState();
});
audio.addEventListener("pause", () => {
this.playing = false;
this.pushProgress(true);
this.updateMediaSessionState();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement | null;
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
if (e.code === "Space") {
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
},
audio(): HTMLAudioElement | null {
return document.getElementById("audio-element") as HTMLAudioElement | null;
},
async restorePosition() {
try {
const [chaptersResp, progress] = await Promise.all([
fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }),
fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{ headers: getAuthHeaders() },
),
]);
if (chaptersResp.ok) {
const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
}
if (progress.ok) {
const p = await progress.json();
const chapter = Number(p.chapter ?? 0);
const offset = Number(p.audio_offset_seconds ?? 0);
if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
const at = Number(p.percentage) * this.bookDuration;
this.seekTo(at);
} else if (chapter > 0 && this.chapters[chapter]?.start != null) {
this.seekTo(this.chapters[chapter].start + offset);
}
this.currentChapterIdx = Math.max(0, chapter);
}
} catch {
// Offline start: still playable; sync resumes when reachable.
}
},
chapterEndEstimate(): number {
const last = this.chapters[this.chapters.length - 1];
return last ? Number(last.end) || 0 : 0;
},
tickChapter() {
// Keep currentChapterIdx pointing at the chapter containing the playhead.
for (let i = this.chapters.length - 1; i >= 0; i -= 1) {
const ch = this.chapters[i];
if (this.currentTime >= ch.start) {
if (this.currentChapterIdx !== i) {
this.currentChapterIdx = i;
this.updateMediaSessionMetadata();
}
return;
}
}
},
chapterTitle(): string {
const ch = this.chapters[this.currentChapterIdx];
return ch
? ch.title || `Chapter ${ch.index + 1}`
: this.title;
},
currentChapter(): number {
return this.chapters.length ? this.currentChapterIdx : -1;
},
chaptersDuration(): number {
return this.bookDuration || this.chapterEndEstimate();
},
togglePlay() {
const audio = this.audio();
if (!audio) return;
if (audio.paused) audio.play().catch(() => showToast("Playback failed to start", "error"));
else audio.pause();
},
seekTo(seconds: number | string) {
const audio = this.audio();
if (!audio) return;
const target = Number(seconds);
if (Number.isFinite(target)) audio.currentTime = Math.max(0, target);
this.currentTime = audio.currentTime;
},
skip(direction: 1 | -1) {
const audio = this.audio();
if (!audio) return;
audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10));
},
stepChapter(delta: 1 | -1) {
const next = this.currentChapterIdx + delta;
if (next < 0 || next >= this.chapters.length) return;
this.jumpToChapter(next);
},
jumpToChapter(index: number) {
const ch = this.chapters[index];
if (ch) this.seekTo(ch.start);
this.currentChapterIdx = index;
},
setSpeed(rate: number) {
this.speed = rate;
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
},
setSkip(seconds: number) {
this.skipSeconds = seconds;
localStorage.setItem("audio_skip", String(seconds));
},
updateMediaSessionMetadata() {
if (!("mediaSession" in navigator)) return;
const ch = this.chapters[this.currentChapterIdx];
navigator.mediaSession.metadata = new MediaMetadata({
title: ch ? ch.title || this.title : this.title,
artist: this.title,
album: "Bookhoard",
});
},
updateMediaSessionState() {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? "playing" : "paused";
},
// ---- progress sync ----
async pushProgress(exit = false) {
if (this.chaptersDuration() <= 0) return;
const audio = this.audio();
if (!audio) return;
const at = audio.currentTime;
let chapter = 0;
let offset = 0;
for (let i = 0; i < this.chapters.length; i += 1) {
if (at >= this.chapters[i].start) {
chapter = i;
offset = Math.max(0, Math.round(at - this.chapters[i].start));
}
}
const playing = this.playing && audio.currentTime - this.sessionStart > PLAYING_THRESHOLD;
const elapsed = Math.max(0, Math.round(this.pendingElapsed));
const body: Record<string, unknown> = {
percentage: this.chaptersDuration() ? at / this.chaptersDuration() : 0,
chapter,
audio_offset_seconds: offset,
elapsed_seconds: playing || exit ? elapsed : 0,
reading_mode: "audio",
};
this.pendingElapsed = 0;
try {
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
method: "PUT",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
this.lastPush = Date.now();
if (exit) clearInterval(this.pushTimer);
} catch {
// non-critical; the next tick syncs
}
},
startPushTimer() {
this.pushTimer = setInterval(() => {
const audio = this.audio();
if (audio?.paused) return; // paused = nothing to sync
this.pushProgress();
}, PROGRESS_INTERVAL_MS);
},
fmtTime,
}));
export { fmtTime };
+1
View File
@@ -2,6 +2,7 @@ import "./alpine";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import "./admin"; import "./admin";
import "./audio";
import "./api"; import "./api";
import "./api-explorer"; import "./api-explorer";
import "./api-explorer-docs"; import "./api-explorer-docs";