Compare commits

..
12 Commits
Author SHA1 Message Date
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
28 changed files with 2697 additions and 1206 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
+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
+13 -10
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
@@ -305,15 +305,18 @@ type MediaItems struct {
// Scan information from ComicInfo.xml (scanner group, resolution, etc.) // Scan information from ComicInfo.xml (scanner group, resolution, etc.)
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"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
TagsSearch []string `db:"tags_search" json:"tags_search"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` TagsSearch []string `db:"tags_search" json:"tags_search"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` 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"`
} }
type MediaNotes struct { type MediaNotes struct {
+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)
}
}
}
+111 -55
View File
@@ -63,41 +63,41 @@ type CreateMediaItemRequest struct {
// UpdateMediaItemRequest represents the request for updating a media item // UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct { type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"` Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"` Author string `form:"author" json:"author"`
ISBN string `form:"isbn" json:"isbn"` ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"` Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"` CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"` CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"` Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"` SeriesNumber int32 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"` Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"` ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"` DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"` Publisher string `form:"publisher" json:"publisher"`
Contributors []string `form:"contributors" json:"contributors"` Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"` Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"` Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"` PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"` Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"` CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"` GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"` OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"` GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"` MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"` ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"` SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"` Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"` Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"` AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"` WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"` MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"` CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"` StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"` IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"` AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"` ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"` Summary string `form:"summary" json:"summary"`
} }
// CreateMediaNoteRequest represents the request for creating a media note // CreateMediaNoteRequest represents the request for creating a media note
@@ -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 {
@@ -925,22 +934,22 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
} }
resp := map[string]interface{}{ resp := map[string]interface{}{
"id": progress.ID, "id": progress.ID,
"media_item_id": progress.MediaItemID, "media_item_id": progress.MediaItemID,
"user_id": progress.UserID, "user_id": progress.UserID,
"current_page": progress.CurrentPage, "current_page": progress.CurrentPage,
"total_pages": progress.TotalPages, "total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt, "last_read_at": progress.LastReadAt,
"percentage": progress.Percentage, "percentage": progress.Percentage,
"character_offset": progress.CharacterOffset, "character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi, "epubcfi": progress.Epubcfi,
"chapter": progress.Chapter, "chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress, "chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup, "format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters, "total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount, "chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice, "last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource, "last_sync_source": progress.LastSyncSource,
"last_sync_timestamp": progress.LastSyncTimestamp, "last_sync_timestamp": progress.LastSyncTimestamp,
} }
@@ -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,27 @@ 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) // RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error { func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c) user := MustGetAuthenticatedUser(c)
@@ -1333,7 +1375,11 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
scanner := services.NewMediaScanner(mh.db) scanner := services.NewMediaScanner(mh.db)
defer scanner.Close() defer scanner.Close()
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil { // 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()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -2224,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"`
+30 -9
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
} }
@@ -984,12 +987,12 @@ func registerFrontendRoutes(cfg *Config) {
} }
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{ conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(), ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title, Title: mi.Title,
Author: mi.Author.String, Author: mi.Author.String,
FilePath: mi.FilePath, FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64, FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections", UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount), counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0, HasReadingData: totalData > 0,
}) })
@@ -1090,9 +1093,9 @@ func registerFrontendRoutes(cfg *Config) {
// already have their own dedicated UI cards (timezone dropdown, scan // already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice. // settings) so they aren't listed twice.
dedicatedUI := map[string]bool{ dedicatedUI := map[string]bool{
"default_timezone": true, "default_timezone": true,
"scan_poll_interval_seconds": true, "scan_poll_interval_seconds": true,
"auto_scan_enabled": true, "auto_scan_enabled": true,
} }
var tunableSettings []templates.SettingEntry var tunableSettings []templates.SettingEntry
if cfg.Settings != nil { if cfg.Settings != nil {
@@ -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,
+1
View File
@@ -62,6 +62,7 @@ func registerMediaRoutes(cfg *Config) {
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.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)
+321 -177
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"
@@ -105,22 +106,23 @@ type FormatInfo struct {
// MediaScanner scans library folders for media files (ebooks, comics, manga) // MediaScanner scans library folders for media files (ebooks, comics, manga)
type MediaScanner struct { type MediaScanner struct {
db *database.Queries db *database.Queries
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
folders []string folders []string
adminID pgtype.UUID adminID pgtype.UUID
defaultLibraryID pgtype.UUID defaultLibraryID pgtype.UUID
libraryTypes map[string][]string libraryTypes map[string][]string
forceRescan bool forceRescan bool
logger *ScannerLogger archiveRetentionDays int
dirtyDirs map[string]time.Time logger *ScannerLogger
dirtyDirsMu sync.RWMutex dirtyDirs map[string]time.Time
fileStability map[string]*atomic.Bool dirtyDirsMu sync.RWMutex
fileStabilityMu sync.RWMutex fileStability map[string]*atomic.Bool
scanMutex sync.Mutex fileStabilityMu sync.RWMutex
scanInProgress atomic.Bool scanMutex sync.Mutex
watching atomic.Bool scanInProgress atomic.Bool
settingsCache *SettingsCache watching atomic.Bool
settingsCache *SettingsCache
totalFiles int totalFiles int
newItems int newItems int
@@ -157,18 +159,19 @@ type CalibreOPFMetadata struct {
// never closed. // never closed.
func NewMediaScanner(db *database.Queries) *MediaScanner { func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{ return &MediaScanner{
db: db, db: db,
watcher: nil, watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second), archiveRetentionDays: config.ArchiveRetentionDays(),
dirtyDirs: make(map[string]time.Time), settingsCache: NewSettingsCache(30 * time.Second),
fileStability: make(map[string]*atomic.Bool), dirtyDirs: make(map[string]time.Time),
watching: atomic.Bool{}, fileStability: make(map[string]*atomic.Bool),
scanInProgress: atomic.Bool{}, watching: atomic.Bool{},
folders: []string{}, scanInProgress: atomic.Bool{},
adminID: pgtype.UUID{}, folders: []string{},
defaultLibraryID: pgtype.UUID{Valid: false}, adminID: pgtype.UUID{},
libraryTypes: make(map[string][]string), defaultLibraryID: pgtype.UUID{Valid: false},
logger: NewScannerLogger(), libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
} }
} }
@@ -476,17 +479,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
} }
@@ -501,28 +510,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)", if item.ArchivedAt.Valid {
item.Title, filePath) // Still missing and already archived: purge once past the
s.logger.LogDelete(delMsg) // 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 {
fmt.Printf("[ARCHIVE] Error: failed to purge archived item %s: %v\n", item.Title, err)
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to purge archived item '%s': %v", item.Title, err))
} else {
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
}
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil { // Missing but not yet archived.
errMsg := fmt.Sprintf("[RESCAN-CLEANUP] ERROR: failed to delete orphaned item %s: %v", item.Title, err) if item.MissingScanCount >= 1 {
s.logger.LogDelete(errMsg) // Second consecutive missing scan: archive it.
s.logger.LogError(errMsg) 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 { } else {
s.logger.LogDelete(fmt.Sprintf("[RESCAN-CLEANUP] SUCCESS: deleted orphaned item '%s'", item.Title)) 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)
} }
} }
} }
@@ -693,11 +720,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
@@ -708,7 +746,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
@@ -757,8 +795,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) {
@@ -958,6 +1005,88 @@ 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"`
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),
}
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
@@ -1265,6 +1394,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 {
@@ -1279,7 +1410,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 {
@@ -1347,89 +1492,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() {
metadata := &MediaMetadata{} if err := r.Close(); err != nil {
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", path, err)
// Title
if title, err := book.Title(); err == nil && title != "" {
metadata.Title = title
}
// Author
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 {
metadata.Author = authors[0]
}
// 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)
} }
}()
opfPath := findOPFPathInZip(r.File)
if opfPath == "" {
return nil, fmt.Errorf("no OPF document found in EPUB %s", path)
}
opfContent, err := readFileFromZip(r.File, opfPath)
if err != nil {
return nil, fmt.Errorf("failed to read OPF %s from EPUB: %v", opfPath, err)
} }
// Publish date metadata, err := parseOPFContent(opfContent)
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 { if err != nil {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil { return nil, fmt.Errorf("failed to parse OPF in EPUB %s: %v", path, err)
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
} }
@@ -1604,7 +1698,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)
@@ -1616,6 +1710,18 @@ 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)
} }
}() }()
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read metadata.opf: %v", err)
}
return parseOPFContent(content)
}
// 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. Namespace-aware
// parsing means it tolerates wherever the xmlns:dc declaration lives.
func parseOPFContent(content []byte) (*MediaMetadata, error) {
// Define XML structure for parsing with full Dublin Core namespace URLs // Define XML structure for parsing with full Dublin Core namespace URLs
var opf struct { var opf struct {
XMLName xml.Name `xml:"package"` XMLName xml.Name `xml:"package"`
@@ -1641,8 +1747,8 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
} `xml:"metadata"` } `xml:"metadata"`
} }
// Parse XML // Parse XML
if err := xml.NewDecoder(file).Decode(&opf); err != nil { if err := xml.NewDecoder(bytes.NewReader(content)).Decode(&opf); err != nil {
return nil, fmt.Errorf("failed to parse metadata.opf XML: %v", err) return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
} }
// Map to MediaMetadata struct // Map to MediaMetadata struct
metadata := &MediaMetadata{} metadata := &MediaMetadata{}
@@ -1666,6 +1772,10 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
if len(opf.Metadata.Publisher) > 0 { if len(opf.Metadata.Publisher) > 0 {
metadata.Publisher = opf.Metadata.Publisher[0] metadata.Publisher = opf.Metadata.Publisher[0]
} }
// Language
if len(opf.Metadata.Language) > 0 && opf.Metadata.Language[0] != "" {
metadata.Language = opf.Metadata.Language[0]
}
// Publish date // Publish date
if len(opf.Metadata.Dates) > 0 { if len(opf.Metadata.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", opf.Metadata.Dates[0]); err == nil {
@@ -1681,14 +1791,23 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
} }
// Identifiers (ISBN, ASIN) // Identifiers (ISBN, ASIN)
for _, id := range opf.Metadata.Identifiers { for _, id := range opf.Metadata.Identifiers {
value := strings.TrimSpace(id.Value)
switch strings.ToUpper(id.Scheme) { switch strings.ToUpper(id.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 that normalizes to a valid ISBN.
if metadata.ISBN == "" && id.Scheme == "" {
if normalized := utils.NormalizeISBNSafe(value); normalized != "" {
metadata.ISBN = normalized
}
}
} }
} }
// Contributors // Contributors
@@ -1715,6 +1834,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
@@ -1736,43 +1894,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
@@ -2147,7 +2269,11 @@ func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
}() }()
outPrefix := filepath.Join(tmpDir, "cover") outPrefix := filepath.Join(tmpDir, "cover")
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-r", "150", pdfPath, outPrefix) // -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 { 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)) fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
return "" return ""
@@ -2601,7 +2727,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 {
@@ -2620,8 +2750,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 != ""},
@@ -2653,19 +2783,34 @@ 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.
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = existing.MetadataOverrides
_, err = s.db.UpdateMediaItem(ctx, params)
return err return err
} }
// RescanMediaItem re-extracts metadata for a single media item and updates it. // 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 // It is the per-book rescan used by the Edit Metadata dialog and backfills
// covers for items imported before the PDF render fallback existed. // covers for items imported before the PDF render fallback existed.
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID) error { // 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) item, err := s.db.GetMediaItem(ctx, mediaItemID)
if err != nil { if err != nil {
return fmt.Errorf("media item not found: %w", err) 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)
}
item.MetadataOverrides = nil
}
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID) folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
if err != nil || len(folders) == 0 { if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library") return fmt.Errorf("no library folders found for library")
@@ -2689,12 +2834,11 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
return fmt.Errorf("media file not found on disk: %s", item.FilePath) return fmt.Errorf("media file not found on disk: %s", item.FilePath)
} }
info, err := os.Stat(fullPath) if _, err := os.Stat(fullPath); err != nil {
if err != nil {
return fmt.Errorf("failed to stat media file: %w", err) return fmt.Errorf("failed to stat media file: %w", err)
} }
if err := s.updateMediaItem(ctx, mediaItemID, fullPath, info); err != nil { if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err) return fmt.Errorf("failed to update media item: %w", err)
} }
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath) s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
@@ -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)
}
})
}
+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
+17 -7
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">
@@ -370,12 +372,18 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p style="color: var(--text-primary)">{ book.MimeType.String }</p> <p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div> </div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 { if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<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)
@MetadataEditorModal(book) if user.Role == "admin" {
@MetadataEditorModal(book)
}
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
+41 -41
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"
@@ -593,34 +574,53 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label> <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={ stringSliceToString(book.Contributors) } class="input" /> <input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly <input type="text" value={ textToString(book.MimeType) } readonly
class="input opacity-60" /> 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={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" />
</div>
</div> </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={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } 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={ book.FileLocation } readonly
class="input opacity-60 font-mono text-xs" />
</div>
</div> </div>
</div> </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 justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<button <div class="flex items-center gap-2">
@click="rescanBook()" <button
:disabled="rescanning" @click="rescanBook()"
class="btn btn-secondary" :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"> <span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
</svg> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</button> </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"> <div class="flex space-x-3">
<button <button
@click="hideMetadataEditor()" @click="hideMetadataEditor()"
+118 -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,72 @@ 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 items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">") 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1344,7 +1341,15 @@ 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, "<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><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">") 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
} }
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,
+38 -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,13 +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; rescanning: boolean;
resetting: boolean;
userRating: number; userRating: number;
ratingHover: number; ratingHover: number;
ratingSaving: boolean; ratingSaving: boolean;
@@ -108,10 +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>; 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>;
@@ -130,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) => {
@@ -145,13 +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, rescanning: false,
resetting: false,
userRating: 0, userRating: 0,
ratingHover: 0, ratingHover: 0,
ratingSaving: false, ratingSaving: false,
@@ -462,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;
@@ -587,6 +553,41 @@ Alpine.data("bookDetail", () => {
} }
}, },
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;
}
}