Compare commits

..
19 Commits
Author SHA1 Message Date
John O'Keefe 1eb9c92d6a fix(scanner): nil metadata_overrides crashed Reset to Scanned with SQLSTATE 23502
Release / build-and-push (push) Successful in 2m30s
Reset to Scanned cleared the overrides row (successfully) but then set
the in-memory copy to nil before handing it to updateMediaItem. pgx
encodes a nil []string parameter as SQL NULL, so the follow-up UPDATE
wrote metadata_overrides = NULL into the column's NOT NULL constraint
and the whole rescan failed with:

  failed to update media item: ERROR: null value in column
  "metadata_overrides" of relation "media_items" violates not-null
  constraint (SQLSTATE 23502)

Two changes:

- RescanMediaItem's reset path assigns []string{} instead of nil, with a
  comment explaining the pgx nil-to-NULL encoding trap.
- updateMediaItem routes the override set through utils.MergeOverrides,
  whose contract guarantees a non-nil slice, so no caller can write
  NULL into that column again (verified against pgx v5.9.2 source: a
  scanned '{}' round-trips as non-nil in both directions; the nil could
  only come from our own assignment).

The plain Rescan path never hit this - only Reset did. Worse, the reset
is the remedy when a book's cover_image_path override pins an empty
cover, so the crash also blocked the way out of that state. After this
fix, a plain rescan on an already-reset book repopulates scanned
metadata and extracts the cover.
2026-09-13 00:12:56 -04:00
John O'Keefe 1fa8ee3a59 feat(scanner): Calibre-aligned OPF metadata extraction
Release / build-and-push (push) Successful in 3m5s
Adopt Calibre's reading conventions for the Dublin Core metadata that
parseOPFContent now pulls from the structured OPF parse:

- Titles: EPUB3 title-type selection (prefer 'main', join a distinct
  subtitle with ': ' exactly as Calibre stores it). There is no separate
  subtitle column by design - Calibre-sidecar books arrive pre-joined,
  so a column would stay empty for most libraries and force every client
  to reimplement concatenation.
- Genre: first dc:subject, mirroring the existing processGenresAndTags
  behavior of the Calibre-sidecar path; the embedded path never
  populated Genre before. Subjects stay one-element-one-tag - Library
  of Congress headings legitimately contain commas ("Holmes, Sherlock
  (Fictitious character) -- Fiction") and must not be split.
- Identifiers: urn:isbn:/urn:asin: prefixed values parse in addition to
  opf:scheme attributes, and the scheme-less fallback now requires an
  ISBN-shaped value (10/13 digits, optional separators/trailing X) so
  URIs like the Gutenberg identifiers cannot masquerade as ISBNs -
  observed live on 'A Study in Scarlet'.
- Series: EPUB3 belongs-to-collection with collection-type=series and
  group-position refines, ahead of the classic calibre:series metas.
- Audiobookshelf metadata.json sidecars join their subtitle field into
  the title the same way.

Tests cover title-type main+subtitle joining, belongs-to-collection
series with fractional group-position, urn:isbn extraction, genre/tag
parity, and comma preservation inside subject headings.
2026-09-12 23:45:18 -04:00
John O'Keefe 72d167005f fix(scanner): resolve EPUB covers via structured OPF parsing, Calibre chain
The cover lookup scraped the OPF with attribute-order-sensitive regexes.
Real books serialize attributes in any order - Grand Central's '3 Days to
Live' puts href before id on manifest items and content before name on
the cover meta - so all three regex paths missed and the book fell
through to filename guessing, extracting no cover at all. Attribute
order is meaningless in XML; the regexes were never safe.

Replace them with a structured parse (encoding/xml, namespace and
attribute-order agnostic; see the new media_scanner_opf.go) and follow
Calibre's read_raster_cover resolution order:

1. manifest item with properties=cover-image (non-(X)HTML media only)
2. <meta name=cover> resolved through the manifest, same media guard
3. first spine item that is itself a raster image (store manga)
4. NEW cover-page fallback: books declaring no raster cover at all -
   the classic EPUB2/Adobe cover.xhtml wrapper - are mined for
   <img src> / SVG <image xlink:href> references (Calibre renders the
   page with Qt; extracting the referenced image covers the practical
   cases without a rendering engine)
5. existing zip filename guessing stays as the last resort, and the old
   regex chain survives as findCoverInOPFLegacy for OPFs too malformed
   for a real XML parse.

Hrefs are now URL-decoded and posix-normalized against the OPF's own
path (path.Join semantics), so '../art/cover.jpg' from a nested cover
page and %20-encoded names resolve correctly.

Tests: attribute-order chaos modeled on the failing Patterson book,
SVG-wrapped cover pages via guide references, image-first spines, and
path resolution edge cases. Verified live against the real
'3 Days to Live' EPUB, which previously produced no cover.
2026-09-12 23:45:01 -04:00
John O'Keefe bf2c2825ac chore(deploy): document ARCHIVE_RETENTION_DAYS in compose and .env.example
Release / build-and-push (push) Successful in 2m33s
Days an archived item is kept with its reading history before library
scans purge it for good (default 90). Set 0 to keep archived items until
purged manually from the library admin page.
2026-09-12 17:50:31 -04:00
John O'Keefe cd119a74da feat(admin): purge-archived endpoint with an archived-items banner
Bulk escape hatch for archived rows (files missing from disk for 2+
scans) so a mass external deletion never has to wait out the retention
window or be clicked away row by row:

- POST /api/media-items/purge-archived (admin only) hard-deletes all
  archived items and returns the purged count; reading history goes with
  the rows, so the call is confirmed in the UI first.
- The library admin page shows an 'Archived items: N' card (only when
  non-zero) with a Purge Archived Now button that calls the endpoint,
  toasts the result, and reloads.
- Frontend admin JS exposes window.purgeArchivedItems following the
  existing localStorage-bearer-token pattern.
2026-09-12 17:50:24 -04:00
John O'Keefe 14445a7c3f feat(scanner): archive-at-two-scans lifecycle for files missing from disk
Replace the silent hard-delete orphan cleanup (which logged only through
ScannerLogger file logs and whose failure paths left rows undetected)
with an archive lifecycle that preserves reading history:

- A file missing in one scan is marked (missing_scan_count = 1); missing
  in a second consecutive scan archives it (archived_at, hidden from
  browsing, progress/notes/highlights survive). Every branch logs to
  stdout with an [ARCHIVE] prefix so skips are always visible.
- When a file reappears - same path, or identical content at a new path
  via the SHA-256 dedup match - the archived state clears automatically
  and the item returns with its history intact.
- Archived rows older than ARCHIVE_RETENTION_DAYS are hard-purged at
  scan time (cascading deletes); 0 disables auto-purge for manual-only
  management. Retention is read from the environment in NewMediaScanner.
2026-09-12 17:50:15 -04:00
John O'Keefe e6aceae0da feat(db): archive lifecycle schema and queries for missing media
Add the storage behind the archive-instead-of-delete lifecycle:

- media_items.missing_scan_count (INT, default 0) and archived_at
  (TIMESTAMPTZ, partial index), both as idempotent ADD COLUMN IF NOT
  EXISTS backfills for existing installs.
- MarkMediaItemMissing / ArchiveMediaItem / ClearMediaItemArchive plus
  PurgeExpiredArchivedMediaItems (retention cutoff) and
  PurgeAllArchivedMediaItems (manual bulk), with CountArchivedMediaItems
  for the admin UI.
- Archived items are hidden from every user-facing listing:
  ListMediaItems, ListMediaItemsByLibrary, ListMediaItemsSorted,
  SearchMediaItems, SearchMediaItemsUnified, the next_books CTE, the
  library media counts, and the search autocomplete value lists. Detail
  lookups by id/path/sha are intentionally unfiltered, and a dedicated
  ListMediaItemsByLibraryIncludingArchived feeds the scanner so the
  lifecycle pass can see and restore archived rows.
2026-09-12 17:50:01 -04:00
John O'Keefe 44b98f3fc3 feat(scanner): read Audiobookshelf metadata.json sidecars
Libraries managed by Audiobookshelf keep a metadata.json next to each
book (title, authors, series+sequence, genres/tags, publisher,
description, isbn/asin, language, published year/date) - and no
metadata.opf. The scanner silently ignored those files: deleting them
changed nothing, and their data never reached the database.

Parse them as a first-class sidecar in extractMetadata, priority
metadata.opf -> metadata.json -> embedded media. Only fields with a
matching media_items column are mapped; narrators, subtitle, explicit,
abridged, and chapters are deliberately skipped.

Cover handling is unchanged: the existing findSidecarCover priority
(cover.jpg / folder.jpg / {basename}.jpg) applies to the sidecar branch
exactly as it does for Calibre.
2026-09-12 17:49:46 -04:00
John O'Keefe 61681aac23 feat(scanner): extract EPUB metadata from the embedded OPF directly
go-epub's ReadBook parses every spine chapter and fails the entire call
if any single chapter (or the TOC) is malformed, discarding already-parsed
OPF metadata. For books like Pragmatic's 'A Common-Sense Guide' the OPF
holds good title/author/publisher/ISBN metadata that rescans then wrote
as blanks - success toast, no (visible) change.

Refactor to parse the EPUB's own OPF document with the same Dublin Core
machinery used for Calibre sidecars: parseCalibreMetadataOPF is now a thin
file wrapper around a reusable parseOPFContent([]byte), and the OPF lookup
previously inline in extractEPUBCover is shared via findOPFPathInZip. Since
metadata never touches chapter bodies, chapter damage cannot blank it.

Also picked up along the way: dc:language mapping and scheme-less
dc:identifier values that normalize to a valid ISBN (EPUB3 style).

Tests cover a Pragmatic-style EPUB (dc namespace on <metadata>, no
identifier scheme, deliberately malformed chapter) that must still yield
full metadata, plus series/date/subject OPF parsing.
2026-09-12 17:49:30 -04:00
John O'Keefe 7a9a66fcc5 feat(ui): show file path and Reset to Scanned in the metadata editor
Release / build-and-push (push) Successful in 2m24s
Complete the metadata editor modal to match the backend override work:

- File Path: read-only, monospace field showing the resolved on-disk
  location (MediaDetail.FileLocation), next to Format and File Size, so
  admins can see exactly which file backs the record without leaving the
  editor. Renders empty for non-admins, who never receive the path.
- Reset to Scanned: new button beside Rescan. It confirms, then calls
  POST /api/media-items/:id/rescan?reset_overrides=true to drop all
  per-field user overrides and re-extract scanned defaults. Rescan alone
  keeps customizations; Reset discards them.
2026-09-12 14:22:38 -04:00
John O'Keefe 2df3ecbf36 fix(ui): remove the broken Generate Cover button from the metadata editor
Generate Cover has never worked: it fetched the book file using a URL
scraped from the cover preview <img> tag (so it downloaded either the
existing cover JPEG or, when no cover existed, the detail page HTML),
then handed it to foliate-js, which rejects both. Its fixed-layout path
also called view.renderer.renderPage(), a method that does not exist in
the pinned foliate fork. Every click ended in the same generic 'Cover
generation failed' toast.

The working alternative already exists server-side: the scanner's
PDF/EPUB cover extraction plus the per-book Rescan button, now that the
rasterizer renders the CropBox. Users who want a specific image can
upload one.

Delete web/src/cover-generator.ts, the modal buttons, and the dead
generateCover()/coverGenerating/fileUrl plumbing in book-detail.ts.
2026-09-12 14:22:29 -04:00
John O'Keefe 9da193e718 feat(ui): admin-only file location and metadata editing on book detail
The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.

- The page handler resolves library folder + relative path (verified
  with os.Stat; falls back to the relative path when the file is not
  found on disk) into the new MediaDetail.FileLocation field - only for
  admins, so the absolute path never leaves the server for regular
  users. This also makes it possible to locate sparse entries whose
  metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
  Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
  admins. The modal drives admin-only endpoints (metadata PUT, rescan,
  reset), so non-admins previously saw a button and a form that could
  only ever fail with 403s.
2026-09-12 14:21:42 -04:00
John O'Keefe 7f64b92b9d feat(metadata): per-field user overrides that survive library rescans
Previously both the metadata editor (PUT /api/media-items/:id) and the
scanner (library scans, force rescans, per-book rescans) wrote through
the same unconditional UPDATE media_items query, so any rescan wiped
user-written descriptions, tags, and uploaded covers. Custom and scanned
values were indistinguishable, and custom cover uploads even wrote to
the same {file}.cover.jpg sidecar path the scanner generates, so each
side silently clobbered the other.

Introduce metadata_overrides, a TEXT[] column on media_items listing the
column names the user has customized:

- Saving metadata records overrides per field by diffing the submitted
  values against the stored row (an untouched save records nothing);
  overrides accumulate until an explicit reset. Cover upload/removal
  always marks cover_image_path. Bulk updates mark each applied field.
- The scanner merges: updateMediaItem() now takes the existing row and
  restores every overridden column (including derived *_search arrays)
  before writing, and preserves the override set itself.
- Uploaded covers move to a dedicated {file}.custom_cover.{jpg|png|webp}
  sidecar so the scanner can never overwrite a user cover on disk.
- RescanMediaItem gains resetOverrides: POST /api/media-items/:id/rescan?
  reset_overrides=true clears the set first, returning the item to pure
  scanned defaults.

Shared detection/restore helpers live in internal/utils
(metadata_overrides.go) with unit tests covering detection, accumulation,
unset-form equality, and restore-with-derived-fields. Schema change is
an idempotent ADD COLUMN IF NOT EXISTS applied on startup. Also includes
incidental gofmt of NewMediaScanner literals in media_scanner.go.
2026-09-12 14:21:35 -04:00
John O'Keefe b9645752f5 fix(db): skip SQL comment lines when parsing schema table names
parseTableNames() regex-scanned every line of schema.sql, comments
included, with 'CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)'. A doc
comment containing that phrase in prose - e.g. 'declared in CREATE TABLE
above' - registered a phantom table ('above'), and startup verification
then failed with 'missing tables: above', crash-looping the app
container on every restart.

Skip lines whose trimmed form starts with '--' so comments can never
contribute table names, and add a regression test asserting every parsed
table maps back to a real CREATE TABLE statement.
2026-09-12 14:21:28 -04:00
John O'Keefe 9c8337d0a8 fix(scanner): render the PDF CropBox, not the MediaBox, in the cover fallback
pdftoppm defaults to rasterizing the MediaBox, while PDF viewers (pdf.js
in the reader, and every other viewer) display the CropBox. For PDFs
whose page 1 is the full print cover wrap (back cover + spine + front
cover in one landscape page) with a CropBox covering only the front
cover - e.g. No Starch's XeTeX-built 'Algorithmic Thinking' - the
fallback stored the entire spread as a squashed landscape cover, while
the reader correctly showed just the front cover.

Pass -cropbox so the rendered cover always matches what the reader
displays. Poppler falls back to the MediaBox when a PDF defines no
CropBox, so PDFs with identical boxes (the common case) render exactly
as before.
2026-09-12 14:21:24 -04:00
john-okeefe da1f689263 feat(ui): add Rescan button to Edit Metadata dialog
Release / build-and-push (push) Successful in 2m50s
Adds a Rescan button to the MetadataEditorModal footer that POSTs to the
new per-book rescan endpoint, with rescanning state (disabled + spinner,
matching the existing Mark Read pattern), success/error toasts, and a
page reload to pick up the refreshed cover and metadata.
2026-09-11 23:08:20 -04:00
john-okeefe 298330a3a8 feat(api): add POST /api/media-items/:id/rescan endpoint
Admin-only endpoint that re-extracts metadata and cover art for a single
book from the file on disk via MediaScanner.RescanMediaItem and returns
the updated media item. Normal library scans skip unchanged files, so
this gives a targeted way to backfill covers for previously imported
books. Includes a Bruno request alongside the existing media-items
collection.
2026-09-11 23:08:20 -04:00
john-okeefe 0614795ca2 feat(scanner): render PDF first page as cover fallback via pdftoppm
extractPDFCover previously only saved embedded raster images from page 1,
so vector/text-first-page PDFs (e.g. InDesign exports like Data Structures
the Fun Way) ended up with no cover and a dashboard placeholder. When no
embedded image is found it now falls back to rendering page 1 with
pdftoppm (poppler-utils), saving the same {pdf}.cover.jpg sidecar.

Also adds MediaScanner.RescanMediaItem, which re-extracts metadata for a
single media item (resolving its on-disk path from library folders) so
previously imported books can backfill covers without a full force rescan.

Dockerfile installs poppler-utils in the final and test-runner stages.
2026-09-11 23:08:20 -04:00
john-okeefe 6aa958c78f fix(reader): save progress on real position change, not on detected intent
Release / build-and-push (push) Successful in 2m27s
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.

Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:

- The relocate handler records the latest CFI (lastCfi), and the
  baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
  view.init() resolves — i.e. the restored position, or the start of
  the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
  CFI comparison for reflowable books, fraction comparison (1e-4
  epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
  bfcache-resurrected page re-baselines to its frozen position, so the
  anti-clobber property survives: a displayed/restored position can
  still never overwrite a newer device push.

The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
2026-09-11 21:50:03 -04:00
33 changed files with 3647 additions and 1323 deletions
+4
View File
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert # BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
# Conversion Cache TTL: Override default 24h # Conversion Cache TTL: Override default 24h
# BOOKHOARD_CONVERSION_CACHE_TTL=48h # BOOKHOARD_CONVERSION_CACHE_TTL=48h
# Archive Retention: days to keep archived items (files missing from disk for
# 2+ scans) before they are purged, deleting their reading history with them.
# Set 0 to keep archived items until purged manually on the library admin page.
# ARCHIVE_RETENTION_DAYS=90
# System timezone (fallback for server-side time operations, defaults to UTC) # System timezone (fallback for server-side time operations, defaults to UTC)
# TZ=America/New_York # TZ=America/New_York
+2 -2
View File
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# This stage is ONLY used for running tests, never deployed to production # This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl RUN apk --no-cache add ca-certificates curl poppler-utils
WORKDIR /app WORKDIR /app
@@ -65,7 +65,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 RUN apk --no-cache add ca-certificates curl poppler-utils
# 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 \
+35
View File
@@ -0,0 +1,35 @@
info:
name: Rescan Media Item
type: http
seq: 9
http:
method: POST
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
headers:
- name: ""
value: application/json
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test_rescan_media_item_success(status, headers, body) {
if (status !== 200) {
throw new Error("Expected status 200, got " + status);
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Rescan Media Item
Re-extracts metadata and cover art for a single media item from the file on disk.
**Method:** POST
**Endpoint:** /api/media-items/{id}/rescan
+23
View File
@@ -207,6 +207,19 @@ CREATE TABLE IF NOT EXISTS media_items (
-- Summary (distinct from description - may merge with Calibre description) -- Summary (distinct from description - may merge with Calibre description)
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre) summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
-- Per-field user-override tracking. Column names listed here (e.g.
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
-- NOT be overwritten by library scans or per-book rescans; only the
-- reset-to-scanned-defaults action clears them.
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
-- Archive lifecycle: a file missing from disk for two consecutive scans
-- gets archived_at set (hidden from all browsing, reading history kept).
-- If the file returns, the row is un-archived. Archived rows are purged
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
missing_scan_count INT NOT NULL DEFAULT 0,
archived_at TIMESTAMPTZ,
-- Chapter metadata for reader navigation and progress tracking -- Chapter metadata for reader navigation and progress tracking
-- Caches detected chapter structure to avoid re-parsing files -- Caches detected chapter structure to avoid re-parsing files
-- Populated by ReaderService.DetectChapters() on first read -- Populated by ReaderService.DetectChapters() on first read
@@ -232,6 +245,16 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Per-field user-override tracking (idempotent backfill for existing
-- installs; the column is also declared in the media_items table
-- definition above for fresh databases)
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
-- Archive lifecycle (idempotent backfill for existing installs)
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
-- Create GIN indexes for fast search field searches -- Create GIN indexes for fast search field searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search); CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search); CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
+6
View File
@@ -61,6 +61,12 @@ services:
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify} BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h} BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
# Library Maintenance
# Days an archived item (file missing from disk for 2+ scans) is kept,
# with its reading history, before library scans purge it for good.
# Set 0 to keep archived items until purged manually on the library admin page.
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
# System timezone (fallback for server-side time operations) # System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
ports: ports:
+11
View File
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
} }
return defaultValue return defaultValue
} }
// ArchiveRetentionDays returns how many days a media item stays archived
// (file missing from disk for two consecutive scans) before library scans
// purge it for good. Reading progress, notes, and highlights survive the
// archive window and are restored if the file returns; the purge deletes
// them along with the row.
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
// until an admin purges them manually from the library admin page.
func ArchiveRetentionDays() int {
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
+4 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -306,6 +306,9 @@ type MediaItems struct {
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
// Summary from ComicInfo.xml (may be merged with description from Calibre) // Summary from ComicInfo.xml (may be merged with description from Calibre)
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
+19 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -20,6 +20,9 @@ type Querier interface {
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
// Library Folders queries // Library Folders queries
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns.
ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error
// Bulk update format group for all media items // Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
@@ -30,7 +33,14 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
// File is back on disk (by path or content hash): restore visibility and
// reset the missing-scan counter. No-op for items that were never archived.
ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error
// Reset an item to scanned defaults: clears per-field user overrides so the
// next rescan can freely overwrite user-customized metadata.
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
CountAdmins(ctx context.Context) (int64, error) CountAdmins(ctx context.Context) (int64, error)
CountArchivedMediaItems(ctx context.Context) (int64, error)
// Count unlinked books for a device // Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
@@ -340,6 +350,7 @@ type Querier interface {
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
// List all media items sharing a SHA-256 hash within a library (hash conflict group) // List all media items sharing a SHA-256 hash within a library (hash conflict group)
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
// 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)
@@ -353,6 +364,13 @@ type Querier interface {
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// Manual bulk purge from the library admin page.
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
// Retention sweep at library-scan time: hard-delete archived items older
// than the cutoff. Cascades remove reading history with the row.
PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error)
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
+440 -34
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
// source: queries.sql // source: queries.sql
package database package database
@@ -107,6 +107,18 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara
return i, err return i, err
} }
const ArchiveMediaItem = `-- name: ArchiveMediaItem :exec
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
WHERE id = $1
`
// Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns.
func (q *Queries) ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ArchiveMediaItem, id)
return err
}
const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec
UPDATE media_items m UPDATE media_items m
SET SET
@@ -226,6 +238,30 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy
return err return err
} }
const ClearMediaItemArchive = `-- name: ClearMediaItemArchive :exec
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0)
`
// File is back on disk (by path or content hash): restore visibility and
// reset the missing-scan counter. No-op for items that were never archived.
func (q *Queries) ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearMediaItemArchive, id)
return err
}
const ClearMediaItemMetadataOverrides = `-- name: ClearMediaItemMetadataOverrides :exec
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
WHERE id = $1
`
// Reset an item to scanned defaults: clears per-field user overrides so the
// next rescan can freely overwrite user-customized metadata.
func (q *Queries) ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearMediaItemMetadataOverrides, id)
return err
}
const CountAdmins = `-- name: CountAdmins :one const CountAdmins = `-- name: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin' SELECT COUNT(*) FROM users WHERE role = 'admin'
` `
@@ -237,6 +273,17 @@ func (q *Queries) CountAdmins(ctx context.Context) (int64, error) {
return count, err return count, err
} }
const CountArchivedMediaItems = `-- name: CountArchivedMediaItems :one
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL
`
func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, CountArchivedMediaItems)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books FROM unlinked_books
@@ -900,7 +947,7 @@ 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)
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)
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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type CreateMediaItemParams struct { type CreateMediaItemParams struct {
@@ -1053,6 +1100,9 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -2447,7 +2497,7 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
} }
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, 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, 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
` `
@@ -2520,6 +2570,9 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -2619,7 +2672,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.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.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)
@@ -2687,6 +2740,9 @@ type GetCollectionItemsForDashboardRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -2761,6 +2817,9 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -2913,7 +2972,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.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.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
@@ -2997,6 +3056,9 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -3030,15 +3092,16 @@ 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.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.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
WHERE ($3::uuid IS NULL OR mi.library_id = $3::uuid) WHERE mi.archived_at IS NULL
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
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, 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, 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
` `
@@ -3103,6 +3166,9 @@ type GetContinueSeriesItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -3177,6 +3243,9 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -4306,7 +4375,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.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.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)
ORDER BY mi.created_at DESC ORDER BY mi.created_at DESC
` `
@@ -4374,6 +4443,9 @@ func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -4805,7 +4877,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, 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, 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) {
@@ -4865,6 +4937,9 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -4878,7 +4953,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, 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, 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 {
@@ -4943,6 +5018,9 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -4956,7 +5034,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, 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, 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) {
@@ -5016,6 +5094,9 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5030,7 +5111,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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
` `
// ============================================ // ============================================
@@ -5093,6 +5174,9 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5106,7 +5190,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, 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, 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) {
@@ -5166,6 +5250,9 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5179,7 +5266,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
} }
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, 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, 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
@@ -5240,6 +5327,9 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5253,7 +5343,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, 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, 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
@@ -5314,6 +5404,9 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5327,7 +5420,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, 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, 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
@@ -5388,6 +5481,9 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5401,7 +5497,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, 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, 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 {
@@ -5467,6 +5563,9 @@ func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMed
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -5807,7 +5906,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.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.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 NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM reading_progress rp SELECT 1 FROM reading_progress rp
@@ -5888,6 +5987,9 @@ func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItems
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -6207,7 +6309,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.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.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)
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT $2 LIMIT $2
@@ -6281,6 +6383,9 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -6301,7 +6406,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.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.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
@@ -6383,6 +6488,9 @@ func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadI
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -6503,7 +6611,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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE series = $1 WHERE series = $1
ORDER BY series_number ASC NULLS LAST ORDER BY series_number ASC NULLS LAST
` `
@@ -6571,6 +6679,9 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]Med
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -7772,7 +7883,7 @@ const GetVisibleLibraryMediaCounts = `-- name: GetVisibleLibraryMediaCounts :man
SELECT l.id, COUNT(mi.id) as media_count SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id GROUP BY l.id
` `
@@ -8354,10 +8465,11 @@ 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.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.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
WHERE mi.archived_at IS NULL
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2 ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
` `
@@ -8420,6 +8532,9 @@ type ListMediaItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -8495,6 +8610,9 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -8517,11 +8635,12 @@ 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.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.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
WHERE mi.library_id = $1 WHERE mi.library_id = $1
AND mi.archived_at IS NULL
ORDER BY mi.created_at DESC ORDER BY mi.created_at DESC
` `
@@ -8579,6 +8698,9 @@ type ListMediaItemsByLibraryRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -8654,6 +8776,174 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
&i.ContributorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
&i.LibraryName,
&i.LibraryTypeName_2,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
`
type ListMediaItemsByLibraryIncludingArchivedRow struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
Genre pgtype.Text `db:"genre" json:"genre"`
CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"`
GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"`
OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
FormatGroup string `db:"format_group" json:"format_group"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
MangaType pgtype.Text `db:"manga_type" json:"manga_type"`
ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"`
SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"`
Volume pgtype.Int4 `db:"volume" json:"volume"`
Imprint pgtype.Text `db:"imprint" json:"imprint"`
AgeRating pgtype.Text `db:"age_rating" json:"age_rating"`
WebUrl pgtype.Text `db:"web_url" json:"web_url"`
StoryArc pgtype.Text `db:"story_arc" json:"story_arc"`
IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"`
MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"`
CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"`
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
LibraryName string `db:"library_name" json:"library_name"`
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
}
func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error) {
rows, err := q.db.Query(ctx, ListMediaItemsByLibraryIncludingArchived, libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListMediaItemsByLibraryIncludingArchivedRow{}
for rows.Next() {
var i ListMediaItemsByLibraryIncludingArchivedRow
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -8676,7 +8966,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
} }
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, 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, 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 {
@@ -8748,6 +9038,9 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -8768,7 +9061,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, 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, 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)
@@ -8835,6 +9128,9 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -8855,11 +9151,12 @@ 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.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.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
WHERE mi.library_id = $1 WHERE mi.archived_at IS NULL
AND mi.library_id = $1
ORDER BY ORDER BY
CASE CASE
WHEN $2 = 'title ASC' THEN mi.title WHEN $2 = 'title ASC' THEN mi.title
@@ -8990,6 +9287,9 @@ type ListMediaItemsSortedRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -9070,6 +9370,9 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -9491,6 +9794,82 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
return items, nil return items, nil
} }
const MarkMediaItemMissing = `-- name: MarkMediaItemMissing :exec
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
WHERE id = $1
`
// First consecutive scan that cannot find the file on disk.
func (q *Queries) MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, MarkMediaItemMissing, id)
return err
}
const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many
DELETE FROM media_items
WHERE archived_at IS NOT NULL
RETURNING id, title
`
type PurgeAllArchivedMediaItemsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
}
// Manual bulk purge from the library admin page.
func (q *Queries) PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error) {
rows, err := q.db.Query(ctx, PurgeAllArchivedMediaItems)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurgeAllArchivedMediaItemsRow{}
for rows.Next() {
var i PurgeAllArchivedMediaItemsRow
if err := rows.Scan(&i.ID, &i.Title); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const PurgeExpiredArchivedMediaItems = `-- name: PurgeExpiredArchivedMediaItems :many
DELETE FROM media_items
WHERE archived_at IS NOT NULL AND archived_at < $1
RETURNING id, title
`
type PurgeExpiredArchivedMediaItemsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
}
// Retention sweep at library-scan time: hard-delete archived items older
// than the cutoff. Cascades remove reading history with the row.
func (q *Queries) PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error) {
rows, err := q.db.Query(ctx, PurgeExpiredArchivedMediaItems, archivedAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurgeExpiredArchivedMediaItemsRow{}
for rows.Next() {
var i PurgeExpiredArchivedMediaItemsRow
if err := rows.Scan(&i.ID, &i.Title); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1 DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1
` `
@@ -10027,6 +10406,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = $3 AND mi.library_id = $3
AND word_similarity($1, COALESCE(mi.author, '')) > 0.3 AND word_similarity($1, COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL AND mi.author IS NOT NULL
@@ -10085,6 +10465,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = $3 AND mi.library_id = $3
AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3 AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL AND mi.genre IS NOT NULL
@@ -10143,6 +10524,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = $3 AND mi.library_id = $3
AND word_similarity($1, COALESCE(mi.language, '')) > 0.3 AND word_similarity($1, COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL AND mi.language IS NOT NULL
@@ -10193,12 +10575,13 @@ 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.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.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
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
AND ( AND (
mi.title ILIKE $3 OR mi.title ILIKE $3 OR
@@ -10281,6 +10664,9 @@ type SearchMediaItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
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"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -10363,6 +10749,9 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -10437,6 +10826,7 @@ 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
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND ($2::uuid IS NULL AND ($2::uuid IS NULL
OR mi.library_id = $2::uuid) OR mi.library_id = $2::uuid)
-- Fuzzy author filter -- Fuzzy author filter
@@ -10721,6 +11111,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = $3 AND mi.library_id = $3
AND word_similarity($1, COALESCE(mi.series, '')) > 0.3 AND word_similarity($1, COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL AND mi.series IS NOT NULL
@@ -11834,9 +12225,10 @@ UPDATE media_items SET
alternate_info = $35, alternate_info = $35,
scan_information = $36, scan_information = $36,
summary = $37, summary = $37,
metadata_overrides = $38,
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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemParams struct { type UpdateMediaItemParams struct {
@@ -11877,6 +12269,7 @@ type UpdateMediaItemParams struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
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"`
} }
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) { func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
@@ -11918,6 +12311,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
arg.AlternateInfo, arg.AlternateInfo,
arg.ScanInformation, arg.ScanInformation,
arg.Summary, arg.Summary,
arg.MetadataOverrides,
) )
var i MediaItems var i MediaItems
err := row.Scan( err := row.Scan(
@@ -11974,6 +12368,9 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -11990,7 +12387,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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemChapterMetadataParams struct { type UpdateMediaItemChapterMetadataParams struct {
@@ -12055,6 +12452,9 @@ func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg Update
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -12162,7 +12562,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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemIdentifiersParams struct { type UpdateMediaItemIdentifiersParams struct {
@@ -12241,6 +12641,9 @@ func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMedi
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
@@ -12261,7 +12664,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, 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, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
` `
type UpdateMediaItemKoboMetadataParams struct { type UpdateMediaItemKoboMetadataParams struct {
@@ -12335,6 +12738,9 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed
&i.AlternateInfo, &i.AlternateInfo,
&i.ScanInformation, &i.ScanInformation,
&i.Summary, &i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata, &i.ChapterMetadata,
&i.LibraryTypeName, &i.LibraryTypeName,
&i.TagsSearch, &i.TagsSearch,
+61 -3
View File
@@ -141,7 +141,7 @@ ORDER BY l.created_at ASC;
SELECT l.id, COUNT(mi.id) as media_count SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id; GROUP BY l.id;
@@ -160,6 +160,7 @@ SELECT mi.*, 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
WHERE mi.archived_at IS NULL
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2; ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many -- name: ListMediaItemsByLibrary :many
@@ -167,6 +168,15 @@ SELECT mi.*, 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
WHERE mi.library_id = $1
AND mi.archived_at IS NULL
ORDER BY mi.created_at DESC;
-- name: ListMediaItemsByLibraryIncludingArchived :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1 WHERE mi.library_id = $1
ORDER BY mi.created_at DESC; ORDER BY mi.created_at DESC;
@@ -175,7 +185,8 @@ SELECT mi.*, 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
WHERE mi.library_id = sqlc.narg('library_id') WHERE mi.archived_at IS NULL
AND mi.library_id = sqlc.narg('library_id')
ORDER BY ORDER BY
CASE CASE
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
@@ -282,10 +293,50 @@ UPDATE media_items SET
alternate_info = $35, alternate_info = $35,
scan_information = $36, scan_information = $36,
summary = $37, summary = $37,
metadata_overrides = $38,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: ClearMediaItemMetadataOverrides :exec
-- Reset an item to scanned defaults: clears per-field user overrides so the
-- next rescan can freely overwrite user-customized metadata.
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
WHERE id = $1;
-- name: MarkMediaItemMissing :exec
-- First consecutive scan that cannot find the file on disk.
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
WHERE id = $1;
-- name: ArchiveMediaItem :exec
-- Second consecutive missing scan: hide the item from all browsing while
-- preserving reading history in case the file returns.
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
WHERE id = $1;
-- name: ClearMediaItemArchive :exec
-- File is back on disk (by path or content hash): restore visibility and
-- reset the missing-scan counter. No-op for items that were never archived.
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
-- name: PurgeExpiredArchivedMediaItems :many
-- Retention sweep at library-scan time: hard-delete archived items older
-- than the cutoff. Cascades remove reading history with the row.
DELETE FROM media_items
WHERE archived_at IS NOT NULL AND archived_at < $1
RETURNING id, title;
-- name: PurgeAllArchivedMediaItems :many
-- Manual bulk purge from the library admin page.
DELETE FROM media_items
WHERE archived_at IS NOT NULL
RETURNING id, title;
-- name: CountArchivedMediaItems :one
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
-- name: DeleteMediaItem :exec -- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1; DELETE FROM media_items WHERE id = $1;
@@ -434,6 +485,7 @@ 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
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND ( AND (
mi.title ILIKE sqlc.narg('search_pattern') OR mi.title ILIKE sqlc.narg('search_pattern') OR
@@ -506,6 +558,7 @@ 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
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND (sqlc.narg('library_id')::uuid IS NULL AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid) OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter -- Fuzzy author filter
@@ -639,6 +692,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL AND mi.author IS NOT NULL
@@ -656,6 +710,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL AND mi.genre IS NOT NULL
@@ -690,6 +745,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL AND mi.series IS NOT NULL
@@ -707,6 +763,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL AND mi.language IS NOT NULL
@@ -2454,7 +2511,8 @@ next_books AS (
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
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE mi.archived_at IS NULL
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
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
) )
+6
View File
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
tables := make(map[string]bool) tables := make(map[string]bool)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// Skip SQL comments: a doc line like "-- ... declared in CREATE
// TABLE above" would otherwise register a phantom table and fail
// startup verification.
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
matches := pattern.FindStringSubmatch(line) matches := pattern.FindStringSubmatch(line)
if len(matches) > 1 { if len(matches) > 1 {
tableName := matches[1] tableName := matches[1]
+28
View File
@@ -0,0 +1,28 @@
package database
import (
"regexp"
"testing"
)
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
// registered a table named "above" and crashed startup verification).
func TestParseTableNamesIgnoresComments(t *testing.T) {
tables, err := parseTableNames()
if err != nil {
t.Fatalf("parseTableNames() error: %v", err)
}
if len(tables) == 0 {
t.Fatal("parseTableNames() returned no tables")
}
for _, name := range tables {
// Every parsed table must correspond to a real CREATE TABLE statement
// at the start of a (non-comment) line.
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
if !stmt.MatchString(SchemaFile) {
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
}
}
}
+95 -3
View File
@@ -572,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
Summary: existingMedia.Summary, Summary: existingMedia.Summary,
} }
// Bulk edits are user customizations: keep existing overrides and mark
// each applied update as overridden so scans preserve it.
overrides := existingMedia.MetadataOverrides
if update.Updates.Title != nil { if update.Updates.Title != nil {
updateParams.Title = *update.Updates.Title updateParams.Title = *update.Updates.Title
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
} }
if update.Updates.Author != nil { if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
} }
if update.Updates.Genre != nil { if update.Updates.Genre != nil {
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
} }
if update.Updates.Language != nil { if update.Updates.Language != nil {
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
} }
if len(update.Updates.Tags) > 0 { if len(update.Updates.Tags) > 0 {
normalizedTags := utils.NormalizeTags(update.Updates.Tags) normalizedTags := utils.NormalizeTags(update.Updates.Tags)
updateParams.Tags = normalizedTags updateParams.Tags = normalizedTags
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags) tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
updateParams.TagsSearch = tagsSearch updateParams.TagsSearch = tagsSearch
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
} }
updateParams.MetadataOverrides = overrides
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
if err != nil { if err != nil {
@@ -1240,6 +1249,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
} }
coverPath := existing.CoverImagePath.String coverPath := existing.CoverImagePath.String
coverUploaded := false
if req.CoverAction == "remove" { if req.CoverAction == "remove" {
coverPath = "" coverPath = ""
@@ -1251,6 +1261,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
} }
coverPath = savedPath coverPath = savedPath
coverUploaded = true
} }
} }
@@ -1259,7 +1270,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
alternateInfoBytes = []byte(req.AlternateInfo) alternateInfoBytes = []byte(req.AlternateInfo)
} }
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{ params := database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title, Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
@@ -1297,7 +1308,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
AlternateInfo: alternateInfoBytes, AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""}, ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""}, Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
}) }
// Fields the user actually changed become overrides so future scans keep
// the custom values. An explicit cover upload/removal always overrides.
overrides := utils.DetectMetadataOverrides(params, existing)
if req.CoverAction == "remove" || coverUploaded {
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
}
params.MetadataOverrides = overrides
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -1309,6 +1330,67 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item) return c.JSON(http.StatusOK, item)
} }
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
// (admin only). Hard-deletes every archived item (files missing from disk for
// 2+ scans) together with its reading history. The archive retention window
// eventually does the same automatically; this is the manual bulk escape hatch.
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"purged": len(purged),
})
}
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
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"})
}
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
scanner := services.NewMediaScanner(mh.db)
defer scanner.Close()
// reset_overrides=true discards user customizations first, returning the
// item to pure scanned defaults (the "Reset to Scanned" action).
resetOverrides := c.QueryParam("reset_overrides") == "true"
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, item)
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only) // DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c) user := MustGetAuthenticatedUser(c)
@@ -2188,7 +2270,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
return "", fmt.Errorf("media item has no file path") return "", fmt.Errorf("media item has no file path")
} }
coverRelPath := relativeFilePath + ".cover.jpg" // Custom covers live at a dedicated sidecar path (distinct from the
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
// user-uploaded cover and the metadata_overrides set can protect it.
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
}
coverRelPath := relativeFilePath + ".custom_cover" + ext
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath) coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
if err != nil { if err != nil {
+4
View File
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
type MediaDetail struct { type MediaDetail struct {
database.MediaItems // Embedded - ALL book fields available database.MediaItems // Embedded - ALL book fields available
// Absolute on-disk location (library folder + relative path), resolved by
// the page handler so the UI can show where the file lives.
FileLocation string `json:"file_location"`
// User-specific data // User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"` Rating *database.MediaRatings `json:"rating,omitempty"`
Collections []database.Collections `json:"collections"` Collections []database.Collections `json:"collections"`
+22 -1
View File
@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@@ -882,7 +884,8 @@ func registerFrontendRoutes(cfg *Config) {
} }
var buf bytes.Buffer var buf bytes.Buffer
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf) archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -1267,6 +1270,23 @@ func registerFrontendRoutes(cfg *Config) {
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true} mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
} }
// Resolve the on-disk location (library folder + relative path) so the
// detail page can show where the file lives, even for sparse metadata.
// Admin-only: everyday users never receive the absolute path.
fileLocation := ""
if user.Role == "admin" {
fileLocation = mediaItem.FilePath
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
if _, serr := os.Stat(candidate); serr == nil {
fileLocation = candidate
break
}
}
}
}
// Fetch rating // Fetch rating
var rating *database.MediaRatings var rating *database.MediaRatings
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
@@ -1330,6 +1350,7 @@ func registerFrontendRoutes(cfg *Config) {
// Assemble response (no field duplication!) // Assemble response (no field duplication!)
detail := handlers.MediaDetail{ detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating, Rating: rating,
Collections: collections, Collections: collections,
ReadingProgress: progress, ReadingProgress: progress,
+2
View File
@@ -60,7 +60,9 @@ func registerMediaRoutes(cfg *Config) {
// Admin-only media routes // Admin-only media routes
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem) admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
// Shelf management (protected) // Shelf management (protected)
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
+520 -226
View File
@@ -5,6 +5,7 @@ package services
import ( import (
"archive/tar" "archive/tar"
"archive/zip" "archive/zip"
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bytes" "bytes"
@@ -22,7 +23,10 @@ import (
_ "image/png" _ "image/png"
"io" "io"
"io/fs" "io/fs"
"net/url"
"os" "os"
"os/exec"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
@@ -111,6 +115,7 @@ type MediaScanner struct {
defaultLibraryID pgtype.UUID defaultLibraryID pgtype.UUID
libraryTypes map[string][]string libraryTypes map[string][]string
forceRescan bool forceRescan bool
archiveRetentionDays int
logger *ScannerLogger logger *ScannerLogger
dirtyDirs map[string]time.Time dirtyDirs map[string]time.Time
dirtyDirsMu sync.RWMutex dirtyDirsMu sync.RWMutex
@@ -158,6 +163,7 @@ func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{ return &MediaScanner{
db: db, db: db,
watcher: nil, watcher: nil,
archiveRetentionDays: config.ArchiveRetentionDays(),
settingsCache: NewSettingsCache(30 * time.Second), settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time), dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool), fileStability: make(map[string]*atomic.Bool),
@@ -475,17 +481,23 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n", fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
processedFiles, mediaFiles, s.newItems, s.errors) processedFiles, mediaFiles, s.newItems, s.errors)
// Clean up: Find media items in DB that no longer exist on filesystem // Archive lifecycle pass. Items whose files vanished from disk are
// archived after two consecutive missing scans (reading history kept,
// item hidden), and purged for good once archived older than the
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
// Every branch logs - the previous hard-delete cleanup failed silently
// and left orphaned rows undetected.
for _, folder := range s.folders { for _, folder := range s.folders {
lib, err := s.db.GetLibraryByFolder(ctx, folder) lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil { if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
continue continue
} }
libraryID := lib.LibraryID libraryID := lib.LibraryID
dbItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID) dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil { if err != nil {
fmt.Printf("Warning: failed to get library items for cleanup: %v\n", err) fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
continue continue
} }
@@ -500,28 +512,46 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
} }
return nil return nil
}); err != nil { }); err != nil {
fmt.Printf("[RESCAN-CLEANUP] Warning: failed to walk directory %s, skipping orphan cleanup: %v\n", folder, err) fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
continue // Skip to next folder to avoid false deletions continue // Skip to next folder to avoid false archivals
} }
// Delete items whose files no longer exist - with safety logging
for _, item := range dbItems { for _, item := range dbItems {
filePath := item.FilePath if item.FilePath == "" || scannedPaths[item.FilePath] {
if filePath != "" && !scannedPaths[filePath] { continue // File present; unarchive is handled in processMediaFile
msg := fmt.Sprintf("[RESCAN-CLEANUP] Orphaned media item found: ID=%s, Title=%s, Path=%s", }
item.ID, item.Title, filePath)
s.logger.LogDelete(msg)
delMsg := fmt.Sprintf("[RESCAN-CLEANUP] Deleting orphaned item '%s' (file no longer exists at %s)",
item.Title, filePath)
s.logger.LogDelete(delMsg)
if item.ArchivedAt.Valid {
// Still missing and already archived: purge once past the
// retention window (0 = keep until manual purge).
if s.archiveRetentionDays > 0 && time.Now().AddDate(0, 0, -s.archiveRetentionDays).After(item.ArchivedAt.Time) {
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil { if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
errMsg := fmt.Sprintf("[RESCAN-CLEANUP] ERROR: failed to delete orphaned item %s: %v", item.Title, err) fmt.Printf("[ARCHIVE] Error: failed to purge archived item %s: %v\n", item.Title, err)
s.logger.LogDelete(errMsg) s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to purge archived item '%s': %v", item.Title, err))
s.logger.LogError(errMsg)
} else { } else {
s.logger.LogDelete(fmt.Sprintf("[RESCAN-CLEANUP] SUCCESS: deleted orphaned item '%s'", item.Title)) fmt.Printf("[ARCHIVE] Purged archived item '%s' (retention %d days): %s\n", item.Title, s.archiveRetentionDays, item.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Purged archived item '%s' after retention window (file missing at %s)", item.Title, item.FilePath))
}
}
continue
}
// Missing but not yet archived.
if item.MissingScanCount >= 1 {
// Second consecutive missing scan: archive it.
if err := s.db.ArchiveMediaItem(ctx, item.ID); err != nil {
fmt.Printf("[ARCHIVE] Error: failed to archive item %s: %v\n", item.Title, err)
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to archive item '%s': %v", item.Title, err))
} else {
fmt.Printf("[ARCHIVE] Archived item '%s' (missing from disk for %d scans): %s\n", item.Title, item.MissingScanCount+1, item.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Archived item '%s' (file missing from disk at %s)", item.Title, item.FilePath))
}
} else {
// First missing scan: mark, archive on the next one.
if err := s.db.MarkMediaItemMissing(ctx, item.ID); err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to mark item missing %s: %v\n", item.Title, err)
} else {
fmt.Printf("[ARCHIVE] Item missing from disk (1/2 scans before archiving): %s\n", item.FilePath)
} }
} }
} }
@@ -692,11 +722,22 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if err == nil { if err == nil {
fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size()) fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size())
// The file is back on disk: lift any archive/missing state so the
// item reappears in libraries and future missing scans start fresh.
if existingItem.ArchivedAt.Valid || existingItem.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existingItem.ID); err != nil {
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingItem.FilePath, err)
} else if existingItem.ArchivedAt.Valid {
fmt.Printf("[ARCHIVE] Restored from archive, file is back: %s\n", existingItem.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (file returned at %s)", existingItem.Title, existingItem.FilePath))
}
}
// If force rescan is enabled, always re-process // If force rescan is enabled, always re-process
if s.forceRescan { if s.forceRescan {
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path) fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
// Use UPDATE instead of DELETE+INSERT to preserve created_at // Use UPDATE instead of DELETE+INSERT to preserve created_at
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil { if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update existing media item: %v\n", err) fmt.Printf("Warning: failed to update existing media item: %v\n", err)
} }
// Recompute hash identifiers too - a force rescan is the admin's // Recompute hash identifiers too - a force rescan is the admin's
@@ -707,7 +748,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Normal behavior: check if file has changed (by size) // Normal behavior: check if file has changed (by size)
if existingItem.FileSize.Int64 != info.Size() { if existingItem.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating media item: %s\n", path) fmt.Printf("File size changed, updating media item: %s\n", path)
_ = s.updateMediaItem(ctx, existingItem.ID, path, info) _ = s.updateMediaItem(ctx, existingItem, path)
// The bytes changed, so any stored hash is stale. // The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path) s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
return false, nil return false, nil
@@ -756,8 +797,17 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if err == nil && existingByHash.ID.Valid { if err == nil && existingByHash.ID.Valid {
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n", fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
existingByHash.FilePath, path) existingByHash.FilePath, path)
// Content returned (possibly at a new path): restore archived rows.
if existingByHash.ArchivedAt.Valid || existingByHash.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existingByHash.ID); err != nil {
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingByHash.FilePath, err)
} else if existingByHash.ArchivedAt.Valid {
fmt.Printf("[ARCHIVE] Restored from archive, identical content found at %s\n", path)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (identical content found at %s)", existingByHash.Title, path))
}
}
if s.forceRescan { if s.forceRescan {
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info) _ = s.updateMediaItem(ctx, existingByHash, path)
} }
return false, nil return false, nil
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) { } else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -957,6 +1007,94 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
return metadata return metadata
} }
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
// metadata.json sidecar next to the media file. Only fields with a matching
// media_items column are mapped; narrators, subtitle, explicit, abridged and
// chapters are deliberately skipped. Returns nil when no sidecar exists.
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(jsonPath)
if err != nil {
fmt.Printf("Warning: failed to read metadata.json sidecar for %s: %v\n", path, err)
return nil
}
var sidecar struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"`
Series []struct {
Series string `json:"series"`
Sequence string `json:"sequence"`
} `json:"series"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
PublishedYear *int `json:"publishedYear"`
PublishedDate *string `json:"publishedDate"`
Publisher *string `json:"publisher"`
Description *string `json:"description"`
ISBN *string `json:"isbn"`
ASIN *string `json:"asin"`
Language *string `json:"language"`
}
if err := json.Unmarshal(data, &sidecar); err != nil {
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
return nil
}
metadata := &MediaMetadata{
Title: strings.TrimSpace(sidecar.Title),
}
// Subtitle joins the title Calibre-style ("Main: Subtitle") - there is no
// separate subtitle column, and Calibre-sidecar books arrive pre-joined.
if subtitle := strings.TrimSpace(sidecar.Subtitle); subtitle != "" && metadata.Title != "" {
metadata.Title = metadata.Title + ": " + subtitle
}
if len(sidecar.Authors) > 0 {
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
}
if len(sidecar.Series) > 0 {
metadata.Series = strings.TrimSpace(sidecar.Series[0].Series)
if index, err := strconv.ParseFloat(strings.TrimSpace(sidecar.Series[0].Sequence), 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
metadata.Tags = utils.NormalizeTags(tags)
}
if sidecar.PublishedDate != nil {
if date, err := time.Parse("2006-01-02", strings.TrimSpace(*sidecar.PublishedDate)); err == nil {
metadata.PublishDate = date
}
}
if metadata.PublishDate.IsZero() && sidecar.PublishedYear != nil && *sidecar.PublishedYear > 0 {
metadata.PublishDate = time.Date(*sidecar.PublishedYear, 1, 1, 0, 0, 0, 0, time.UTC)
}
if sidecar.Publisher != nil {
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
}
if sidecar.Description != nil {
metadata.Description = strings.TrimSpace(*sidecar.Description)
}
if sidecar.ISBN != nil {
metadata.ISBN = utils.NormalizeISBNSafe(strings.TrimSpace(*sidecar.ISBN))
}
if sidecar.ASIN != nil {
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
if sidecar.Language != nil {
metadata.Language = strings.TrimSpace(*sidecar.Language)
}
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
@@ -1264,6 +1402,8 @@ func extractGenreTagsFromComicInfo(comicInfo *ComicInfo) []string {
} }
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) { func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
// Metadata sidecar priority: Calibre metadata.opf, then Audiobookshelf
// metadata.json, then the media file's own embedded metadata.
// Try Calibre sidecar first // Try Calibre sidecar first
calibreMetadata := s.extractCalibreSidecar(path) calibreMetadata := s.extractCalibreSidecar(path)
if calibreMetadata != nil { if calibreMetadata != nil {
@@ -1278,7 +1418,21 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
return s.mergeMetadata(path, calibreMetadata) return s.mergeMetadata(path, calibreMetadata)
} }
// EXISTING: Fallback to embedded metadata // Audiobookshelf-style metadata.json sidecar (fields without a DB column
// - narrators, subtitle, explicit, abridged, chapters - are skipped)
abMetadata := extractAudiobookshelfSidecar(path)
if abMetadata != nil {
fmt.Printf("Using metadata.json sidecar for %s\n", path)
coverPath := findSidecarCover(path)
if coverPath != "" {
abMetadata.CoverPath = s.getRelativePath(coverPath)
}
return s.mergeMetadata(path, abMetadata)
}
// Fallback to embedded metadata
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
switch ext { switch ext {
@@ -1346,89 +1500,38 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
} }
} }
// extractEPUBMetadata extracts metadata from an EPUB by parsing its embedded
// OPF document directly (container.xml → OPF → Dublin Core elements).
//
// It deliberately does NOT use a full-book parser: the previous go-epub
// implementation parsed every spine chapter and failed the whole call when any
// single chapter (or the TOC) was malformed, discarding perfectly good OPF
// metadata and leaving rescans writing blanks. The OPF holds all the metadata
// we need; chapter damage can no longer affect it.
func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) { func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) {
book, err := epub.ReadBook(path) r, err := zip.OpenReader(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open EPUB: %v", err) return nil, fmt.Errorf("failed to open EPUB: %v", err)
} }
defer func() {
if err := r.Close(); err != nil {
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", path, err)
}
}()
metadata := &MediaMetadata{} opfPath := findOPFPathInZip(r.File)
if opfPath == "" {
// Title return nil, fmt.Errorf("no OPF document found in EPUB %s", path)
if title, err := book.Title(); err == nil && title != "" { }
metadata.Title = title opfContent, err := readFileFromZip(r.File, opfPath)
if err != nil {
return nil, fmt.Errorf("failed to read OPF %s from EPUB: %v", opfPath, err)
} }
// Author metadata, err := parseOPFContent(opfContent)
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 { if err != nil {
metadata.Author = authors[0] return nil, fmt.Errorf("failed to parse OPF in EPUB %s: %v", path, err)
} }
// Description
if descriptions, err := book.MetadataByKey("description"); err == nil && len(descriptions) > 0 {
metadata.Description = descriptions[0]
}
// Publisher
if publishers, err := book.MetadataByKey("publisher"); err == nil && len(publishers) > 0 {
metadata.Publisher = publishers[0]
}
// Series and series number (Calibre specific metadata)
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
metadata.Series = series[0]
}
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
// Publish date
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", dates[0]); err == nil {
metadata.PublishDate = date
}
}
}
// Contributors
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
// Normalize contributors for display
metadata.Contributors = utils.NormalizeContributors(contributors)
}
// ISBN
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
for _, isbn := range isbns {
if strings.Contains(strings.ToLower(isbn), "isbn") {
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
isbnParts := strings.SplitN(isbn, ":", 2)
if len(isbnParts) == 2 {
metadata.ISBN = isbnParts[1]
break
}
}
if strings.Contains(strings.ToLower(isbn), "asin") {
// Extract ASIN from identifier like "asin:B08XXXXX"
asinParts := strings.SplitN(isbn, ":", 2)
if len(asinParts) == 2 {
metadata.ASIN = asinParts[1]
}
}
}
}
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
// Normalize tags for display
metadata.Tags = utils.NormalizeTags(tags)
}
return metadata, nil return metadata, nil
} }
@@ -1603,7 +1706,7 @@ func (s *MediaScanner) LogProcessingIssue(
return err return err
} }
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata // parseCalibreMetadataOPF parses a Calibre metadata.opf sidecar file.
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) { func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
// Open file // Open file
file, err := os.Open(opfPath) file, err := os.Open(opfPath)
@@ -1615,94 +1718,116 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
fmt.Printf("Warning: failed to close metadata.opf: %v\n", err) fmt.Printf("Warning: failed to close metadata.opf: %v\n", err)
} }
}() }()
// Define XML structure for parsing with full Dublin Core namespace URLs content, err := io.ReadAll(file)
var opf struct { if err != nil {
XMLName xml.Name `xml:"package"` return nil, fmt.Errorf("failed to read metadata.opf: %v", err)
Metadata struct {
XMLName xml.Name `xml:"metadata"`
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
Desc []string `xml:"http://purl.org/dc/elements/1.1/ description"`
Publisher []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
Language []string `xml:"http://purl.org/dc/elements/1.1/ language"`
Identifiers []struct {
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
Value string `xml:",chardata"`
} `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
// Calibre-specific meta tags - capture all, filter later
MetaTags []struct {
Name string `xml:"name,attr"`
Value string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
} }
// Parse XML return parseOPFContent(content)
if err := xml.NewDecoder(file).Decode(&opf); err != nil { }
return nil, fmt.Errorf("failed to parse metadata.opf XML: %v", err)
// parseOPFContent parses an OPF document (Dublin Core metadata) into
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
// embedded inside an EPUB - the dc:* vocabulary is identical. Parsing is
// attribute-order agnostic and namespace aware (see media_scanner_opf.go);
// title selection and series detection follow Calibre's behavior.
func parseOPFContent(content []byte) (*MediaMetadata, error) {
opf, err := parseOPFXML(content)
if err != nil {
return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
} }
md := opf.Metadata
// Map to MediaMetadata struct // Map to MediaMetadata struct
metadata := &MediaMetadata{} metadata := &MediaMetadata{}
// Title (required) // Title: EPUB3 title-type main selection with Calibre-style subtitle join
if len(opf.Metadata.Titles) > 0 { if title := opf.selectTitle(); title != "" {
metadata.Title = opf.Metadata.Titles[0] metadata.Title = title
} }
// Author (first creator) // Author (first creator)
if len(opf.Metadata.Creators) > 0 { if len(md.Creators) > 0 {
metadata.Author = opf.Metadata.Creators[0] metadata.Author = md.Creators[0]
}
// Tags (all subjects; one element = one tag, commas are legal inside
// subject headings like "Holmes, Sherlock (Fictitious character)").
// Genre mirrors the sidecar path's processGenresAndTags: first subject.
if len(md.Subjects) > 0 {
metadata.Tags = utils.NormalizeTags(md.Subjects)
if len(metadata.Tags) > 0 {
metadata.Genre = metadata.Tags[0]
} }
// Tags (all subjects)
if len(opf.Metadata.Subjects) > 0 {
metadata.Tags = utils.NormalizeTags(opf.Metadata.Subjects)
} }
// Description // Description
if len(opf.Metadata.Desc) > 0 { if len(md.Descriptions) > 0 {
metadata.Description = opf.Metadata.Desc[0] metadata.Description = md.Descriptions[0]
} }
// Publisher // Publisher
if len(opf.Metadata.Publisher) > 0 { if len(md.Publishers) > 0 {
metadata.Publisher = opf.Metadata.Publisher[0] metadata.Publisher = md.Publishers[0]
}
// Language
if len(md.Languages) > 0 && md.Languages[0] != "" {
metadata.Language = md.Languages[0]
} }
// Publish date // Publish date
if len(opf.Metadata.Dates) > 0 { if len(md.Dates) > 0 {
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", opf.Metadata.Dates[0]); err == nil { if date, err := time.Parse("2006-01-02T15:04:05Z07:00", md.Dates[0]); err == nil {
metadata.PublishDate = date metadata.PublishDate = date
} else if date, err := time.Parse("2006-01-02", opf.Metadata.Dates[0]); err == nil { } else if date, err := time.Parse("2006-01-02", md.Dates[0]); err == nil {
metadata.PublishDate = date metadata.PublishDate = date
} else { } else {
// Try alternative date formats // Try alternative date formats
if date, err := time.Parse("2006", opf.Metadata.Dates[0]); err == nil { if date, err := time.Parse("2006", md.Dates[0]); err == nil {
metadata.PublishDate = date metadata.PublishDate = date
} }
} }
} }
// Identifiers (ISBN, ASIN) // Identifiers: opf:scheme attribute first, then URN-prefixed values
for _, id := range opf.Metadata.Identifiers { // (urn:isbn:...), then bare values that normalize to a valid ISBN.
switch strings.ToUpper(id.Scheme) { for _, id := range md.Identifiers {
value := strings.TrimSpace(id.Value)
scheme := strings.ToUpper(strings.TrimSpace(id.Scheme))
if scheme == "" {
if lower := strings.ToLower(value); strings.HasPrefix(lower, "urn:") {
rest := value[4:]
if prefix, val, ok := strings.Cut(rest, ":"); ok {
scheme = strings.ToUpper(prefix)
value = strings.TrimSpace(val)
}
}
}
switch scheme {
case "ISBN": case "ISBN":
metadata.ISBN = utils.NormalizeISBNSafe(id.Value) metadata.ISBN = utils.NormalizeISBNSafe(value)
case "ASIN": case "ASIN":
metadata.ASIN = id.Value metadata.ASIN = value
case "UUID", "CALIBRE": case "UUID", "CALIBRE":
// Store UUID in hash info, not metadata // Store UUID in hash info, not metadata
// Will be extracted by extractHashInfo() // Will be extracted by extractHashInfo()
default:
// EPUB3 identifiers often carry no opf:scheme attribute; accept a
// bare value only when it is ISBN-shaped (rejects the URIs and
// UUIDs that commonly share the identifier list).
if metadata.ISBN == "" && scheme == "" && isISBNLike(value) {
if normalized, err := utils.NormalizeISBN(value); err == nil {
metadata.ISBN = normalized
}
}
} }
} }
// Contributors // Contributors
if len(opf.Metadata.Contributors) > 0 { if len(md.Contributors) > 0 {
metadata.Contributors = utils.NormalizeContributors(opf.Metadata.Contributors) metadata.Contributors = utils.NormalizeContributors(md.Contributors)
} }
// Calibre-specific meta tags (filter by name attribute) // Series: EPUB3 belongs-to-collection, then calibre:series metas
for _, meta := range opf.Metadata.MetaTags { if series, index := opf.readSeries(); series != "" {
switch meta.Name { metadata.Series = series
case "calibre:series": if index > 0 {
metadata.Series = meta.Value
case "calibre:series_index":
if index, err := strconv.ParseFloat(meta.Value, 32); err == nil {
metadata.SeriesNumber = int32(index) metadata.SeriesNumber = int32(index)
} }
}
// Calibre-specific meta tags (filter by name attribute)
for _, meta := range md.Metas {
switch meta.Name {
case "calibre:rating": case "calibre:rating":
// Not imported (ratings are per-user in Bookhoard) // Not imported (ratings are per-user in Bookhoard)
case "calibre:title_sort": case "calibre:title_sort":
@@ -1714,6 +1839,45 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
return metadata, nil return metadata, nil
} }
// findOPFPathInZip locates the OPF document inside an EPUB by reading
// META-INF/container.xml (string-scraped; we only need the rootfile
// full-path attribute). Returns "" when absent.
func findOPFPathInZip(files []*zip.File) string {
for _, f := range files {
if f.Name != "META-INF/container.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return ""
}
content, readErr := io.ReadAll(rc)
if closeErr := rc.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close META-INF/container.xml reader: %v\n", closeErr)
}
if readErr != nil {
return ""
}
opfStart := bytes.Index(content, []byte("<rootfile "))
if opfStart == -1 {
return ""
}
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
if opfStartAttr == -1 {
return ""
}
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
if opfEndQuote == -1 {
return ""
}
return string(content[opfStartQuote : opfStartQuote+opfEndQuote])
}
return ""
}
// extractEPUBCover extracts the cover image from an EPUB file. // extractEPUBCover extracts the cover image from an EPUB file.
// It looks for: // It looks for:
// 1. An item with properties="cover-image" in the manifest // 1. An item with properties="cover-image" in the manifest
@@ -1735,43 +1899,7 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
// Try to find cover image from OPF metadata // Try to find cover image from OPF metadata
coverImageName := "" coverImageName := ""
// Attempt to read the OPF file to find cover reference opfPath := findOPFPathInZip(r.File)
// First, find container.xml to locate the OPF
var opfPath string
for _, f := range r.File {
if f.Name == "META-INF/container.xml" {
rc, err := f.Open()
if err != nil {
continue
}
content, readErr := io.ReadAll(rc)
if closeErr := rc.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close META-INF/container.xml reader in %s: %v\n", epubPath, closeErr)
}
if readErr != nil {
continue
}
// Parse container.xml to find OPF path
// Simple string search since we just need the path
opfStart := bytes.Index(content, []byte("<rootfile "))
if opfStart == -1 {
continue
}
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
if opfStartAttr == -1 {
continue
}
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
if opfEndQuote == -1 {
continue
}
opfPath = string(content[opfStartQuote : opfStartQuote+opfEndQuote])
break
}
}
if opfPath == "" { if opfPath == "" {
// No OPF found, try common cover image paths // No OPF found, try common cover image paths
@@ -1826,7 +1954,47 @@ func findCoverImageInZip(files []*zip.File) string {
} }
// findCoverInOPF parses OPF content to find cover image reference // findCoverInOPF parses OPF content to find cover image reference
func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string { // findCoverInOPF locates the cover image for an EPUB, following Calibre's
// read_raster_cover resolution order (see media_scanner_opf.go):
// 1. manifest item with properties="cover-image"
// 2. <meta name="cover"> resolved through the manifest
// 3. the first spine item being a raster image itself (store manga)
// 4. NEW: the cover page (guide type="cover" or first spine item) mined for
// <img src> / SVG <image xlink:href> - covers books that declare no
// raster cover at all, e.g. classic EPUB2/Adobe cover.xhtml wrappers
// 5. filename guessing in the zip (pre-existing fallback)
//
// XML parsing is attribute-order agnostic; if the OPF is too malformed for
// encoding/xml, the legacy regex chain runs as a compatibility fallback.
func findCoverInOPF(opfContent []byte, files []*zip.File, opfPath string) string {
opf, err := parseOPFXML(opfContent)
if err != nil {
return findCoverInOPFLegacy(opfContent, files, opfPath)
}
if href := opf.findRasterCoverInOPF(); href != "" {
return resolveOPFPath(opfPath, href)
}
// Cover-page fallback: Calibre renders the page; we extract the image it
// references (the practical case - the page wraps a raster in img/SVG).
if pageHref := opf.coverPageHref(); pageHref != "" {
if pageContent, err := readFileFromZip(files, resolveOPFPath(opfPath, pageHref)); err == nil {
if imgRef := findImageReferenceInPage(pageContent); imgRef != "" {
imgPath := resolveOPFPath(resolveOPFPath(opfPath, pageHref), imgRef)
if zipHasFile(files, imgPath) {
return imgPath
}
}
}
}
return findCoverImageInZip(files)
}
// findCoverInOPFLegacy is the pre-XML cover lookup, kept solely as a
// fallback for OPFs too malformed for a real XML parse.
func findCoverInOPFLegacy(opfContent []byte, files []*zip.File, opfPath string) string {
contentStr := string(opfContent) contentStr := string(opfContent)
// Look for item with properties="cover-image" // Look for item with properties="cover-image"
@@ -1838,7 +2006,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID)) hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
hrefMatches := hrefRE.FindStringSubmatch(contentStr) hrefMatches := hrefRE.FindStringSubmatch(contentStr)
if len(hrefMatches) > 1 { if len(hrefMatches) > 1 {
return resolveOPFPath(opfDir, hrefMatches[1]) return resolveOPFPath(opfPath, hrefMatches[1])
} }
} }
@@ -1852,7 +2020,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID)) hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
hrefMatches := hrefRE.FindStringSubmatch(contentStr) hrefMatches := hrefRE.FindStringSubmatch(contentStr)
if len(hrefMatches) > 1 { if len(hrefMatches) > 1 {
return resolveOPFPath(opfDir, hrefMatches[1]) return resolveOPFPath(opfPath, hrefMatches[1])
} }
} }
} }
@@ -1861,18 +2029,34 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
return findCoverImageInZip(files) return findCoverImageInZip(files)
} }
// resolveOPFPath resolves a relative path against the OPF directory // zipHasFile reports whether the zip contains an entry with exactly this name.
func resolveOPFPath(opfDir, href string) string { func zipHasFile(files []*zip.File, name string) bool {
if opfDir == "" { name = filepath.ToSlash(name)
return href for _, f := range files {
if filepath.ToSlash(f.Name) == name {
return true
} }
// Handle ../ in href
if strings.HasPrefix(href, "../") {
// Simple case: just use the href as-is for now
return href
} }
// Join the directory with the href return false
return filepath.Join(filepath.Dir(opfDir), href) }
// resolveOPFPath resolves an OPF-relative href against the OPF document's own
// path inside the zip. Hrefs are URL-decoded and normalized with posix path
// semantics ("../" walks up), matching Calibre's
// posixpath.normpath(posixpath.join(base, href)).
func resolveOPFPath(opfPath, href string) string {
if unescaped, err := url.PathUnescape(href); err == nil {
href = unescaped
}
href = strings.TrimPrefix(filepath.ToSlash(href), "/")
base := ""
if dir := path.Dir(filepath.ToSlash(opfPath)); dir != "." {
base = dir
}
if base == "" {
return path.Clean(href)
}
return path.Clean(path.Join(base, href))
} }
// readFileFromZip reads a file from the zip by name // readFileFromZip reads a file from the zip by name
@@ -2078,17 +2262,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
// Use pdfcpu API to extract images from first page // Use pdfcpu API to extract images from first page
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error // ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil) err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
if err != nil { if err == nil {
// No images found or extraction failed - this is OK, just return empty
return "", nil
}
// Check for extracted images in the temp directory // Check for extracted images in the temp directory
entries, err := os.ReadDir(tmpDir) entries, err := os.ReadDir(tmpDir)
if err != nil || len(entries) == 0 { if err == nil && len(entries) > 0 {
return "", nil
}
// Find the largest image (likely the cover) // Find the largest image (likely the cover)
var largestImage string var largestImage string
var largestSize int64 var largestSize int64
@@ -2111,16 +2288,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
} }
} }
if largestImage == "" { if largestImage != "" {
return "", nil
}
// Read the image // Read the image
imageData, err := os.ReadFile(largestImage) imageData, err := os.ReadFile(largestImage)
if err != nil || len(imageData) == 0 { if err == nil && len(imageData) > 0 {
return "", nil
}
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg) // Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg" coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil { if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
@@ -2128,6 +2299,61 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
} }
return coverPath, nil return coverPath, nil
}
}
}
}
// No embedded raster cover found (e.g. vector/text first page) - fall back
// to rendering the first page with pdftoppm (poppler-utils).
return s.renderPDFCoverPage(pdfPath), nil
}
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
// Returns the path to the saved cover, or empty string if rendering failed.
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
if _, err := exec.LookPath("pdftoppm"); err != nil {
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
return ""
}
tmpDir, err := os.MkdirTemp("", "pdf-render-")
if err != nil {
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
return ""
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
}
}()
outPrefix := filepath.Join(tmpDir, "cover")
// -cropbox renders the CropBox (the viewer-visible region, matching pdf.js)
// rather than the MediaBox; poppler falls back to the MediaBox when no
// CropBox is defined. This matters for PDFs whose page 1 is a full print
// cover wrap (back + spine + front) with a CropBox covering just the front.
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-cropbox", "-r", "150", pdfPath, outPrefix)
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
return ""
}
imageData, err := os.ReadFile(outPrefix + ".jpg")
if err != nil || len(imageData) < 1000 {
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
return ""
}
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
return ""
}
return coverPath
} }
// ComicInfo represents metadata from ComicInfo.xml // ComicInfo represents metadata from ComicInfo.xml
@@ -2562,7 +2788,11 @@ func countArchiveImages(filePath string) (int, error) {
return count, nil return count, nil
} }
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error { // updateMediaItem re-extracts metadata for an existing media item and writes
// it back. Fields listed in the item's metadata_overrides set are preserved
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update // Re-extract metadata for the update
metadata, err := s.extractMetadata(path) metadata, err := s.extractMetadata(path)
if err != nil { if err != nil {
@@ -2581,8 +2811,8 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
if metadata.AlternateInfo != "" { if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo) alternateInfoBytes = []byte(metadata.AlternateInfo)
} }
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{ params := database.UpdateMediaItemParams{
ID: mediaItemID, ID: existing.ID,
Title: metadata.Title, Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""}, Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""}, Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
@@ -2614,10 +2844,74 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
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 != ""},
}) }
// Keep user-customized fields, and keep the override set itself intact.
// MergeOverrides guarantees a non-nil slice: a nil []string would encode
// as SQL NULL and violate metadata_overrides' NOT NULL constraint.
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
_, err = s.db.UpdateMediaItem(ctx, params)
return err return err
} }
// RescanMediaItem re-extracts metadata for a single media item and updates it.
// It is the per-book rescan used by the Edit Metadata dialog and backfills
// covers for items imported before the PDF render fallback existed.
// With resetOverrides, user customizations are discarded first: the item is
// returned to pure scanned defaults (the "Reset to Scanned" action).
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID, resetOverrides bool) error {
item, err := s.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return fmt.Errorf("media item not found: %w", err)
}
if resetOverrides {
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
return fmt.Errorf("failed to clear metadata overrides: %w", err)
}
// Empty - not nil: pgx encodes a nil []string parameter as SQL NULL,
// which would violate the column's NOT NULL constraint when
// updateMediaItem writes the row back.
item.MetadataOverrides = []string{}
}
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
}
folderPaths := make([]string, 0, len(folders))
for _, folder := range folders {
folderPaths = append(folderPaths, folder.FolderPath)
}
s.folders = folderPaths
var fullPath string
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, item.FilePath)
if _, err := os.Stat(candidate); err == nil {
fullPath = candidate
break
}
}
if fullPath == "" {
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
}
if _, err := os.Stat(fullPath); err != nil {
return fmt.Errorf("failed to stat media file: %w", err)
}
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
return nil
}
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) { func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{ return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
FilePath: s.getRelativePath(filePath), FilePath: s.getRelativePath(filePath),
@@ -0,0 +1,232 @@
package services
import (
"archive/zip"
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
"time"
)
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
func createTestJPEGBytes() string {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
if err := jpeg.Encode(&buf, img, nil); err != nil {
return ""
}
return buf.String()
}
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
// output: the dc namespace declared on the <metadata> element (not on
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
// deliberately malformed chapter body. The malformed chapter is the
// regression trigger: the previous go-epub-based extractor failed the whole
// book when any chapter was unparseable and wrote blank metadata.
func createPragmaticStyleEPUB(epubPath string) error {
file, err := os.Create(epubPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
})
if err != nil {
return err
}
mimetypeW.Write([]byte("application/epub+zip"))
files := map[string]string{
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
// dc namespace declared on <metadata>; identifiers carry no scheme attr
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:language>en</dc:language>
<dc:title>A Common-Sense Guide</dc:title>
<dc:creator>Jay Wengrow</dc:creator>
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
<dc:description>Content that makes you a better programmer.</dc:description>
<dc:subject>Programming</dc:subject>
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="ch1"/></spine>
</package>`,
// Malformed on purpose: unclosed tags
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
}
for name, content := range files {
w, err := zipWriter.Create(name)
if err != nil {
return err
}
if _, err := w.Write([]byte(content)); err != nil {
return err
}
}
return zipWriter.Close()
}
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
// unparseable chapter made the extractor return nothing at all: metadata must
// come from the OPF regardless of chapter-body damage.
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
tmpDir := t.TempDir()
epubPath := filepath.Join(tmpDir, "book.epub")
if err := createPragmaticStyleEPUB(epubPath); err != nil {
t.Fatalf("failed to create test EPUB: %v", err)
}
s := NewMediaScanner(nil)
metadata, err := s.extractEPUBMetadata(epubPath)
if err != nil {
t.Fatalf("extractEPUBMetadata() error: %v", err)
}
if metadata.Title != "A Common-Sense Guide" {
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
}
if metadata.Author != "Jay Wengrow" {
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
}
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
}
if metadata.Description == "" {
t.Error("Description missing")
}
if metadata.Language != "en" {
t.Errorf("Language = %q, want %q", metadata.Language, "en")
}
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
if metadata.ISBN == "" {
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
}
}
func TestParseOPFContentCalibreSeries(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Test Book</dc:title>
<dc:creator>Some Author</dc:creator>
<dc:date>2020-03-15</dc:date>
<dc:subject>Fiction</dc:subject>
<dc:subject>Classic</dc:subject>
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
<meta name="calibre:series" content="Great Series"/>
<meta name="calibre:series_index" content="2.5"/>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("schemed ISBN not extracted")
}
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
if !metadata.PublishDate.Equal(wantDate) {
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
}
if len(metadata.Tags) != 2 {
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
}
}
func TestExtractAudiobookshelfSidecar(t *testing.T) {
tests := []struct {
name string
json string
validate func(t *testing.T, m *MediaMetadata)
}{
{
name: "full sidecar",
json: `{
"title": "An Book",
"authors": ["Author One", "Author Two"],
"series": [{"series": "The Series", "sequence": "4.5"}],
"genres": ["Fantasy"],
"tags": ["tag1"],
"publishedYear": 2019,
"publisher": "ACME Books",
"description": "A very good book.",
"isbn": "978-3-16-148410-0",
"asin": "B08XYZ",
"language": "en"
}`,
validate: func(t *testing.T, m *MediaMetadata) {
if m.Title != "An Book" || m.Author != "Author One" {
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
}
if m.Series != "The Series" || m.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
}
if len(m.Tags) != 2 {
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
}
if m.PublishDate.Year() != 2019 {
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
}
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
}
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
}
},
},
{
name: "sparse sidecar (real-world Audiobookshelf export)",
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
validate: func(t *testing.T, m *MediaMetadata) {
if m.Title != "Sparse (1234)" || m.Author != "X" {
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
}
if m.Description != "" || m.Tags != nil {
t.Error("null/empty sidecar fields must stay unset")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
t.Fatal(err)
}
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
if m == nil {
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
}
tt.validate(t, m)
})
}
t.Run("no sidecar returns nil", func(t *testing.T) {
dir := t.TempDir()
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
}
})
}
+327
View File
@@ -0,0 +1,327 @@
package services
import (
"bytes"
"encoding/xml"
"strconv"
"strings"
)
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
// with attribute-order-sensitive regexes; real books serialize attributes in
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
// content before name), which silently defeated cover detection. Everything
// here is parsed with encoding/xml so attribute order and namespace prefix
// choices are irrelevant.
type opfDCValue struct {
ID string `xml:"id,attr"`
Value string `xml:",chardata"`
}
type opfIdentifier struct {
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
Value string `xml:",chardata"`
}
type opfMeta struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
Property string `xml:"property,attr"`
Refines string `xml:"refines,attr"`
Value string `xml:",chardata"`
}
type opfItem struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
Properties string `xml:"properties,attr"`
}
// opfDocument is a structured view of an OPF package document.
type opfDocument struct {
Metadata struct {
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
Metas []opfMeta `xml:"meta"`
} `xml:"metadata"`
Manifest struct {
Items []opfItem `xml:"item"`
} `xml:"manifest"`
Spine struct {
Itemrefs []struct {
IDRef string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
Guide struct {
References []struct {
Type string `xml:"type,attr"`
Href string `xml:"href,attr"`
} `xml:"reference"`
} `xml:"guide"`
}
func parseOPFXML(content []byte) (*opfDocument, error) {
var doc opfDocument
if err := xml.Unmarshal(content, &doc); err != nil {
return nil, err
}
return &doc, nil
}
// refinesFor maps an element id to its EPUB3 refining metas
// (those whose refines attribute starts with '#').
func (d *opfDocument) refinesFor(id string) []opfMeta {
var out []opfMeta
if id == "" {
return out
}
for _, m := range d.Metadata.Metas {
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
out = append(out, m)
}
}
return out
}
// refinesProperty returns the value of the first refining meta carrying the
// given property (e.g. "title-type", "collection-type", "group-position").
func refinesProperty(metas []opfMeta, property string) (string, bool) {
for _, m := range metas {
if strings.EqualFold(m.Property, property) {
if v := strings.TrimSpace(m.Value); v != "" {
return v, true
}
}
}
return "", false
}
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
// title-type "main"; fall back to the first non-empty title. A distinct
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
// main title with ": ", exactly as Calibre stores it.
func (d *opfDocument) selectTitle() string {
var first, main, subtitle string
for _, t := range d.Metadata.Titles {
v := strings.TrimSpace(t.Value)
if v == "" {
continue
}
if first == "" {
first = v
}
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
if !ok {
continue
}
switch strings.ToLower(tt) {
case "main":
if main == "" {
main = v
}
default:
l := strings.ToLower(tt)
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
if subtitle == "" {
subtitle = v
}
}
}
}
title := main
if title == "" {
title = first
}
if subtitle != "" && subtitle != title {
title = title + ": " + subtitle
}
return title
}
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
// collection-type=series refine and group-position index) first, then the
// classic calibre:series / calibre:series_index metas.
func (d *opfDocument) readSeries() (series string, index float64) {
for _, m := range d.Metadata.Metas {
if !strings.EqualFold(m.Property, "belongs-to-collection") {
continue
}
name := strings.TrimSpace(m.Value)
if name == "" {
continue
}
refines := d.refinesFor(m.ID)
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
continue
}
if gp, ok := refinesProperty(refines, "group-position"); ok {
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
index = v
}
}
return name, index
}
for _, m := range d.Metadata.Metas {
switch m.Name {
case "calibre:series":
series = m.Content
case "calibre:series_index":
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
index = v
}
}
}
return series, index
}
// itemByID returns manifest items with id, href and media-type, keyed by id.
func (d *opfDocument) itemByID() map[string]opfItem {
m := make(map[string]opfItem, len(d.Manifest.Items))
for _, it := range d.Manifest.Items {
if it.ID != "" && it.Href != "" && it.MediaType != "" {
m[it.ID] = it
}
}
return m
}
// firstSpineItem returns the manifest item for the first spine idref.
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
if len(d.Spine.Itemrefs) == 0 {
return opfItem{}, false
}
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
return item, ok
}
// isRasterMedia reports whether a manifest media-type is an image but not an
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
// cover images.
func isRasterMedia(mediaType string) bool {
mt := strings.ToLower(strings.TrimSpace(mediaType))
if mt == "" {
return false
}
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
return false
}
return strings.HasPrefix(mt, "image/")
}
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
// 1. manifest item with properties containing "cover-image"
// 2. <meta name="cover" content="ID"> resolved through the manifest
// 3. the first spine item being a raster image itself (store manga)
//
// Returns the OPF-relative href of the cover image, or "".
func (d *opfDocument) findRasterCoverInOPF() string {
// 1. properties="cover-image" (space-separated property list)
for _, it := range d.Manifest.Items {
for _, prop := range strings.Fields(it.Properties) {
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
return it.Href
}
}
}
// 2. meta name="cover" content=<manifest image id>
byID := d.itemByID()
for _, m := range d.Metadata.Metas {
if !strings.EqualFold(m.Name, "cover") {
continue
}
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
return it.Href
}
}
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
if it, ok := d.firstSpineItem(); ok {
mt := strings.ToLower(it.MediaType)
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
return it.Href
}
}
return ""
}
// coverPageHref returns the OPF-relative href of the cover *page* document to
// mine for an embedded image: the guide's type="cover" reference when
// present, otherwise the first spine item (Calibre renders the latter).
func (d *opfDocument) coverPageHref() string {
for _, ref := range d.Guide.References {
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
return ref.Href
}
}
if it, ok := d.firstSpineItem(); ok {
if it.Href != "" && !isRasterMedia(it.MediaType) {
return it.Href
}
}
return ""
}
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
// significant characters). Guards the scheme-less dc:identifier fallback
// against URLs and UUIDs sharing the same slot.
func isISBNLike(v string) bool {
digits := 0
for i, r := range v {
switch {
case r >= '0' && r <= '9':
digits++
case r == '-' || r == ' ':
// separator
case (r == 'X' || r == 'x') && i == len(v)-1:
digits++ // ISBN-10 check character
default:
return false
}
}
return digits == 10 || digits == 13
}
// findImageReferenceInPage extracts the first raster image reference from a
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
// Returns the reference relative to the page document, or "".
func findImageReferenceInPage(pageContent []byte) string {
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
for {
tok, err := decoder.Token()
if err != nil {
return ""
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch strings.ToLower(start.Name.Local) {
case "img":
for _, a := range start.Attr {
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
return strings.TrimSpace(a.Value)
}
}
case "image":
for _, a := range start.Attr {
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
return strings.TrimSpace(a.Value)
}
}
}
}
}
+223
View File
@@ -0,0 +1,223 @@
package services
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
// helper to build an EPUB zip from a file map for cover tests
func writeEPUB(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := zip.NewWriter(f)
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
if err != nil {
t.Fatal(err)
}
mimetype.Write([]byte("application/epub+zip"))
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
// Live" serialization (href before id, content before name on the meta tag).
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>3 Days to Live</dc:title>
<meta content="cover-image" name="cover"/>
</metadata>
<manifest>
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
</manifest>
<spine/>
</package>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/images/9781538752760.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "book.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("cover not extracted - attribute order still defeats resolution")
}
if _, err := os.Stat(coverPath); err != nil {
t.Fatalf("cover file not written: %v", err)
}
}
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
// (here via SVG), reachable through the guide reference or first spine item.
func TestFindCoverInOPFCoverPage(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Old Adobe Book</dc:title>
</metadata>
<manifest>
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="coverpage"/></spine>
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
</package>`
coverPage := `<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
</body>
</html>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/text/cover.xhtml": coverPage,
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
}
}
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
// item is a raster image itself (Calibre's third resolution step).
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
<manifest>
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="p1"/></spine>
</package>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/pages/0001.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "manga.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("image-first spine cover not detected")
}
}
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
// series with collection-type and group-position refines.
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="t1">The Main Title</dc:title>
<dc:title id="t2">The Subtitle</dc:title>
<meta refines="#t1" property="title-type">main</meta>
<meta refines="#t2" property="title-type">subtitle</meta>
<dc:subject>Programming</dc:subject>
<dc:subject>Algorithms</dc:subject>
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
<meta refines="#coll1" property="collection-type">series</meta>
<meta refines="#coll1" property="group-position">4.5</meta>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if want := "The Main Title: The Subtitle"; metadata.Title != want {
t.Errorf("Title = %q, want %q", metadata.Title, want)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("urn:isbn: identifier not extracted")
}
if metadata.Genre != "Programming" {
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
}
if len(metadata.Tags) != 2 {
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
}
}
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
// embedded commas as single tags (Library of Congress style headings).
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>A Study in Scarlet</dc:title>
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if len(metadata.Tags) != 1 {
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
}
}
// TestResolveOPFPath checks URL decoding and posix normalization of
// OPF-relative hrefs.
func TestResolveOPFPath(t *testing.T) {
tests := []struct {
opfPath, href, want string
}{
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
{"package.opf", "cover.jpg", "cover.jpg"},
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
}
for _, tt := range tests {
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
}
}
}
+305
View File
@@ -0,0 +1,305 @@
package utils
import (
"strconv"
"strings"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// Per-field user-override tracking for media item metadata.
//
// metadata_overrides is a TEXT[] column on media_items holding the names of
// columns the user has customized via the metadata editor. Library scans and
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
// only the reset-to-scanned-defaults action clears the set.
const (
OverrideTitle = "title"
OverrideAuthor = "author"
OverrideISBN = "isbn"
OverrideDescription = "description"
OverrideCoverImagePath = "cover_image_path"
OverrideSeries = "series"
OverrideSeriesNumber = "series_number"
OverrideTags = "tags"
OverrideAsin = "asin"
OverrideDatePublished = "date_published"
OverridePublisher = "publisher"
OverrideContributors = "contributors"
OverrideLanguage = "language"
OverrideEdition = "edition"
OverridePageCount = "page_count"
OverrideGenre = "genre"
OverrideCopyrightYear = "copyright_year"
OverrideGoodreadsID = "goodreads_id"
OverrideOpenlibraryID = "openlibrary_id"
OverrideGoogleBooksID = "google_books_id"
OverrideMangaType = "manga_type"
OverrideReadingDirection = "reading_direction"
OverrideSeriesCount = "series_count"
OverrideVolume = "volume"
OverrideImprint = "imprint"
OverrideAgeRating = "age_rating"
OverrideWebURL = "web_url"
OverrideMetadataNotes = "metadata_notes"
OverrideCommunityRating = "community_rating"
OverrideStoryArc = "story_arc"
OverrideIsBlackAndWhite = "is_black_and_white"
OverrideAlternateInfo = "alternate_info"
OverrideScanInformation = "scan_information"
OverrideSummary = "summary"
)
// hasOverride reports whether key is in the override set.
func hasOverride(overrides []string, key string) bool {
for _, k := range overrides {
if k == key {
return true
}
}
return false
}
// MergeOverrides unions existing and added, preserving order and dropping
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
func MergeOverrides(existing []string, added ...string) []string {
seen := make(map[string]bool, len(existing)+len(added))
merged := make([]string, 0, len(existing)+len(added))
for _, k := range existing {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
for _, k := range added {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
return merged
}
// Normalizers turn nullable column values into comparable strings so that
// "unset" (invalid/zero) forms compare equal regardless of which side they
// come from.
func normText(t pgtype.Text) string {
if !t.Valid {
return ""
}
return t.String
}
func normInt(i pgtype.Int4) string {
if !i.Valid {
return ""
}
return strconv.FormatInt(int64(i.Int32), 10)
}
func normFloat(f pgtype.Float8) string {
if !f.Valid {
return ""
}
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
}
func normBool(b pgtype.Bool) string {
if !b.Valid {
return ""
}
return strconv.FormatBool(b.Bool)
}
func normDate(d pgtype.Date) string {
if !d.Valid {
return ""
}
return d.Time.Format("2006-01-02")
}
func normStringSlice(s []string) string {
if len(s) == 0 {
return ""
}
return strings.Join(s, "\x1f")
}
func normBytes(b []byte) string {
if len(b) == 0 {
return ""
}
return string(b)
}
// detectMetadataOverridesDiff returns the keys whose value in params differs
// from the existing row. Used by the metadata editor save path to grow the
// override set with exactly the fields the user changed.
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
var changed []string
add := func(key string, differs bool) {
if differs {
changed = append(changed, key)
}
}
add(OverrideTitle, params.Title != existing.Title)
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
return changed
}
// DetectMetadataOverrides returns the union of the existing override set and
// any fields whose incoming (user-submitted) values differ from the stored
// row. Overrides accumulate: a field stays protected until an explicit reset,
// even if a later save reverts the value.
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
}
// ApplyMetadataOverrides restores every overridden field in params from the
// existing row so scanner updates cannot clobber user customizations. Derived
// search columns are restored together with their base column.
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
o := existing.MetadataOverrides
if hasOverride(o, OverrideTitle) {
params.Title = existing.Title
}
if hasOverride(o, OverrideAuthor) {
params.Author = existing.Author
}
if hasOverride(o, OverrideISBN) {
params.Isbn = existing.Isbn
}
if hasOverride(o, OverrideDescription) {
params.Description = existing.Description
}
if hasOverride(o, OverrideCoverImagePath) {
params.CoverImagePath = existing.CoverImagePath
}
if hasOverride(o, OverrideSeries) {
params.Series = existing.Series
}
if hasOverride(o, OverrideSeriesNumber) {
params.SeriesNumber = existing.SeriesNumber
}
if hasOverride(o, OverrideTags) {
params.Tags = existing.Tags
params.TagsSearch = existing.TagsSearch
}
if hasOverride(o, OverrideAsin) {
params.Asin = existing.Asin
}
if hasOverride(o, OverrideDatePublished) {
params.DatePublished = existing.DatePublished
}
if hasOverride(o, OverridePublisher) {
params.Publisher = existing.Publisher
}
if hasOverride(o, OverrideContributors) {
params.Contributors = existing.Contributors
params.ContributorsSearch = existing.ContributorsSearch
}
if hasOverride(o, OverrideLanguage) {
params.Language = existing.Language
}
if hasOverride(o, OverrideEdition) {
params.Edition = existing.Edition
}
if hasOverride(o, OverridePageCount) {
params.PageCount = existing.PageCount
}
if hasOverride(o, OverrideGenre) {
params.Genre = existing.Genre
}
if hasOverride(o, OverrideCopyrightYear) {
params.CopyrightYear = existing.CopyrightYear
}
if hasOverride(o, OverrideGoodreadsID) {
params.GoodreadsID = existing.GoodreadsID
}
if hasOverride(o, OverrideOpenlibraryID) {
params.OpenlibraryID = existing.OpenlibraryID
}
if hasOverride(o, OverrideGoogleBooksID) {
params.GoogleBooksID = existing.GoogleBooksID
}
if hasOverride(o, OverrideMangaType) {
params.MangaType = existing.MangaType
}
if hasOverride(o, OverrideReadingDirection) {
params.ReadingDirection = existing.ReadingDirection
}
if hasOverride(o, OverrideSeriesCount) {
params.SeriesCount = existing.SeriesCount
}
if hasOverride(o, OverrideVolume) {
params.Volume = existing.Volume
}
if hasOverride(o, OverrideImprint) {
params.Imprint = existing.Imprint
}
if hasOverride(o, OverrideAgeRating) {
params.AgeRating = existing.AgeRating
}
if hasOverride(o, OverrideWebURL) {
params.WebUrl = existing.WebUrl
}
if hasOverride(o, OverrideMetadataNotes) {
params.MetadataNotes = existing.MetadataNotes
}
if hasOverride(o, OverrideCommunityRating) {
params.CommunityRating = existing.CommunityRating
}
if hasOverride(o, OverrideStoryArc) {
params.StoryArc = existing.StoryArc
}
if hasOverride(o, OverrideIsBlackAndWhite) {
params.IsBlackAndWhite = existing.IsBlackAndWhite
}
if hasOverride(o, OverrideAlternateInfo) {
params.AlternateInfo = existing.AlternateInfo
}
if hasOverride(o, OverrideScanInformation) {
params.ScanInformation = existing.ScanInformation
}
if hasOverride(o, OverrideSummary) {
params.Summary = existing.Summary
}
}
+112
View File
@@ -0,0 +1,112 @@
package utils
import (
"reflect"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
func TestMergeOverrides(t *testing.T) {
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
want := []string{"title", "tags", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("MergeOverrides() = %v, want %v", got, want)
}
if MergeOverrides(nil) == nil {
t.Error("MergeOverrides(nil) must return non-nil slice")
}
}
func TestDetectMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned description", Valid: true},
Tags: []string{"foo", "bar"},
}
params := database.UpdateMediaItemParams{
// unchanged
Title: "Scanned Title",
// changed
Description: pgtype.Text{String: "My custom description", Valid: true},
Tags: []string{"foo", "bar"},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"description"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
}
// Overrides accumulate: an existing override survives a later save.
existing.MetadataOverrides = []string{"publisher"}
got = DetectMetadataOverrides(params, existing)
want := []string{"publisher", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
}
// An untouched save detects nothing new.
noop := database.UpdateMediaItemParams{
Title: existing.Title,
Description: existing.Description,
Tags: existing.Tags,
}
got = DetectMetadataOverrides(noop, existing)
if !reflect.DeepEqual(got, []string{"publisher"}) {
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
}
}
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
// Zero-value params must not look "changed" against NULL-ish columns.
existing := database.MediaItems{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
params := database.UpdateMediaItemParams{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"series_number"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
}
}
func TestApplyMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Custom Title",
Description: pgtype.Text{String: "Custom desc", Valid: true},
Tags: []string{"mine"},
TagsSearch: []string{"mine"},
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
}
params := database.UpdateMediaItemParams{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned desc", Valid: true},
Tags: []string{"scanned"},
TagsSearch: []string{"scanned"},
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
}
// Only title and tags overridden; scanned description and cover win.
existing.MetadataOverrides = []string{"title", "tags"}
ApplyMetadataOverrides(&params, existing)
if params.Title != "Custom Title" {
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
}
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
t.Error("Tags/TagsSearch must be restored together")
}
if params.Description.String != "Scanned desc" {
t.Errorf("Description = %q, want scanned value", params.Description.String)
}
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
}
}
+18 -1
View File
@@ -1,6 +1,6 @@
package templates package templates
templ AdminLibrary(user User, libraries []LibraryData, users []User) { templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -34,6 +34,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</button> </button>
</div> </div>
</div> </div>
if archivedCount > 0 {
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="font-semibold" style="color: var(--text-primary)">
Archived items: { archivedCount }
</p>
<p class="text-sm" style="color: var(--text-secondary)">
Files missing from disk for two consecutive scans. Reading history is kept
unless purged; items return automatically if their files come back.
</p>
</div>
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4")
Purge Archived Now
</button>
</div>
}
<div id="libraries-container"> <div id="libraries-container">
@LibraryList(user, libraries, users) @LibraryList(user, libraries, users)
</div> </div>
File diff suppressed because it is too large Load Diff
+10
View File
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span> <span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
} }
</button> </button>
if user.Role == "admin" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5"> <button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
<span>Edit</span> <span>Edit</span>
</button> </button>
}
</div> </div>
<!-- Rating --> <!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0"> <div class="mb-5" @mouseleave="ratingHover = 0">
@@ -376,6 +378,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p> <p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div> </div>
} }
if user.Role == "admin" {
<div class="md:col-span-2 lg:col-span-3">
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
</div>
}
</div> </div>
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"> <div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div> </div>
@ProgressSyncModal(user, book) @ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book) @NotesHighlightsModal(user, book)
if user.Role == "admin" {
@MetadataEditorModal(book) @MetadataEditorModal(book)
}
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
+33 -20
View File
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden" <input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
@change="handleCoverUpload($event)" /> @change="handleCoverUpload($event)" />
<div class="w-64 space-y-2"> <div class="w-64 space-y-2">
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="coverGenerating"
disabled
>
@Icon("refresh", "h-4 w-4")
Generating...
</button>
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="!coverGenerating"
>
@Icon("refresh", "h-4 w-4")
Generate Cover
</button>
<button <button
type="button" type="button"
class="btn btn-ghost w-full" class="btn btn-ghost w-full"
@@ -605,11 +586,42 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
class="input opacity-60" /> class="input opacity-60" />
</div> </div>
</div> </div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
<input type="text" value={ book.FileLocation } readonly
class="input opacity-60 font-mono text-xs" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);"> </div>
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<div class="flex items-center gap-2">
<button
@click="rescanBook()"
:disabled="rescanning"
class="btn btn-secondary"
>
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<button
@click="resetMetadata()"
:disabled="resetting"
class="btn btn-secondary"
title="Discard all manual edits and restore scanned metadata"
>
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
</div>
<div class="flex space-x-3">
<button <button
@click="hideMetadataEditor()" @click="hideMetadataEditor()"
class="btn btn-secondary" class="btn btn-secondary"
@@ -626,4 +638,5 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div> </div>
</div> </div>
</div> </div>
</div>
} }
+126 -113
View File
@@ -689,23 +689,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"coverGenerating\" disabled>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Generating...</button> <button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"!coverGenerating\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Generate Cover</button> <button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -713,7 +697,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> <span x-show=\"!openSections.basic\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> <span x-show=\"!openSections.basic\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -721,59 +705,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span></span></button><div x-show=\"openSections.basic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Title</label> <input type=\"text\" name=\"title\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span></button><div x-show=\"openSections.basic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Title</label> <input type=\"text\" name=\"title\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 336, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 317, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Author</label> <input type=\"text\" name=\"author\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Author</label> <input type=\"text\" name=\"author\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author)) templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 340, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 321, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description)) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 345, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 326, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary)) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 350, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 331, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -781,43 +765,43 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</button></span></template></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</button></span></template></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, tag := range book.Tags { for _, tag := range book.Tags {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span data-editor-tag=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span data-editor-tag=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 363, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 344, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"hidden\"></span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" class=\"hidden\"></span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64)) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 397, Col: 66} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 378, Col: 66}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -825,7 +809,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> <span x-show=\"!openSections.publication\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -833,85 +817,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Publisher</label> <input type=\"text\" name=\"publisher\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Publisher</label> <input type=\"text\" name=\"publisher\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher)) templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 415, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 396, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished)) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 420, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 401, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var42 string var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition)) templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 425, Col: 76} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 406, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language)) templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 429, Col: 78} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 410, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre)) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 433, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 414, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var45 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32)) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 438, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 419, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -919,7 +903,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> <span x-show=\"!openSections.series\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <span x-show=\"!openSections.series\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -927,59 +911,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span></span></button><div x-show=\"openSections.series\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Series</label> <input type=\"text\" name=\"series\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span></span></button><div x-show=\"openSections.series\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Series</label> <input type=\"text\" name=\"series\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series)) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 456, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 437, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><div class=\"grid grid-cols-3 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Number</label> <input type=\"number\" name=\"series_number\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"input\"></div><div class=\"grid grid-cols-3 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Number</label> <input type=\"number\" name=\"series_number\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var47 string var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32)) templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 462, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 443, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var48 string var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32)) templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 468, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 449, Col: 59}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var49 string var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32)) templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 455, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -987,7 +971,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -995,85 +979,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</span></span></button><div x-show=\"openSections.identifiers\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span></span></button><div x-show=\"openSections.identifiers\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var50 string var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn)) templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var51 string var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin)) templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 497, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 478, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var52 string var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID)) templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 482, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var53 string var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID)) templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 505, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 486, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var54 string var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID)) templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 509, Col: 90} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 490, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var55 string var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl)) templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 494, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1081,7 +1065,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> <span x-show=\"!openSections.comic\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1089,186 +1073,186 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var56 string var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown") templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 531, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 512, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Unknown</option> <option value=\"no\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">Unknown</option> <option value=\"no\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var57 string var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no") templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 532, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">No</option> <option value=\"yes\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">No</option> <option value=\"yes\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var58 string var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes") templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 514, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var59 string var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left") templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 534, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 515, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var60 string var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto") templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 540, Col: 86} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 521, Col: 86}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Auto</option> <option value=\"ltr\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Auto</option> <option value=\"ltr\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var61 string var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr") templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 522, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Left to Right</option> <option value=\"rtl\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var62 string var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl") templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 542, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 523, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">Right to Left</option> <option value=\"vertical\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var63 string var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical") templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 543, Col: 94} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 524, Col: 94}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var64 string var templ_7745c5c3_Var64 string
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating)) templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 548, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 529, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var65 string var templ_7745c5c3_Var65 string
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc)) templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 552, Col: 79} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var66 string var templ_7745c5c3_Var66 string
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint)) templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 76} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 537, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var67 string var templ_7745c5c3_Var67 string
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation)) templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var68 string var templ_7745c5c3_Var68 string
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes)) templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 565, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 546, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.IsBlackAndWhite.Bool { if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " checked") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " checked")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1276,7 +1260,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span> <span x-show=\"!openSections.technical\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1284,59 +1268,88 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var69 string var templ_7745c5c3_Var69 string
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32)) templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 589, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 570, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var70 string var templ_7745c5c3_Var70 string
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors)) templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 594, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 575, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var71 string var templ_7745c5c3_Var71 string
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType)) templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 599, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 580, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var72 string var templ_7745c5c3_Var72 string
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024)) templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 604, Col: 98} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 585, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 591, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1344,7 +1357,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "Save</button></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "Save</button></div></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
File diff suppressed because it is too large Load Diff
+40
View File
@@ -249,6 +249,46 @@ function stopScanStatusPolling(): void {
} }
} }
// Purge all archived items (files missing from disk for 2+ scans). Exposed on
// window for the library admin page's inline button; reading history is
// deleted with the rows, so a confirm dialog guards it.
async function purgeArchivedItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch("/api/media-items/purge-archived", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to purge archived items");
}
const data = await resp.json();
showToast(
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
"success",
);
setTimeout(() => window.location.reload(), 700);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to purge archived items",
"error",
);
}
}
(window as any).purgeArchivedItems = purgeArchivedItems;
export { export {
hideScanProgress, hideScanProgress,
loadWatchStatus, loadWatchStatus,
+66 -37
View File
@@ -1,6 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { generateCoverBlob } from "./cover-generator";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
function getMediaId(): string { function getMediaId(): string {
@@ -91,12 +90,13 @@ export {
interface MetadataEditorState { interface MetadataEditorState {
openSections: Record<string, boolean>; openSections: Record<string, boolean>;
coverGenerating: boolean;
hasExistingCover: boolean; hasExistingCover: boolean;
newCoverPreview: string; newCoverPreview: string;
coverFile: Blob | null; coverFile: Blob | null;
coverAction: string; coverAction: string;
saving: boolean; saving: boolean;
rescanning: boolean;
resetting: boolean;
userRating: number; userRating: number;
ratingHover: number; ratingHover: number;
ratingSaving: boolean; ratingSaving: boolean;
@@ -107,9 +107,10 @@ interface MetadataEditorState {
showMetadataEditor(): void; showMetadataEditor(): void;
hideMetadataEditor(): void; hideMetadataEditor(): void;
handleCoverUpload(event: Event): void; handleCoverUpload(event: Event): void;
generateCover(): Promise<void>;
removeCover(): void; removeCover(): void;
saveMetadata(): Promise<void>; saveMetadata(): Promise<void>;
rescanBook(): Promise<void>;
resetMetadata(): Promise<void>;
starFill(i: number): string; starFill(i: number): string;
ratingText(): string; ratingText(): string;
setRating(value: number): Promise<void>; setRating(value: number): Promise<void>;
@@ -128,12 +129,6 @@ Alpine.data("bookDetail", () => {
coverImg?.src && coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg"); !coverImg.src.includes("placeholder-book.svg");
const coverPreviewEl = document.querySelector(
".aspect-\\[2\\/3\\] img",
) as HTMLImageElement | null;
const coverSrc = coverPreviewEl?.src || "";
const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : "";
const initialTags: string[] = []; const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]"); const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
tagBadges.forEach((el) => { tagBadges.forEach((el) => {
@@ -143,12 +138,13 @@ Alpine.data("bookDetail", () => {
return { return {
openSections: { basic: true } as Record<string, boolean>, openSections: { basic: true } as Record<string, boolean>,
coverGenerating: false,
hasExistingCover: !!hasCover, hasExistingCover: !!hasCover,
newCoverPreview: "", newCoverPreview: "",
coverFile: null as Blob | null, coverFile: null as Blob | null,
coverAction: "keep", coverAction: "keep",
saving: false, saving: false,
rescanning: false,
resetting: false,
userRating: 0, userRating: 0,
ratingHover: 0, ratingHover: 0,
ratingSaving: false, ratingSaving: false,
@@ -459,33 +455,6 @@ Alpine.data("bookDetail", () => {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
async generateCover() {
this.coverGenerating = true;
try {
const formatGroup =
document
.querySelector('[data-format-group]')
?.getAttribute("data-format-group") || "reflowable";
const blob = await generateCoverBlob(fileUrl, formatGroup);
if (!blob) return;
this.coverFile = blob;
this.coverAction = "upload";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) {
preview.src = URL.createObjectURL(blob);
}
this.newCoverPreview = URL.createObjectURL(blob);
showToast("Cover generated successfully", "success");
} finally {
this.coverGenerating = false;
}
},
removeCover() { removeCover() {
this.coverAction = "remove"; this.coverAction = "remove";
this.coverFile = null; this.coverFile = null;
@@ -559,6 +528,66 @@ Alpine.data("bookDetail", () => {
} }
}, },
async rescanBook() {
if (this.rescanning) return;
this.rescanning = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
method: "POST",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to rescan book");
}
showToast("Book rescanned successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to rescan book",
"error",
);
} finally {
this.rescanning = false;
}
},
async resetMetadata() {
if (this.resetting) return;
if (
!window.confirm(
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
)
) {
return;
}
this.resetting = true;
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
{
method: "POST",
headers: { Authorization: getAuthHeader() },
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to reset metadata");
}
showToast("Metadata reset to scanned defaults", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to reset metadata",
"error",
);
} finally {
this.resetting = false;
}
},
async searchEditorTags() { async searchEditorTags() {
const libraryId = document.body.getAttribute("data-library-id") || ""; const libraryId = document.body.getAttribute("data-library-id") || "";
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) { if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
-82
View File
@@ -1,82 +0,0 @@
import { showToast } from "./toast";
function getToken(): string {
return localStorage.getItem("token") || "";
}
export async function generateCoverBlob(
fileUrl: string,
formatGroup: string,
): Promise<Blob | null> {
try {
const View = (await import("foliate-js/view.js")).default;
const view = new View();
const resp = await fetch(fileUrl, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!resp.ok) {
showToast("Failed to fetch book file for cover generation", "error");
return null;
}
const blob = await resp.blob();
const file = new File([blob], "book", { type: blob.type });
const pdfOptions =
formatGroup === "fixed_layout"
? {
pdf: {
cMapUrl: "/static/vendor/pdfjs/cmaps/",
standardFontDataUrl: "/static/vendor/pdfjs/standard_fonts/",
},
}
: {};
await view.open(file, pdfOptions);
if (!view.book?.sections?.length) {
showToast("Could not read book sections", "error");
return null;
}
if (formatGroup === "fixed_layout") {
const canvas = document.createElement("canvas");
await view.renderer?.renderPage(view.book.sections[0], canvas);
return new Promise((resolve) => {
canvas.toBlob(
(b) => resolve(b),
"image/jpeg",
0.85,
);
});
}
const coverHref = view.book.cover;
if (coverHref) {
const coverBlob = await coverHref.blob();
if (coverBlob.type.startsWith("image/")) {
return coverBlob;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const c = document.createElement("canvas");
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext("2d")?.drawImage(img, 0, 0);
c.toBlob((b) => resolve(b), "image/jpeg", 0.85);
};
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(coverBlob);
});
}
showToast("No cover found in book file", "error");
return null;
} catch (e) {
console.error("Cover generation failed:", e);
showToast("Cover generation failed", "error");
return null;
}
}
+38 -26
View File
@@ -482,11 +482,14 @@ document.addEventListener("alpine:init", () => {
tocItems: [] as any[], tocItems: [] as any[],
mediaItemId: "" as string, mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null, saveTimeout: null as ReturnType<typeof setTimeout> | null,
// Set only by deliberate navigation (page turns, jumps, slider). The // Position last known to be stored (the restore at open time, or the
// restore at open time and section-load relocations never set it, so // last successful save). Relocations that don't move from it are never
// progress saves can only ever write a position the user actually // written back, so a restored position can't clobber a newer device
// moved to — never a stale restore clobbering a newer device push. // push — while swipe/scroll paging (handled inside foliate, with no
userMoved: false as boolean, // wrapper method to flag) still saves normally.
lastSyncedCfi: "" as string,
lastSyncedFraction: -1 as number,
lastCfi: "" as string,
contextText: "" as string, contextText: "" as string,
readingTheme: "light" as string, readingTheme: "light" as string,
readingMode: "light" as string, readingMode: "light" as string,
@@ -883,6 +886,7 @@ document.addEventListener("alpine:init", () => {
const { fraction, location, pageItem, cfi, tocItem, section } = const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail; e.detail;
this.hideSelectionPopover(); this.hideSelectionPopover();
this.lastCfi = cfi || "";
this.lastRelocateDetail = { this.lastRelocateDetail = {
fraction, fraction,
location, location,
@@ -944,6 +948,11 @@ document.addEventListener("alpine:init", () => {
} else { } else {
await this.view.init({}) await this.view.init({})
} }
// The position restored above (or the start of the book on a fresh
// open) is the baseline: only relocations that actually move from
// it may write progress.
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
// The renderer only knows it's a PDF once frames exist (they carry // The renderer only knows it's a PDF once frames exist (they carry
// pdf.js onZoom), i.e. after init has rendered the first spread. // pdf.js onZoom), i.e. after init has rendered the first spread.
// Read it now and apply the saved pointer mode — this also makes the // Read it now and apply the saved pointer mode — this also makes the
@@ -953,10 +962,15 @@ document.addEventListener("alpine:init", () => {
this.renderer.setAttribute("interaction-mode", this.interactionMode); this.renderer.setAttribute("interaction-mode", this.interactionMode);
} }
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
// A bfcache-resurrected page is stale by definition: forbid it from // A bfcache-resurrected page is stale by definition: re-baseline to
// writing its frozen position back until the user navigates again. // its frozen position so it can't write that back until the user
// actually navigates again.
window.addEventListener("pageshow", (e: PageTransitionEvent) => { window.addEventListener("pageshow", (e: PageTransitionEvent) => {
if (e.persisted) this.userMoved = false; if (e.persisted) {
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction =
this.lastRelocateDetail?.fraction ?? -1;
}
}); });
this.fetchReadingSpeed(); this.fetchReadingSpeed();
this.refreshAnnotations(); this.refreshAnnotations();
@@ -1557,9 +1571,9 @@ document.addEventListener("alpine:init", () => {
} }
}, },
// Fresh reading position from the database — the single source of // Fresh reading position from the database — the single source of
// truth at open time. Fails soft to a fresh start: the userMoved gate // truth at open time. Fails soft to a fresh start: change detection
// guarantees merely opening (even at the wrong spot) can never // against the restored baseline guarantees merely opening (even at
// overwrite the stored position. // the wrong spot) can never overwrite the stored position.
async fetchSavedLocation(): Promise<{ async fetchSavedLocation(): Promise<{
cfi?: string; cfi?: string;
page?: number; page?: number;
@@ -1591,9 +1605,17 @@ document.addEventListener("alpine:init", () => {
} }
}, },
debouncedSaveProgress(fraction: number, location: any, cfi: string) { debouncedSaveProgress(fraction: number, location: any, cfi: string) {
// Only deliberate navigation writes progress: displaying a restored // Only an actual change from the last stored position writes
// position must never overwrite a newer device push. // progress: displaying a restored position must never overwrite a
if (!this.userMoved) return; // newer device push. Swipes and scrolls are handled inside foliate
// with no wrapper method to flag, so position — not intent — is the
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
const currentCfi = cfi || "";
const changed =
currentCfi || this.lastSyncedCfi
? currentCfi !== this.lastSyncedCfi
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
if (!changed) return;
if (this.saveTimeout) clearTimeout(this.saveTimeout); if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => { this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi); this.saveProgress(fraction, location, cfi);
@@ -1644,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
this.lastSyncedCfi = cfi || "";
this.lastSyncedFraction = fraction;
} catch (_e) { } catch (_e) {
// silent fail — progress save is non-critical // silent fail — progress save is non-critical
} }
@@ -1734,23 +1758,18 @@ document.addEventListener("alpine:init", () => {
saveSettings({ double_page_spread: this.doublePageSpread }); saveSettings({ double_page_spread: this.doublePageSpread });
}, },
goLeft() { goLeft() {
this.userMoved = true;
this.view?.goLeft?.(); this.view?.goLeft?.();
}, },
goRight() { goRight() {
this.userMoved = true;
this.view?.goRight?.(); this.view?.goRight?.();
}, },
nextPage() { nextPage() {
this.userMoved = true;
this.view?.next?.(); this.view?.next?.();
}, },
previousPage() { previousPage() {
this.userMoved = true;
this.view?.prev?.(); this.view?.prev?.();
}, },
goToFraction(value: string) { goToFraction(value: string) {
this.userMoved = true;
this.view?.goToFraction?.(parseFloat(value)); this.view?.goToFraction?.(parseFloat(value));
}, },
toggleTOC() { toggleTOC() {
@@ -1929,11 +1948,9 @@ document.addEventListener("alpine:init", () => {
}, },
goToSearchResult(item: { cfi?: string; page?: number | null }) { goToSearchResult(item: { cfi?: string; page?: number | null }) {
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.cfi); this.view?.goTo?.(item.cfi);
} else if (item.page != null) { } else if (item.page != null) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.page); this.view?.goTo?.(item.page);
} else return; } else return;
@@ -1962,7 +1979,6 @@ document.addEventListener("alpine:init", () => {
goBackToLocation() { goBackToLocation() {
const loc = this.backStack.pop(); const loc = this.backStack.pop();
if (!loc) return; if (!loc) return;
this.userMoved = true;
if (loc.cfi) this.view?.goTo?.(loc.cfi); if (loc.cfi) this.view?.goTo?.(loc.cfi);
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page); else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
}, },
@@ -1978,7 +1994,6 @@ document.addEventListener("alpine:init", () => {
}, },
goToTOCItem(item: any) { goToTOCItem(item: any) {
if (this.view && item.href) { if (this.view && item.href) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.href); this.view.goTo(item.href);
this.tocOpen = false; this.tocOpen = false;
@@ -2106,7 +2121,6 @@ document.addEventListener("alpine:init", () => {
}, },
goToPage(index: number) { goToPage(index: number) {
if (!this.view || typeof index !== "number" || index < 0) return; if (!this.view || typeof index !== "number" || index < 0) return;
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(index); this.view.goTo(index);
this.tocOpen = false; this.tocOpen = false;
@@ -2114,11 +2128,9 @@ document.addEventListener("alpine:init", () => {
goToBookmark(item: { cfi: string; page: number | null }) { goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return; if (!this.view) return;
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.cfi); this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) { } else if (item.page != null && item.page > 0) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
// Fixed-layout/comic: sections are pages; foliate takes an index. // Fixed-layout/comic: sections are pages; foliate takes an index.
this.view.goTo(item.page - 1); this.view.goTo(item.page - 1);