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:
1 parent
a093529b80
commit
902cbe5915
20 files changed
+1425
-212
No files matched your search
+1
-1
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in new issue
Block a user