collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
21214 lines
743 KiB
Go
21214 lines
743 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.0
|
|
// source: queries.sql
|
|
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const AddBookToCollection = `-- name: AddBookToCollection :one
|
|
|
|
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
|
RETURNING id, collection_id, media_item_id, added_at, added_by_user_id, excluded, position
|
|
`
|
|
|
|
type AddBookToCollectionParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
|
}
|
|
|
|
// COLLECTION ITEMS QUERIES
|
|
// Add book to collection
|
|
func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error) {
|
|
row := q.db.QueryRow(ctx, AddBookToCollection, arg.CollectionID, arg.MediaItemID, arg.AddedByUserID)
|
|
var i CollectionItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.MediaItemID,
|
|
&i.AddedAt,
|
|
&i.AddedByUserID,
|
|
&i.Excluded,
|
|
&i.Position,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const AddBookToCollectionOrdered = `-- name: AddBookToCollectionOrdered :one
|
|
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
|
RETURNING id, collection_id, media_item_id, added_at, added_by_user_id, excluded, position
|
|
`
|
|
|
|
type AddBookToCollectionOrderedParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
|
Position pgtype.Int4 `db:"position" json:"position"`
|
|
}
|
|
|
|
// A2c: playlist upsert — position set ONLY on insert, so rescans and
|
|
// re-adds never clobber the user's manual drag ordering.
|
|
func (q *Queries) AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error) {
|
|
row := q.db.QueryRow(ctx, AddBookToCollectionOrdered,
|
|
arg.CollectionID,
|
|
arg.MediaItemID,
|
|
arg.AddedByUserID,
|
|
arg.Position,
|
|
)
|
|
var i CollectionItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.MediaItemID,
|
|
&i.AddedAt,
|
|
&i.AddedByUserID,
|
|
&i.Excluded,
|
|
&i.Position,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const AddBookToKoboShelf = `-- name: AddBookToKoboShelf :one
|
|
|
|
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (device_id, media_item_id)
|
|
DO UPDATE SET
|
|
shelf_name = EXCLUDED.shelf_name,
|
|
shelf_position = EXCLUDED.shelf_position,
|
|
last_synced_at = NOW()
|
|
RETURNING id, device_id, media_item_id, shelf_name, shelf_position, added_at, last_synced_at, collection_id, position_in_collection
|
|
`
|
|
|
|
type AddBookToKoboShelfParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"`
|
|
}
|
|
|
|
// ============================================
|
|
// KOBO SHELF MANAGEMENT QUERIES
|
|
// ============================================
|
|
func (q *Queries) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error) {
|
|
row := q.db.QueryRow(ctx, AddBookToKoboShelf,
|
|
arg.DeviceID,
|
|
arg.MediaItemID,
|
|
arg.ShelfName,
|
|
arg.ShelfPosition,
|
|
)
|
|
var i KoboShelves
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.ShelfName,
|
|
&i.ShelfPosition,
|
|
&i.AddedAt,
|
|
&i.LastSyncedAt,
|
|
&i.CollectionID,
|
|
&i.PositionInCollection,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const AddLibraryFolder = `-- name: AddLibraryFolder :one
|
|
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING id, library_id, folder_path, created_at, purpose
|
|
`
|
|
|
|
type AddLibraryFolderParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
|
Purpose string `db:"purpose" json:"purpose"`
|
|
}
|
|
|
|
// Library Folders queries
|
|
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
|
// scanner-skipped, written by the podcast download worker instead).
|
|
func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) {
|
|
row := q.db.QueryRow(ctx, AddLibraryFolder, arg.LibraryID, arg.FolderPath, arg.Purpose)
|
|
var i LibraryFolders
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.CreatedAt,
|
|
&i.Purpose,
|
|
)
|
|
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
|
|
UPDATE media_items m
|
|
SET
|
|
format_group = detect_format_group(m.mime_type, m.file_path),
|
|
format_mimetype = m.mime_type,
|
|
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
|
|
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
|
|
updated_at = NOW()
|
|
WHERE m.format_group IS NULL OR m.format_group = 'unknown'
|
|
`
|
|
|
|
// Bulk update format group for all media items
|
|
func (q *Queries) BulkUpdateFormatGroups(ctx context.Context) error {
|
|
_, err := q.db.Exec(ctx, BulkUpdateFormatGroups)
|
|
return err
|
|
}
|
|
|
|
const BulkUpdateProgressFromSync = `-- name: BulkUpdateProgressFromSync :many
|
|
SELECT bulk_update_progress_from_koreader FROM bulk_update_progress_from_koreader($1::uuid, $2::jsonb)
|
|
`
|
|
|
|
type BulkUpdateProgressFromSyncParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []byte `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
func (q *Queries) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) {
|
|
rows, err := q.db.Query(ctx, BulkUpdateProgressFromSync, arg.Column1, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []interface{}{}
|
|
for rows.Next() {
|
|
var bulk_update_progress_from_koreader interface{}
|
|
if err := rows.Scan(&bulk_update_progress_from_koreader); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, bulk_update_progress_from_koreader)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const CheckForProgressConflicts = `-- name: CheckForProgressConflicts :one
|
|
SELECT COUNT(*) as conflict_count
|
|
FROM reading_progress
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND last_sync_timestamp > NOW() - INTERVAL '5 minutes'
|
|
AND last_sync_source != $3
|
|
`
|
|
|
|
type CheckForProgressConflictsParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
|
}
|
|
|
|
func (q *Queries) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CheckForProgressConflicts, arg.MediaItemID, arg.UserID, arg.LastSyncSource)
|
|
var conflict_count int64
|
|
err := row.Scan(&conflict_count)
|
|
return conflict_count, err
|
|
}
|
|
|
|
const ClaimQueuedPodcastEpisode = `-- name: ClaimQueuedPodcastEpisode :one
|
|
UPDATE media_items
|
|
SET download_state = 'downloading', updated_at = NOW()
|
|
WHERE id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state = 'queued'
|
|
RETURNING media_items.id, media_items.library_id, media_items.title, media_items.subtitle, media_items.author, media_items.isbn, media_items.description, media_items.file_path, media_items.file_size, media_items.mime_type, media_items.cover_image_path, media_items.series, media_items.series_number, media_items.tags, media_items.narrators, media_items.authors, media_items.authors_search, media_items.publishers, media_items.publishers_search, media_items.abridged, media_items.explicit, media_items.asin, media_items.date_published, media_items.publisher, media_items.contributors, media_items.language, media_items.edition, media_items.page_count, media_items.genre, media_items.copyright_year, media_items.goodreads_id, media_items.openlibrary_id, media_items.google_books_id, media_items.added_by_admin_id, media_items.created_at, media_items.imported_at, media_items.updated_at, media_items.format_group, media_items.format_mimetype, media_items.is_reflowable, media_items.has_fixed_layout, media_items.total_characters, media_items.chapter_count, media_items.entitlement_id, media_items.revision_number, media_items.kobo_content_id, media_items.kobo_metadata, media_items.manga_type, media_items.reading_direction, media_items.series_count, media_items.volume, media_items.imprint, media_items.age_rating, media_items.web_url, media_items.story_arc, media_items.is_black_and_white, media_items.metadata_notes, media_items.community_rating, media_items.alternate_info, media_items.scan_information, media_items.summary, media_items.metadata_overrides, media_items.missing_scan_count, media_items.archived_at, media_items.chapter_metadata, media_items.library_type_name, media_items.audio_metadata, media_items.tags_search, media_items.contributors_search, media_items.narrators_search, media_items.file_sha256, media_items.opf_identifier, media_items.opf_uuid, media_items.hash_confidence, media_items.source, media_items.source_feed_id, media_items.episode_guid, media_items.feed_position, media_items.season_number, media_items.episode_number, media_items.enclosure_url, media_items.declared_duration_seconds, media_items.transcript_url, media_items.chapters_url, media_items.hosts, media_items.guests, media_items.download_state, media_items.download_error, media_items.download_attempts, media_items.download_queue_pos
|
|
`
|
|
|
|
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
|
|
// statement. Two claimants racing on the same row: exactly one UPDATE
|
|
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
|
|
// This is the SKIP-LOCKED guarantee without a second round-trip.
|
|
func (q *Queries) ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, ClaimQueuedPodcastEpisode, id)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CleanupExpiredOpdsTokens = `-- name: CleanupExpiredOpdsTokens :exec
|
|
DELETE FROM opds_tokens WHERE expires_at < NOW()
|
|
`
|
|
|
|
// Cleanup expired OPDS tokens
|
|
func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
|
|
_, err := q.db.Exec(ctx, CleanupExpiredOpdsTokens)
|
|
return err
|
|
}
|
|
|
|
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
|
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision))
|
|
`
|
|
|
|
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error {
|
|
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1)
|
|
return err
|
|
}
|
|
|
|
const ClearCollectionItems = `-- name: ClearCollectionItems :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1
|
|
`
|
|
|
|
func (q *Queries) ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, ClearCollectionItems, collectionID)
|
|
return err
|
|
}
|
|
|
|
const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec
|
|
DELETE FROM sync_queue WHERE device_id = $1
|
|
`
|
|
|
|
func (q *Queries) ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, ClearDeviceSyncQueue, deviceID)
|
|
return err
|
|
}
|
|
|
|
const ClearKoboShelf = `-- name: ClearKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1
|
|
`
|
|
|
|
func (q *Queries) ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, ClearKoboShelf, deviceID)
|
|
return err
|
|
}
|
|
|
|
const ClearKoboShelfByName = `-- name: ClearKoboShelfByName :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND shelf_name = $2
|
|
`
|
|
|
|
type ClearKoboShelfByNameParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
}
|
|
|
|
func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error {
|
|
_, err := q.db.Exec(ctx, ClearKoboShelfByName, arg.DeviceID, arg.ShelfName)
|
|
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
|
|
SELECT COUNT(*) FROM users WHERE role = 'admin'
|
|
`
|
|
|
|
func (q *Queries) CountAdmins(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountAdmins)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
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 CountCollectionItems = `-- name: CountCollectionItems :one
|
|
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1
|
|
`
|
|
|
|
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
|
// dashboard renders a completion marker when hide_finished filtered
|
|
// everything out of a non-empty collection.
|
|
func (q *Queries) CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountCollectionItems, collectionID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one
|
|
SELECT COUNT(*)::int FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND NOT COALESCE(ci.excluded, false)
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
`
|
|
|
|
type CountCollectionLiveItemsParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
// Live item count for a STATIC collection (rule-free cards on the
|
|
// collections page); same live filter as GetCollectionItems, plus the
|
|
// future-hybrid excluded exclusion and an optional library scope.
|
|
func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) {
|
|
row := q.db.QueryRow(ctx, CountCollectionLiveItems, arg.CollectionID, arg.LibraryID)
|
|
var column_1 int32
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const CountDownloadedEpisodes = `-- name: CountDownloadedEpisodes :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source_feed_id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state = 'downloaded'
|
|
`
|
|
|
|
func (q *Queries) CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountDownloadedEpisodes, sourceFeedID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountOtherMediaItemsInDir = `-- name: CountOtherMediaItemsInDir :one
|
|
SELECT COUNT(*)::int
|
|
FROM media_items
|
|
WHERE library_id = $1::uuid
|
|
AND id != $2::uuid
|
|
AND archived_at IS NULL
|
|
AND file_path LIKE ($3::text || '%')
|
|
`
|
|
|
|
type CountOtherMediaItemsInDirParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DirPrefix pgtype.Text `db:"dir_prefix" json:"dir_prefix"`
|
|
}
|
|
|
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
|
// metadata.opf may only be written when NO other non-archived item
|
|
// shares the directory; otherwise the sidecar goes to
|
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
|
func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) {
|
|
row := q.db.QueryRow(ctx, CountOtherMediaItemsInDir, arg.LibraryID, arg.ID, arg.DirPrefix)
|
|
var column_1 int32
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const CountPodcastEpisodesForFeed = `-- name: CountPodcastEpisodesForFeed :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
|
`
|
|
|
|
func (q *Queries) CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountPodcastEpisodesForFeed, sourceFeedID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountPodcastFeedSubscribers = `-- name: CountPodcastFeedSubscribers :one
|
|
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1
|
|
`
|
|
|
|
func (q *Queries) CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountPodcastFeedSubscribers, feedID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountPodcastLibrariesForUser = `-- name: CountPodcastLibrariesForUser :one
|
|
SELECT COUNT(*)
|
|
FROM libraries l
|
|
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
|
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
`
|
|
|
|
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
|
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
|
func (q *Queries) CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountPodcastLibrariesForUser, userID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountQueuedEpisodes = `-- name: CountQueuedEpisodes :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading')
|
|
`
|
|
|
|
func (q *Queries) CountQueuedEpisodes(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountQueuedEpisodes)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountUndownloadedEpisodes = `-- name: CountUndownloadedEpisodes :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source_feed_id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state <> 'downloaded'
|
|
`
|
|
|
|
func (q *Queries) CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountUndownloadedEpisodes, sourceFeedID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
|
SELECT COUNT(*) as count
|
|
FROM unlinked_books
|
|
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
|
|
`
|
|
|
|
// Count unlinked books for a device (ignored rows stay hidden)
|
|
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBooksForUser :one
|
|
SELECT COUNT(*) as count
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
|
`
|
|
|
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
|
// devices. Ignored rows must not nag from the dashboard badge.
|
|
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CountUserDevices = `-- name: CountUserDevices :one
|
|
SELECT COUNT(*) FROM devices WHERE user_id = $1
|
|
`
|
|
|
|
func (q *Queries) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, CountUserDevices, userID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const CreateAutoResolvedSyncConflict = `-- name: CreateAutoResolvedSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
|
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
|
RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at
|
|
`
|
|
|
|
type CreateAutoResolvedSyncConflictParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
|
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
|
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
|
}
|
|
|
|
func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) {
|
|
row := q.db.QueryRow(ctx, CreateAutoResolvedSyncConflict,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.ConflictType,
|
|
arg.ConflictData,
|
|
arg.ResolutionData,
|
|
)
|
|
var i SyncConflicts
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateCollection = `-- name: CreateCollection :one
|
|
|
|
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
|
`
|
|
|
|
type CreateCollectionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
|
}
|
|
|
|
// COLLECTIONS QUERIES
|
|
// Create collection
|
|
func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, CreateCollection,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Color,
|
|
arg.Icon,
|
|
arg.AutoAssignRules,
|
|
arg.ViewSettings,
|
|
arg.ShowOnDashboard,
|
|
arg.FilterTree,
|
|
)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateDevice = `-- name: CreateDevice :one
|
|
|
|
INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
type CreateDeviceParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
DeviceIdentifier string `db:"device_identifier" json:"device_identifier"`
|
|
AuthToken string `db:"auth_token" json:"auth_token"`
|
|
SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"`
|
|
AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"`
|
|
SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
}
|
|
|
|
// ============================================
|
|
// DEVICE MANAGEMENT & AUTH
|
|
// ============================================
|
|
// Device Registration & Management
|
|
func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, CreateDevice,
|
|
arg.UserID,
|
|
arg.DeviceName,
|
|
arg.DeviceType,
|
|
arg.DeviceIdentifier,
|
|
arg.AuthToken,
|
|
arg.SyncEnabled,
|
|
arg.AutoSync,
|
|
arg.SyncFrequencyMinutes,
|
|
arg.DeviceMetadata,
|
|
)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateDeviceCatalog = `-- name: CreateDeviceCatalog :one
|
|
|
|
INSERT INTO device_catalogs (device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (device_id, kobo_content_id)
|
|
DO UPDATE SET
|
|
available = EXCLUDED.available,
|
|
delivery_date = COALESCE(EXCLUDED.delivery_date, device_catalogs.delivery_date),
|
|
delivery_method = EXCLUDED.delivery_method
|
|
RETURNING id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method
|
|
`
|
|
|
|
type CreateDeviceCatalogParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
|
|
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
|
|
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
|
|
Available pgtype.Bool `db:"available" json:"available"`
|
|
DeliveryDate pgtype.Timestamptz `db:"delivery_date" json:"delivery_date"`
|
|
DeliveryMethod pgtype.Text `db:"delivery_method" json:"delivery_method"`
|
|
}
|
|
|
|
// DEVICE CATALOGS QUERIES
|
|
// Create device catalog entry
|
|
func (q *Queries) CreateDeviceCatalog(ctx context.Context, arg CreateDeviceCatalogParams) (DeviceCatalogs, error) {
|
|
row := q.db.QueryRow(ctx, CreateDeviceCatalog,
|
|
arg.DeviceID,
|
|
arg.MediaItemID,
|
|
arg.BookhoardUuid,
|
|
arg.KoboContentID,
|
|
arg.ContentIDType,
|
|
arg.Available,
|
|
arg.DeliveryDate,
|
|
arg.DeliveryMethod,
|
|
)
|
|
var i DeviceCatalogs
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.BookhoardUuid,
|
|
&i.KoboContentID,
|
|
&i.ContentIDType,
|
|
&i.Available,
|
|
&i.DeliveryDate,
|
|
&i.DeliveryMethod,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateDeviceFileAlias = `-- name: CreateDeviceFileAlias :one
|
|
|
|
INSERT INTO device_file_aliases (media_item_id, device_id, file_path, file_sha256, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at
|
|
`
|
|
|
|
type CreateDeviceFileAliasParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// DEVICE FILE ALIASES QUERIES
|
|
// Create device file alias
|
|
func (q *Queries) CreateDeviceFileAlias(ctx context.Context, arg CreateDeviceFileAliasParams) (DeviceFileAliases, error) {
|
|
row := q.db.QueryRow(ctx, CreateDeviceFileAlias,
|
|
arg.MediaItemID,
|
|
arg.DeviceID,
|
|
arg.FilePath,
|
|
arg.FileSha256,
|
|
arg.ConfidenceScore,
|
|
)
|
|
var i DeviceFileAliases
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.ConfidenceScore,
|
|
&i.LastSeenAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateDeviceShelfMapping = `-- name: CreateDeviceShelfMapping :one
|
|
|
|
INSERT INTO device_shelf_mappings (collection_id, device_id, device_shelf_name, sync_direction)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (collection_id, device_id)
|
|
DO UPDATE SET
|
|
device_shelf_name = EXCLUDED.device_shelf_name,
|
|
sync_direction = EXCLUDED.sync_direction
|
|
RETURNING id, collection_id, device_id, device_shelf_name, sync_direction, created_at
|
|
`
|
|
|
|
type CreateDeviceShelfMappingParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"`
|
|
SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"`
|
|
}
|
|
|
|
// DEVICE SHELF MAPPINGS QUERIES
|
|
// Create device shelf mapping
|
|
func (q *Queries) CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error) {
|
|
row := q.db.QueryRow(ctx, CreateDeviceShelfMapping,
|
|
arg.CollectionID,
|
|
arg.DeviceID,
|
|
arg.DeviceShelfName,
|
|
arg.SyncDirection,
|
|
)
|
|
var i DeviceShelfMappings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.DeviceID,
|
|
&i.DeviceShelfName,
|
|
&i.SyncDirection,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateDictionaryEntry = `-- name: CreateDictionaryEntry :one
|
|
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
RETURNING id, word, definition, part_of_speech, example, etymology, created_at, accessed_at
|
|
`
|
|
|
|
type CreateDictionaryEntryParams struct {
|
|
Word string `db:"word" json:"word"`
|
|
Definition string `db:"definition" json:"definition"`
|
|
PartOfSpeech pgtype.Text `db:"part_of_speech" json:"part_of_speech"`
|
|
Example pgtype.Text `db:"example" json:"example"`
|
|
Etymology pgtype.Text `db:"etymology" json:"etymology"`
|
|
}
|
|
|
|
func (q *Queries) CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error) {
|
|
row := q.db.QueryRow(ctx, CreateDictionaryEntry,
|
|
arg.Word,
|
|
arg.Definition,
|
|
arg.PartOfSpeech,
|
|
arg.Example,
|
|
arg.Etymology,
|
|
)
|
|
var i DictionaryCache
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Word,
|
|
&i.Definition,
|
|
&i.PartOfSpeech,
|
|
&i.Example,
|
|
&i.Etymology,
|
|
&i.CreatedAt,
|
|
&i.AccessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateHashConflict = `-- name: CreateHashConflict :exec
|
|
|
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (library_id, file_sha256) DO NOTHING
|
|
`
|
|
|
|
type CreateHashConflictParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
|
}
|
|
|
|
// HASH CONFLICTS QUERIES
|
|
// Record a pending hash conflict (no-op if the group is already tracked, so
|
|
// resolved groups stay resolved and are never re-flagged)
|
|
func (q *Queries) CreateHashConflict(ctx context.Context, arg CreateHashConflictParams) error {
|
|
_, err := q.db.Exec(ctx, CreateHashConflict, arg.LibraryID, arg.FileSha256)
|
|
return err
|
|
}
|
|
|
|
const CreateLibrary = `-- name: CreateLibrary :one
|
|
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, name, description, library_type_id, created_by_admin_id, created_at, updated_at, allow_member_feeds
|
|
`
|
|
|
|
type CreateLibraryParams struct {
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
}
|
|
|
|
// Libraries queries
|
|
func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error) {
|
|
row := q.db.QueryRow(ctx, CreateLibrary,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.LibraryTypeID,
|
|
arg.CreatedByAdminID,
|
|
)
|
|
var i Libraries
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaBookmarkFull = `-- name: CreateMediaBookmarkFull :one
|
|
INSERT INTO media_bookmarks (
|
|
media_item_id, user_id, page_number, chapter_number,
|
|
cfi_position, title, position, notes,
|
|
percentage_location, epubcfi_location, chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data, origin_source
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
|
) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
|
|
`
|
|
|
|
type CreateMediaBookmarkFullParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
|
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
|
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
|
Title string `db:"title" json:"title"`
|
|
Position pgtype.Text `db:"position" json:"position"`
|
|
Notes pgtype.Text `db:"notes" json:"notes"`
|
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
|
|
}
|
|
|
|
func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaBookmarkFull,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.PageNumber,
|
|
arg.ChapterNumber,
|
|
arg.CfiPosition,
|
|
arg.Title,
|
|
arg.Position,
|
|
arg.Notes,
|
|
arg.PercentageLocation,
|
|
arg.EpubcfiLocation,
|
|
arg.ChapterReference,
|
|
arg.DedupKey,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
arg.OriginSource,
|
|
)
|
|
var i MediaBookmarks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.PageNumber,
|
|
&i.ChapterNumber,
|
|
&i.CfiPosition,
|
|
&i.Title,
|
|
&i.Position,
|
|
&i.Notes,
|
|
&i.CreatedAt,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.DeviceSyncData,
|
|
&i.PercentageLocation,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
&i.OriginSource,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaHighlightFull = `-- name: CreateMediaHighlightFull :one
|
|
INSERT INTO media_highlights (
|
|
media_item_id, user_id, selection_text,
|
|
start_position, end_position, color, note_text,
|
|
percentage_start, percentage_end,
|
|
epubcfi_start, epubcfi_end,
|
|
chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
|
) RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at
|
|
`
|
|
|
|
type CreateMediaHighlightFullParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
}
|
|
|
|
func (q *Queries) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaHighlightFull,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.SelectionText,
|
|
arg.StartPosition,
|
|
arg.EndPosition,
|
|
arg.Color,
|
|
arg.NoteText,
|
|
arg.PercentageStart,
|
|
arg.PercentageEnd,
|
|
arg.EpubcfiStart,
|
|
arg.EpubcfiEnd,
|
|
arg.ChapterReference,
|
|
arg.DedupKey,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
)
|
|
var i MediaHighlights
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.NoteID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.ChapterReference,
|
|
&i.ParagraphStart,
|
|
&i.ParagraphEnd,
|
|
&i.PanelNumber,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.NoteText,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaItem = `-- name: CreateMediaItem :one
|
|
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
|
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type CreateMediaItemParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Contributors []string `db:"contributors" json:"contributors"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
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"`
|
|
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"`
|
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
}
|
|
|
|
// Media Items queries
|
|
// narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
|
// pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
|
// column default and 23502s). The API create handler normalizes nil.
|
|
func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaItem,
|
|
arg.LibraryID,
|
|
arg.Title,
|
|
arg.Subtitle,
|
|
arg.Authors,
|
|
arg.AuthorsSearch,
|
|
arg.Isbn,
|
|
arg.Description,
|
|
arg.FilePath,
|
|
arg.FileSize,
|
|
arg.MimeType,
|
|
arg.CoverImagePath,
|
|
arg.Series,
|
|
arg.SeriesNumber,
|
|
arg.Tags,
|
|
arg.TagsSearch,
|
|
arg.Asin,
|
|
arg.DatePublished,
|
|
arg.Publishers,
|
|
arg.PublishersSearch,
|
|
arg.Contributors,
|
|
arg.ContributorsSearch,
|
|
arg.Language,
|
|
arg.Edition,
|
|
arg.PageCount,
|
|
arg.Genre,
|
|
arg.CopyrightYear,
|
|
arg.GoodreadsID,
|
|
arg.OpenlibraryID,
|
|
arg.GoogleBooksID,
|
|
arg.AddedByAdminID,
|
|
arg.CreatedAt,
|
|
arg.ImportedAt,
|
|
arg.MangaType,
|
|
arg.ReadingDirection,
|
|
arg.SeriesCount,
|
|
arg.Volume,
|
|
arg.Imprint,
|
|
arg.AgeRating,
|
|
arg.WebUrl,
|
|
arg.StoryArc,
|
|
arg.IsBlackAndWhite,
|
|
arg.MetadataNotes,
|
|
arg.CommunityRating,
|
|
arg.AlternateInfo,
|
|
arg.ScanInformation,
|
|
arg.Summary,
|
|
arg.LibraryTypeName,
|
|
arg.AudioMetadata,
|
|
arg.Narrators,
|
|
arg.NarratorsSearch,
|
|
arg.Abridged,
|
|
arg.Explicit,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaItemFormat = `-- name: CreateMediaItemFormat :one
|
|
|
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
|
file_path = EXCLUDED.file_path,
|
|
file_sha256 = EXCLUDED.file_sha256,
|
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
|
mime_type = EXCLUDED.mime_type
|
|
RETURNING id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id
|
|
`
|
|
|
|
type CreateMediaItemFormatParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
FormatType string `db:"format_type" json:"format_type"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
FileSizeBytes pgtype.Int8 `db:"file_size_bytes" json:"file_size_bytes"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
ConvertedFromFormatID pgtype.UUID `db:"converted_from_format_id" json:"converted_from_format_id"`
|
|
}
|
|
|
|
// MEDIA ITEM FORMATS QUERIES
|
|
// Create media item format
|
|
func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaItemFormat,
|
|
arg.MediaItemID,
|
|
arg.FormatType,
|
|
arg.FilePath,
|
|
arg.FileSha256,
|
|
arg.FileSizeBytes,
|
|
arg.MimeType,
|
|
arg.ConvertedFromFormatID,
|
|
)
|
|
var i MediaItemFormats
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.FormatType,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.FileSizeBytes,
|
|
&i.MimeType,
|
|
&i.CreatedAt,
|
|
&i.ConvertedFromFormatID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :one
|
|
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
|
|
ON CONFLICT (library_id, file_path) DO NOTHING
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type CreateMediaItemIfNewParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Contributors []string `db:"contributors" json:"contributors"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
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"`
|
|
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"`
|
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
}
|
|
|
|
// API create path: race-safe create-or-detect. On a duplicate
|
|
// (library_id, file_path) this inserts NOTHING and returns no row
|
|
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
|
// and a duplicate flag instead of pretending it created something.
|
|
// narrators NOT NULL: callers pass empty slices, never nil (see
|
|
// CreateMediaItem note).
|
|
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
|
|
arg.LibraryID,
|
|
arg.Title,
|
|
arg.Subtitle,
|
|
arg.Authors,
|
|
arg.AuthorsSearch,
|
|
arg.Isbn,
|
|
arg.Description,
|
|
arg.FilePath,
|
|
arg.FileSize,
|
|
arg.MimeType,
|
|
arg.CoverImagePath,
|
|
arg.Series,
|
|
arg.SeriesNumber,
|
|
arg.Tags,
|
|
arg.TagsSearch,
|
|
arg.Asin,
|
|
arg.DatePublished,
|
|
arg.Publishers,
|
|
arg.PublishersSearch,
|
|
arg.Contributors,
|
|
arg.ContributorsSearch,
|
|
arg.Language,
|
|
arg.Edition,
|
|
arg.PageCount,
|
|
arg.Genre,
|
|
arg.CopyrightYear,
|
|
arg.GoodreadsID,
|
|
arg.OpenlibraryID,
|
|
arg.GoogleBooksID,
|
|
arg.AddedByAdminID,
|
|
arg.CreatedAt,
|
|
arg.ImportedAt,
|
|
arg.MangaType,
|
|
arg.ReadingDirection,
|
|
arg.SeriesCount,
|
|
arg.Volume,
|
|
arg.Imprint,
|
|
arg.AgeRating,
|
|
arg.WebUrl,
|
|
arg.StoryArc,
|
|
arg.IsBlackAndWhite,
|
|
arg.MetadataNotes,
|
|
arg.CommunityRating,
|
|
arg.AlternateInfo,
|
|
arg.ScanInformation,
|
|
arg.Summary,
|
|
arg.LibraryTypeName,
|
|
arg.AudioMetadata,
|
|
arg.Narrators,
|
|
arg.NarratorsSearch,
|
|
arg.Abridged,
|
|
arg.Explicit,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaNoteFull = `-- name: CreateMediaNoteFull :one
|
|
INSERT INTO media_notes (
|
|
media_item_id, user_id, content, position,
|
|
percentage_location, character_start, character_end,
|
|
epubcfi_location, chapter_reference, paragraph_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
|
) RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at
|
|
`
|
|
|
|
type CreateMediaNoteFullParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Content string `db:"content" json:"content"`
|
|
Position pgtype.Text `db:"position" json:"position"`
|
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
|
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
|
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
}
|
|
|
|
func (q *Queries) CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaNoteFull,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.Content,
|
|
arg.Position,
|
|
arg.PercentageLocation,
|
|
arg.CharacterStart,
|
|
arg.CharacterEnd,
|
|
arg.EpubcfiLocation,
|
|
arg.ChapterReference,
|
|
arg.ParagraphReference,
|
|
arg.DedupKey,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
)
|
|
var i MediaNotes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Content,
|
|
&i.Position,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageLocation,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.ParagraphReference,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateMediaRating = `-- name: CreateMediaRating :one
|
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
rating = EXCLUDED.rating,
|
|
updated_at = NOW()
|
|
RETURNING id, media_item_id, user_id, rating, created_at, updated_at
|
|
`
|
|
|
|
type CreateMediaRatingParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Rating int32 `db:"rating" json:"rating"`
|
|
}
|
|
|
|
func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error) {
|
|
row := q.db.QueryRow(ctx, CreateMediaRating, arg.MediaItemID, arg.UserID, arg.Rating)
|
|
var i MediaRatings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Rating,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateOpdsToken = `-- name: CreateOpdsToken :one
|
|
|
|
INSERT INTO opds_tokens (device_id, token, token_type, expires_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, device_id, token, token_type, expires_at, created_at
|
|
`
|
|
|
|
type CreateOpdsTokenParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
Token string `db:"token" json:"token"`
|
|
TokenType pgtype.Text `db:"token_type" json:"token_type"`
|
|
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
|
}
|
|
|
|
// OPDS TOKENS QUERIES
|
|
// Create OPDS token
|
|
func (q *Queries) CreateOpdsToken(ctx context.Context, arg CreateOpdsTokenParams) (OpdsTokens, error) {
|
|
row := q.db.QueryRow(ctx, CreateOpdsToken,
|
|
arg.DeviceID,
|
|
arg.Token,
|
|
arg.TokenType,
|
|
arg.ExpiresAt,
|
|
)
|
|
var i OpdsTokens
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.Token,
|
|
&i.TokenType,
|
|
&i.ExpiresAt,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateOrUpdateKoboEntitlement = `-- name: CreateOrUpdateKoboEntitlement :one
|
|
|
|
INSERT INTO kobo_entitlements (device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, kobo_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (device_id, entitlement_id)
|
|
DO UPDATE SET
|
|
content_id = EXCLUDED.content_id,
|
|
revision_number = EXCLUDED.revision_number,
|
|
purchase_date = COALESCE(EXCLUDED.purchase_date, kobo_entitlements.purchase_date),
|
|
book_status = 'installed',
|
|
sync_status = 'synced',
|
|
kobo_metadata = EXCLUDED.kobo_metadata,
|
|
updated_at = NOW()
|
|
RETURNING id, device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, accession_date, book_status, sync_status, kobo_metadata, created_at, updated_at
|
|
`
|
|
|
|
type CreateOrUpdateKoboEntitlementParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
PurchaseDate pgtype.Timestamptz `db:"purchase_date" json:"purchase_date"`
|
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
|
}
|
|
|
|
// ============================================
|
|
// KOBO ENTITLEMENT QUERIES
|
|
// ============================================
|
|
func (q *Queries) CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error) {
|
|
row := q.db.QueryRow(ctx, CreateOrUpdateKoboEntitlement,
|
|
arg.DeviceID,
|
|
arg.MediaItemID,
|
|
arg.EntitlementID,
|
|
arg.ContentID,
|
|
arg.RevisionNumber,
|
|
arg.PurchaseDate,
|
|
arg.KoboMetadata,
|
|
)
|
|
var i KoboEntitlements
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.EntitlementID,
|
|
&i.ContentID,
|
|
&i.RevisionNumber,
|
|
&i.PurchaseDate,
|
|
&i.AccessionDate,
|
|
&i.BookStatus,
|
|
&i.SyncStatus,
|
|
&i.KoboMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreatePodcastFeed = `-- name: CreatePodcastFeed :one
|
|
|
|
INSERT INTO podcast_feeds (library_id, feed_url, title, created_by_admin_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (library_id, feed_url) DO UPDATE SET updated_at = NOW()
|
|
RETURNING id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at
|
|
`
|
|
|
|
type CreatePodcastFeedParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
|
Title string `db:"title" json:"title"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
|
|
// Visibility is RLS-enforced (schema policy block) — NO query here carries
|
|
// visibility clauses. Feed reads run under the acting user's identity
|
|
// (subscriber-or-admin policy); the fetcher/worker run as first-admin.
|
|
// The fetcher/download worker is the SOLE writer of feed/episode metadata;
|
|
// users write only podcast_subscriptions rows.
|
|
// ============================================================================
|
|
// Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
|
|
// a second subscriber adding the same URL attaches to the existing row
|
|
// instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
|
|
// even when the conflicting row is invisible to the actor under RLS
|
|
// (non-admin creators cannot SELECT other users' feeds). Metadata columns
|
|
// are filled by the fetcher right after; this only establishes identity.
|
|
func (q *Queries) CreatePodcastFeed(ctx context.Context, arg CreatePodcastFeedParams) (PodcastFeeds, error) {
|
|
row := q.db.QueryRow(ctx, CreatePodcastFeed,
|
|
arg.LibraryID,
|
|
arg.FeedUrl,
|
|
arg.Title,
|
|
arg.CreatedByAdminID,
|
|
)
|
|
var i PodcastFeeds
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreatePodcastLibraryFolder = `-- name: CreatePodcastLibraryFolder :one
|
|
INSERT INTO library_folders (library_id, folder_path, purpose)
|
|
VALUES ($1, $2, 'podcast')
|
|
RETURNING id, library_id, folder_path, created_at, purpose
|
|
`
|
|
|
|
type CreatePodcastLibraryFolderParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
|
}
|
|
|
|
func (q *Queries) CreatePodcastLibraryFolder(ctx context.Context, arg CreatePodcastLibraryFolderParams) (LibraryFolders, error) {
|
|
row := q.db.QueryRow(ctx, CreatePodcastLibraryFolder, arg.LibraryID, arg.FolderPath)
|
|
var i LibraryFolders
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.CreatedAt,
|
|
&i.Purpose,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateProcessingIssue = `-- name: CreateProcessingIssue :one
|
|
|
|
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid), library_id, issue_type, COALESCE(file_path, ''))
|
|
DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
|
severity = EXCLUDED.severity,
|
|
resolved = false,
|
|
resolved_at = NULL
|
|
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
|
`
|
|
|
|
type CreateProcessingIssueParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
IssueType string `db:"issue_type" json:"issue_type"`
|
|
IssueDescription string `db:"issue_description" json:"issue_description"`
|
|
Severity string `db:"severity" json:"severity"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// PROCESSING ISSUES QUERIES
|
|
// ============================================================================
|
|
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
|
// are nullable — item-backed rows key on (item, library, type, ”),
|
|
// row-less import failures on (zero-uuid, library, type, path). The
|
|
// conflict target matches idx_processing_issues_dedup's expressions.
|
|
// A repeat failure REOPENS a resolved row (resolved=false).
|
|
func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error) {
|
|
row := q.db.QueryRow(ctx, CreateProcessingIssue,
|
|
arg.MediaItemID,
|
|
arg.LibraryID,
|
|
arg.IssueType,
|
|
arg.IssueDescription,
|
|
arg.Severity,
|
|
arg.FilePath,
|
|
)
|
|
var i ProcessingIssues
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.LibraryID,
|
|
&i.IssueType,
|
|
&i.IssueDescription,
|
|
&i.Severity,
|
|
&i.Resolved,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateReadingHistory = `-- name: CreateReadingHistory :one
|
|
INSERT INTO reading_history (
|
|
user_id,
|
|
media_item_id,
|
|
device_id,
|
|
progress_percentage,
|
|
reading_session_start,
|
|
reading_session_end,
|
|
pages_read,
|
|
time_spent_seconds,
|
|
device_metadata
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
|
|
`
|
|
|
|
type CreateReadingHistoryParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
}
|
|
|
|
// Create reading history entry
|
|
func (q *Queries) CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error) {
|
|
row := q.db.QueryRow(ctx, CreateReadingHistory,
|
|
arg.UserID,
|
|
arg.MediaItemID,
|
|
arg.DeviceID,
|
|
arg.ProgressPercentage,
|
|
arg.ReadingSessionStart,
|
|
arg.ReadingSessionEnd,
|
|
arg.PagesRead,
|
|
arg.TimeSpentSeconds,
|
|
arg.DeviceMetadata,
|
|
)
|
|
var i ReadingHistory
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.ProgressPercentage,
|
|
&i.ReadingSessionStart,
|
|
&i.ReadingSessionEnd,
|
|
&i.PagesRead,
|
|
&i.TimeSpentSeconds,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateReadingSpeed = `-- name: CreateReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at, updated_at
|
|
`
|
|
|
|
type CreateReadingSpeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
PagesPerMinute pgtype.Float4 `db:"pages_per_minute" json:"pages_per_minute"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TotalReadingMinutes pgtype.Float4 `db:"total_reading_minutes" json:"total_reading_minutes"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
func (q *Queries) CreateReadingSpeed(ctx context.Context, arg CreateReadingSpeedParams) (ReadingSpeed, error) {
|
|
row := q.db.QueryRow(ctx, CreateReadingSpeed,
|
|
arg.UserID,
|
|
arg.MediaItemID,
|
|
arg.PagesPerMinute,
|
|
arg.PagesRead,
|
|
arg.TotalReadingMinutes,
|
|
arg.LastReadAt,
|
|
)
|
|
var i ReadingSpeed
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.WordsPerMinute,
|
|
&i.PagesPerMinute,
|
|
&i.PagesRead,
|
|
&i.TotalReadingMinutes,
|
|
&i.LastReadAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateRefreshToken = `-- name: CreateRefreshToken :one
|
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, user_id, token, expires_at, created_at, revoked_at
|
|
`
|
|
|
|
type CreateRefreshTokenParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Token pgtype.UUID `db:"token" json:"token"`
|
|
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
|
}
|
|
|
|
// Refresh Tokens queries
|
|
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) {
|
|
row := q.db.QueryRow(ctx, CreateRefreshToken, arg.UserID, arg.Token, arg.ExpiresAt)
|
|
var i RefreshTokens
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.CreatedAt,
|
|
&i.RevokedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateSavedFilter = `-- name: CreateSavedFilter :one
|
|
INSERT INTO saved_filters (user_id, name, resource_type, filters)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, user_id, name, resource_type, filters, created_at, updated_at
|
|
`
|
|
|
|
type CreateSavedFilterParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
|
Filters []byte `db:"filters" json:"filters"`
|
|
}
|
|
|
|
func (q *Queries) CreateSavedFilter(ctx context.Context, arg CreateSavedFilterParams) (SavedFilters, error) {
|
|
row := q.db.QueryRow(ctx, CreateSavedFilter,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.ResourceType,
|
|
arg.Filters,
|
|
)
|
|
var i SavedFilters
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.ResourceType,
|
|
&i.Filters,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateSyncConflict = `-- name: CreateSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at
|
|
`
|
|
|
|
type CreateSyncConflictParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
|
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
|
}
|
|
|
|
// Conflict Resolution
|
|
func (q *Queries) CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) {
|
|
row := q.db.QueryRow(ctx, CreateSyncConflict,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.ConflictType,
|
|
arg.ConflictData,
|
|
)
|
|
var i SyncConflicts
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateSyncHistoryEntry = `-- name: CreateSyncHistoryEntry :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, status)
|
|
VALUES ($1, $2, 'koreader_progress', $3, 5, 'completed')
|
|
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
|
|
`
|
|
|
|
type CreateSyncHistoryEntryParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
SyncData []byte `db:"sync_data" json:"sync_data"`
|
|
}
|
|
|
|
func (q *Queries) CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error) {
|
|
row := q.db.QueryRow(ctx, CreateSyncHistoryEntry, arg.DeviceID, arg.MediaItemID, arg.SyncData)
|
|
var i SyncQueue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateSyncQueueItem = `-- name: CreateSyncQueueItem :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
|
|
`
|
|
|
|
type CreateSyncQueueItemParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
SyncType string `db:"sync_type" json:"sync_type"`
|
|
SyncData []byte `db:"sync_data" json:"sync_data"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
MaxAttempts pgtype.Int4 `db:"max_attempts" json:"max_attempts"`
|
|
Status pgtype.Text `db:"status" json:"status"`
|
|
}
|
|
|
|
// Sync Queue Management
|
|
func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) {
|
|
row := q.db.QueryRow(ctx, CreateSyncQueueItem,
|
|
arg.DeviceID,
|
|
arg.MediaItemID,
|
|
arg.SyncType,
|
|
arg.SyncData,
|
|
arg.Priority,
|
|
arg.MaxAttempts,
|
|
arg.Status,
|
|
)
|
|
var i SyncQueue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateSystemCollection = `-- name: CreateSystemCollection :one
|
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
|
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
|
`
|
|
|
|
type CreateSystemCollectionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
|
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
}
|
|
|
|
func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCollectionParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, CreateSystemCollection,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Icon,
|
|
arg.Color,
|
|
arg.ShowOnDashboard,
|
|
arg.QueryType,
|
|
arg.Priority,
|
|
)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
|
|
|
|
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
|
`
|
|
|
|
type CreateUnlinkedBookParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// ============================================
|
|
// ENHANCED KOBO SYNC
|
|
// ============================================
|
|
// Create unlinked book entry
|
|
func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBookParams) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, CreateUnlinkedBook,
|
|
arg.DeviceID,
|
|
arg.ContentID,
|
|
arg.FilePath,
|
|
arg.Title,
|
|
arg.Author,
|
|
arg.ConfidenceScore,
|
|
)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const CreateUser = `-- name: CreateUser :one
|
|
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at
|
|
`
|
|
|
|
type CreateUserParams struct {
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
Role string `db:"role" json:"role"`
|
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
|
}
|
|
|
|
type CreateUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
|
row := q.db.QueryRow(ctx, CreateUser,
|
|
arg.Email,
|
|
arg.Username,
|
|
arg.PasswordHash,
|
|
arg.FirstName,
|
|
arg.LastName,
|
|
arg.Theme,
|
|
arg.Role,
|
|
arg.Timezone,
|
|
)
|
|
var i CreateUserRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.Timezone,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const DeleteCollection = `-- name: DeleteCollection :exec
|
|
DELETE FROM collections WHERE id = $1
|
|
`
|
|
|
|
// Delete collection
|
|
func (q *Queries) DeleteCollection(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteCollection, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteDevice = `-- name: DeleteDevice :exec
|
|
DELETE FROM devices WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteDevice(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteDevice, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteDeviceByToken = `-- name: DeleteDeviceByToken :exec
|
|
DELETE FROM devices WHERE auth_token = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteDeviceByToken(ctx context.Context, authToken string) error {
|
|
_, err := q.db.Exec(ctx, DeleteDeviceByToken, authToken)
|
|
return err
|
|
}
|
|
|
|
const DeleteDeviceCatalog = `-- name: DeleteDeviceCatalog :exec
|
|
DELETE FROM device_catalogs WHERE id = $1
|
|
`
|
|
|
|
// Delete device catalog entry
|
|
func (q *Queries) DeleteDeviceCatalog(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteDeviceCatalog, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteDeviceFileAlias = `-- name: DeleteDeviceFileAlias :exec
|
|
DELETE FROM device_file_aliases WHERE id = $1
|
|
`
|
|
|
|
// Delete device file alias
|
|
func (q *Queries) DeleteDeviceFileAlias(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteDeviceFileAlias, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteDeviceShelfMapping = `-- name: DeleteDeviceShelfMapping :exec
|
|
DELETE FROM device_shelf_mappings WHERE id = $1
|
|
`
|
|
|
|
// Delete device shelf mapping
|
|
func (q *Queries) DeleteDeviceShelfMapping(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteDeviceShelfMapping, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteKoboEntitlement = `-- name: DeleteKoboEntitlement :exec
|
|
DELETE FROM kobo_entitlements WHERE device_id = $1 AND entitlement_id = $2
|
|
`
|
|
|
|
type DeleteKoboEntitlementParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteKoboEntitlement(ctx context.Context, arg DeleteKoboEntitlementParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteKoboEntitlement, arg.DeviceID, arg.EntitlementID)
|
|
return err
|
|
}
|
|
|
|
const DeleteLibrary = `-- name: DeleteLibrary :exec
|
|
DELETE FROM libraries WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteLibrary(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteLibrary, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteLibraryFolder = `-- name: DeleteLibraryFolder :one
|
|
DELETE FROM library_folders WHERE library_id = $1 AND folder_path = $2 RETURNING id, library_id, folder_path, created_at, purpose
|
|
`
|
|
|
|
type DeleteLibraryFolderParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
|
}
|
|
|
|
func (q *Queries) DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error) {
|
|
row := q.db.QueryRow(ctx, DeleteLibraryFolder, arg.LibraryID, arg.FolderPath)
|
|
var i LibraryFolders
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.CreatedAt,
|
|
&i.Purpose,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const DeleteMediaItem = `-- name: DeleteMediaItem :exec
|
|
DELETE FROM media_items WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteMediaItem(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteMediaItem, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteMediaItemFormat = `-- name: DeleteMediaItemFormat :exec
|
|
DELETE FROM media_item_formats WHERE id = $1
|
|
`
|
|
|
|
// Delete media item format
|
|
func (q *Queries) DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteMediaItemFormat, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteMediaRating = `-- name: DeleteMediaRating :exec
|
|
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type DeleteMediaRatingParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteMediaRating, arg.MediaItemID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const DeleteMediaReview = `-- name: DeleteMediaReview :exec
|
|
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type DeleteMediaReviewParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteMediaReview, arg.MediaItemID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const DeletePodcastFeed = `-- name: DeletePodcastFeed :execrows
|
|
DELETE FROM podcast_feeds WHERE id = $1
|
|
`
|
|
|
|
// Admin-only removal (handler checks role); episodes cascade via
|
|
// media_items.source_feed_id ON DELETE CASCADE.
|
|
func (q *Queries) DeletePodcastFeed(ctx context.Context, id pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, DeletePodcastFeed, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const DeletePodcastSubscription = `-- name: DeletePodcastSubscription :execrows
|
|
DELETE FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2
|
|
`
|
|
|
|
type DeletePodcastSubscriptionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
|
}
|
|
|
|
// Unsubscribe NEVER deletes feed/episode/file state (D6).
|
|
func (q *Queries) DeletePodcastSubscription(ctx context.Context, arg DeletePodcastSubscriptionParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, DeletePodcastSubscription, arg.UserID, arg.FeedID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
|
|
DELETE FROM processing_issues
|
|
WHERE id = $1
|
|
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
|
`
|
|
|
|
func (q *Queries) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
|
|
row := q.db.QueryRow(ctx, DeleteProcessingIssue, id)
|
|
var i ProcessingIssues
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.LibraryID,
|
|
&i.IssueType,
|
|
&i.IssueDescription,
|
|
&i.Severity,
|
|
&i.Resolved,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
|
|
DELETE FROM reading_progress WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type DeleteReadingProgressParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteReadingProgress, arg.MediaItemID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const DeleteSavedFilter = `-- name: DeleteSavedFilter :one
|
|
DELETE FROM saved_filters
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id, user_id, name, resource_type, filters, created_at, updated_at
|
|
`
|
|
|
|
type DeleteSavedFilterParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error) {
|
|
row := q.db.QueryRow(ctx, DeleteSavedFilter, arg.ID, arg.UserID)
|
|
var i SavedFilters
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.ResourceType,
|
|
&i.Filters,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const DeleteSyncConflict = `-- name: DeleteSyncConflict :exec
|
|
DELETE FROM sync_conflicts WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteSyncConflict, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteSyncQueueItem = `-- name: DeleteSyncQueueItem :exec
|
|
DELETE FROM sync_queue WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteSyncQueueItem, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteSystemConfig = `-- name: DeleteSystemConfig :exec
|
|
DELETE FROM system_config WHERE key = $1
|
|
`
|
|
|
|
// Delete system config
|
|
func (q *Queries) DeleteSystemConfig(ctx context.Context, key string) error {
|
|
_, err := q.db.Exec(ctx, DeleteSystemConfig, key)
|
|
return err
|
|
}
|
|
|
|
const DeleteUnlinkedBook = `-- name: DeleteUnlinkedBook :exec
|
|
DELETE FROM unlinked_books WHERE id = $1
|
|
`
|
|
|
|
// Delete unlinked book
|
|
func (q *Queries) DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteUnlinkedBook, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteUser = `-- name: DeleteUser :exec
|
|
DELETE FROM users WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DeleteUser, id)
|
|
return err
|
|
}
|
|
|
|
const DeleteUserSettingByKey = `-- name: DeleteUserSettingByKey :exec
|
|
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2
|
|
`
|
|
|
|
type DeleteUserSettingByKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
}
|
|
|
|
func (q *Queries) DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteUserSettingByKey, arg.UserID, arg.SettingKey)
|
|
return err
|
|
}
|
|
|
|
const DeleteUserSystemCollection = `-- name: DeleteUserSystemCollection :exec
|
|
DELETE FROM collections
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true
|
|
`
|
|
|
|
type DeleteUserSystemCollectionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
}
|
|
|
|
func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error {
|
|
_, err := q.db.Exec(ctx, DeleteUserSystemCollection, arg.UserID, arg.Name)
|
|
return err
|
|
}
|
|
|
|
const DetachReadingHistoryFromDevice = `-- name: DetachReadingHistoryFromDevice :exec
|
|
UPDATE reading_history SET device_id = NULL WHERE device_id = $1
|
|
`
|
|
|
|
// Device deletion (deregister on logout): detach the device's reading
|
|
// history instead of deleting it — these rows are real captured stats
|
|
// (the Users section reads them); reading_history.device_id is NO ACTION
|
|
// so a blind device delete 500s once any history anchors to the device.
|
|
// device_id is nullable: NULL keeps the session, loses the association.
|
|
func (q *Queries) DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, DetachReadingHistoryFromDevice, deviceID)
|
|
return err
|
|
}
|
|
|
|
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM devices
|
|
WHERE id = $1 AND user_id = $2
|
|
) AS has_device
|
|
`
|
|
|
|
type DeviceExistsForUserParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
// Device existence probe for capture: the web/app progress paths
|
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
|
// label before stats); a capture row must only reference a REAL device.
|
|
func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) {
|
|
row := q.db.QueryRow(ctx, DeviceExistsForUser, arg.DeviceID, arg.UserID)
|
|
var has_device bool
|
|
err := row.Scan(&has_device)
|
|
return has_device, err
|
|
}
|
|
|
|
const FacetCountsUnderFilters = `-- name: FacetCountsUnderFilters :many
|
|
|
|
(SELECT 'author'::text AS facet, a::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
-- author's own block omitted: a facet counts under the others
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND a IS NOT NULL AND a != ''
|
|
GROUP BY a
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'publisher'::text AS facet, p::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND p IS NOT NULL AND p != ''
|
|
GROUP BY p
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'narrator'::text AS facet, n::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND n IS NOT NULL AND n != ''
|
|
GROUP BY n
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'tags'::text AS facet, t::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND t IS NOT NULL AND t != ''
|
|
GROUP BY t
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'series'::text AS facet, mi.series::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.series IS NOT NULL AND mi.series != ''
|
|
GROUP BY mi.series
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'genre'::text AS facet, mi.genre::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.genre IS NOT NULL AND mi.genre != ''
|
|
GROUP BY mi.genre
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'language'::text AS facet, mi.language::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.language IS NOT NULL AND mi.language != ''
|
|
GROUP BY mi.language
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'story_arc'::text AS facet, mi.story_arc::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
|
|
GROUP BY mi.story_arc
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'imprint'::text AS facet, mi.imprint::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.imprint IS NOT NULL AND mi.imprint != ''
|
|
GROUP BY mi.imprint
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'edition'::text AS facet, mi.edition::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.edition IS NOT NULL AND mi.edition != ''
|
|
GROUP BY mi.edition
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'age_rating'::text AS facet, mi.age_rating::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
|
|
GROUP BY mi.age_rating
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'format_group'::text AS facet, mi.format_group::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.format_group IS NOT NULL AND mi.format_group != ''
|
|
GROUP BY mi.format_group
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'manga_type'::text AS facet, mi.manga_type::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown'
|
|
GROUP BY mi.manga_type
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
UNION ALL
|
|
(SELECT 'reading_direction'::text AS facet, mi.reading_direction::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($22 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($22, ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND ($4 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($4, narrator) > 0.3
|
|
))
|
|
AND ($5 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($5, tag) > 0.3
|
|
))
|
|
AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
|
|
AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($9, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
|
|
AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
|
|
AND ($16::bool IS NULL OR mi.explicit = $16::bool)
|
|
AND ($17::bool IS NULL OR mi.abridged = $17::bool)
|
|
AND (
|
|
$18 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $18 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $18)
|
|
)
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $19)
|
|
)
|
|
AND (
|
|
$20::bool IS NULL OR
|
|
($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($20::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
|
|
GROUP BY mi.reading_direction
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT $21)
|
|
`
|
|
|
|
type FacetCountsUnderFiltersParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
|
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
|
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
|
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
|
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
|
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
|
StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"`
|
|
ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"`
|
|
EditionFilter interface{} `db:"edition_filter" json:"edition_filter"`
|
|
AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"`
|
|
FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"`
|
|
MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"`
|
|
ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"`
|
|
ExplicitFilter pgtype.Bool `db:"explicit_filter" json:"explicit_filter"`
|
|
AbridgedFilter pgtype.Bool `db:"abridged_filter" json:"abridged_filter"`
|
|
YearMin interface{} `db:"year_min" json:"year_min"`
|
|
YearMax interface{} `db:"year_max" json:"year_max"`
|
|
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
|
}
|
|
|
|
type FacetCountsUnderFiltersRow struct {
|
|
Facet string `db:"facet" json:"facet"`
|
|
Value string `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
}
|
|
|
|
// Facet-value listings (faceted-navigation Batch C v2, faceted plan
|
|
// Phase 7): ALL listable facets' top values + counts in ONE statement —
|
|
// one round trip serves the filter drawer's panel, with classic faceted
|
|
// semantics: each arm OMITS its own facet's filter block and carries
|
|
// every OTHER registered facet's block (copied mechanically from
|
|
// SearchMediaItemsUnified — keep the two in lockstep), so with filters
|
|
// active each facet counts under the others, and with no filters the
|
|
// blocks are neutral and the counts are the v1 global-per-library ones.
|
|
// One query serves both cases.
|
|
//
|
|
// Visibility: same family-lens join as the search query — a user's
|
|
// hidden-library rows are absent; the handler 403s an out-of-set
|
|
// explicit library_id. library_id is nullable: NULL = the All-Libraries
|
|
// view (no scope beyond the lens). Bool/range facets (has_cover,
|
|
// explicit, abridged, year) filter every arm but list no values — they
|
|
// render as toggles/inputs, not lists.
|
|
//
|
|
// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
|
|
// select bind per-arm only in parenthesized subqueries). The same
|
|
// sqlc.narg names repeat across arms; sqlc binds them once.
|
|
func (q *Queries) FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error) {
|
|
rows, err := q.db.Query(ctx, FacetCountsUnderFilters,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.PublisherFilter,
|
|
arg.NarratorFilter,
|
|
arg.TagsFilter,
|
|
arg.SeriesFilter,
|
|
arg.GenreFilter,
|
|
arg.LanguageFilter,
|
|
arg.StoryArcFilter,
|
|
arg.ImprintFilter,
|
|
arg.EditionFilter,
|
|
arg.AgeRatingFilter,
|
|
arg.FormatGroupFilter,
|
|
arg.MangaTypeFilter,
|
|
arg.ReadingDirectionFilter,
|
|
arg.ExplicitFilter,
|
|
arg.AbridgedFilter,
|
|
arg.YearMin,
|
|
arg.YearMax,
|
|
arg.HasCover,
|
|
arg.Limit,
|
|
arg.AuthorFilter,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FacetCountsUnderFiltersRow{}
|
|
for rows.Next() {
|
|
var i FacetCountsUnderFiltersRow
|
|
if err := rows.Scan(&i.Facet, &i.Value, &i.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
|
FROM media_items
|
|
WHERE file_sha256 IS NOT NULL
|
|
GROUP BY library_id, file_sha256
|
|
HAVING COUNT(*) > 1
|
|
`
|
|
|
|
type FindHashConflictGroupsRow struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
DupCount int64 `db:"dup_count" json:"dup_count"`
|
|
}
|
|
|
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
|
func (q *Queries) FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error) {
|
|
rows, err := q.db.Query(ctx, FindHashConflictGroups)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FindHashConflictGroupsRow{}
|
|
for rows.Next() {
|
|
var i FindHashConflictGroupsRow
|
|
if err := rows.Scan(&i.LibraryID, &i.FileSha256, &i.DupCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GenerateKoboEntitlementId = `-- name: GenerateKoboEntitlementId :one
|
|
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id
|
|
`
|
|
|
|
func (q *Queries) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) {
|
|
row := q.db.QueryRow(ctx, GenerateKoboEntitlementId)
|
|
var entitlement_id interface{}
|
|
err := row.Scan(&entitlement_id)
|
|
return entitlement_id, err
|
|
}
|
|
|
|
const GetActiveAnnotationsForBook = `-- name: GetActiveAnnotationsForBook :many
|
|
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end,
|
|
mh.note_text,
|
|
mh.dedup_key,
|
|
mh.last_modified_at,
|
|
mh.last_modified_source
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end,
|
|
NULL as note_text,
|
|
mn.dedup_key,
|
|
mn.last_modified_at,
|
|
mn.last_modified_source
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetActiveAnnotationsForBookParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
type GetActiveAnnotationsForBookRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
|
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
}
|
|
|
|
// ============================================
|
|
// ANNOTATION SERVE QUERIES
|
|
// ============================================
|
|
func (q *Queries) GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error) {
|
|
rows, err := q.db.Query(ctx, GetActiveAnnotationsForBook, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetActiveAnnotationsForBookRow{}
|
|
for rows.Next() {
|
|
var i GetActiveAnnotationsForBookRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AnnotationType,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.NoteText,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetAllSystemConfig = `-- name: GetAllSystemConfig :many
|
|
SELECT key, value, updated_at, updated_by FROM system_config ORDER BY key
|
|
`
|
|
|
|
// Get all system config
|
|
func (q *Queries) GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) {
|
|
rows, err := q.db.Query(ctx, GetAllSystemConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SystemConfig{}
|
|
for rows.Next() {
|
|
var i SystemConfig
|
|
if err := rows.Scan(
|
|
&i.Key,
|
|
&i.Value,
|
|
&i.UpdatedAt,
|
|
&i.UpdatedBy,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetAllSystemSettings = `-- name: GetAllSystemSettings :many
|
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key
|
|
`
|
|
|
|
type GetAllSystemSettingsRow struct {
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
}
|
|
|
|
func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetAllSystemSettings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetAllSystemSettingsRow{}
|
|
for rows.Next() {
|
|
var i GetAllSystemSettingsRow
|
|
if err := rows.Scan(&i.SettingKey, &i.SettingValue, &i.Description); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many
|
|
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key
|
|
`
|
|
|
|
func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) {
|
|
rows, err := q.db.Query(ctx, GetAllSystemSettingsFull)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SystemSettings{}
|
|
for rows.Next() {
|
|
var i SystemSettings
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.SettingKey,
|
|
&i.SettingValue,
|
|
&i.Description,
|
|
&i.UpdatedAt,
|
|
&i.SettingType,
|
|
&i.MinValue,
|
|
&i.MaxValue,
|
|
&i.RequiresRestart,
|
|
&i.Category,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetAnnotationsForBookParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
type GetAnnotationsForBookRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
|
}
|
|
|
|
func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) {
|
|
rows, err := q.db.Query(ctx, GetAnnotationsForBook, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetAnnotationsForBookRow{}
|
|
for rows.Next() {
|
|
var i GetAnnotationsForBookRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AnnotationType,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
|
|
|
|
SELECT speed FROM audio_playback_speed
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
`
|
|
|
|
type GetAudioPlaybackSpeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
// Per-book playback speed preference (player plan §8).
|
|
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
|
|
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
|
|
var speed float32
|
|
err := row.Scan(&speed)
|
|
return speed, err
|
|
}
|
|
|
|
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
|
SELECT
|
|
rh.id, rh.device_id, rh.progress_percentage,
|
|
rh.reading_session_start, rh.reading_session_end,
|
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
|
FROM reading_history rh
|
|
WHERE rh.user_id = $1
|
|
AND rh.media_item_id = $2
|
|
ORDER BY rh.created_at ASC
|
|
`
|
|
|
|
type GetBookReadingSessionsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
type GetBookReadingSessionsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
|
func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetBookReadingSessions, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetBookReadingSessionsRow{}
|
|
for rows.Next() {
|
|
var i GetBookReadingSessionsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ProgressPercentage,
|
|
&i.ReadingSessionStart,
|
|
&i.ReadingSessionEnd,
|
|
&i.PagesRead,
|
|
&i.TimeSpentSeconds,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetBooksByTag = `-- name: GetBooksByTag :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
|
ORDER BY title ASC
|
|
`
|
|
|
|
type GetBooksByTagParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Column2 string `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetBooksByTag, arg.LibraryID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollection = `-- name: GetCollection :one
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
|
|
`
|
|
|
|
// Get collection
|
|
func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, GetCollection, id)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
|
WHERE user_id = $1
|
|
AND view_settings->>'imported_from_series' = $2::text
|
|
AND deleted_at IS NULL
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetCollectionByImportedSeriesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ImportedFromSeries string `db:"imported_from_series" json:"imported_from_series"`
|
|
}
|
|
|
|
// A2c §2.4: playlist-collection discovery — provenance key first so a user
|
|
// rename can't spawn duplicates on the next scan.
|
|
func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, GetCollectionByImportedSeries, arg.UserID, arg.ImportedFromSeries)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
|
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetCollectionByUserAndNameParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
}
|
|
|
|
func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, GetCollectionByUserAndName, arg.UserID, arg.Name)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetCollectionEpisodesOrdered = `-- name: GetCollectionEpisodesOrdered :many
|
|
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
|
|
FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND mi.source = 'podcast_episode'
|
|
AND mi.download_state = 'downloaded'
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
`
|
|
|
|
type GetCollectionEpisodesOrderedRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
}
|
|
|
|
// The collection queue's downloaded episodes in collection order
|
|
// (plan item 2.3 "Play collection as sequence"): position-first, then
|
|
// added_at. INNER JOIN — the chain steps only through episodes the
|
|
// requester can see (RLS drops unsubscribed feeds) and that are
|
|
// downloaded (invariant 6: un-downloaded episodes are never tracked).
|
|
func (q *Queries) GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionEpisodesOrdered, collectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetCollectionEpisodesOrderedRow{}
|
|
for rows.Next() {
|
|
var i GetCollectionEpisodesOrderedRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.FeedPosition,
|
|
&i.DatePublished,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionItems = `-- name: GetCollectionItems :many
|
|
SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, ci.position, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
|
FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
`
|
|
|
|
type GetCollectionItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
|
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
|
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
|
|
Position pgtype.Int4 `db:"position" json:"position"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FormatGroup string `db:"format_group" json:"format_group"`
|
|
}
|
|
|
|
// Get collection items
|
|
// A2c: position-first ordering (playlist reading order); un-positioned
|
|
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
|
// which walked a playlist backwards.
|
|
func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionItems, collectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetCollectionItemsRow{}
|
|
for rows.Next() {
|
|
var i GetCollectionItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.MediaItemID,
|
|
&i.AddedAt,
|
|
&i.AddedByUserID,
|
|
&i.Excluded,
|
|
&i.Position,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.CoverImagePath,
|
|
&i.LibraryID,
|
|
&i.FormatGroup,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionItemsDetailed = `-- name: GetCollectionItemsDetailed :many
|
|
SELECT ci.media_item_id, ci.position, ci.added_at,
|
|
(mi.id IS NOT NULL)::bool AS visible,
|
|
mi.title, mi.author, mi.cover_image_path, mi.format_group,
|
|
mi.source, mi.download_state,
|
|
pf.title AS feed_title
|
|
FROM collection_items ci
|
|
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
|
|
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
|
WHERE ci.collection_id = $1
|
|
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
`
|
|
|
|
type GetCollectionItemsDetailedRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Position pgtype.Int4 `db:"position" json:"position"`
|
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
|
Visible bool `db:"visible" json:"visible"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
FormatGroup pgtype.Text `db:"format_group" json:"format_group"`
|
|
Source pgtype.Text `db:"source" json:"source"`
|
|
DownloadState pgtype.Text `db:"download_state" json:"download_state"`
|
|
FeedTitle pgtype.Text `db:"feed_title" json:"feed_title"`
|
|
}
|
|
|
|
// Episode-collections detail (plan item 2.3): the collection's full
|
|
// membership for the SSR detail page, with per-row visibility. LEFT
|
|
// JOIN + RLS: rows whose media is invisible to the requester
|
|
// (unsubscribed episode — or a family-lens hidden book) survive with
|
|
// NULL media columns and visible=false, so the page can render an
|
|
// "unavailable" hint instead of silently vanishing them. Rows visible
|
|
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
|
|
func (q *Queries) GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionItemsDetailed, collectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetCollectionItemsDetailedRow{}
|
|
for rows.Next() {
|
|
var i GetCollectionItemsDetailedRow
|
|
if err := rows.Scan(
|
|
&i.MediaItemID,
|
|
&i.Position,
|
|
&i.AddedAt,
|
|
&i.Visible,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.CoverImagePath,
|
|
&i.FormatGroup,
|
|
&i.Source,
|
|
&i.DownloadState,
|
|
&i.FeedTitle,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
|
WHERE ci.collection_id = $2
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (ci.excluded IS NOT TRUE)
|
|
AND ($4::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
LIMIT $5
|
|
`
|
|
|
|
type GetCollectionItemsForDashboardParams struct {
|
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
HideFinished pgtype.Bool `db:"hide_finished" json:"hide_finished"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetCollectionItemsForDashboardRow struct {
|
|
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
|
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
|
}
|
|
|
|
// The excluded filter lives here (was: callers filtered ci.excluded in
|
|
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
|
// returns the real row so neither exists).
|
|
// A2c: position-first ordering; optional hide_finished — when the flag is
|
|
// set AND a viewer is supplied, items the viewer finished (percentage
|
|
// >= 0.999) drop out, turning the section into the reading queue head.
|
|
// viewer_user_id NULL = no progress filter (never-finished semantics for
|
|
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
|
// is a caller bug and shows everything).
|
|
func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard,
|
|
arg.ViewerUserID,
|
|
arg.CollectionID,
|
|
arg.LibraryID,
|
|
arg.HideFinished,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetCollectionItemsForDashboardRow{}
|
|
for rows.Next() {
|
|
var i GetCollectionItemsForDashboardRow
|
|
if err := rows.Scan(
|
|
&i.MediaItems.ID,
|
|
&i.MediaItems.LibraryID,
|
|
&i.MediaItems.Title,
|
|
&i.MediaItems.Subtitle,
|
|
&i.MediaItems.Author,
|
|
&i.MediaItems.Isbn,
|
|
&i.MediaItems.Description,
|
|
&i.MediaItems.FilePath,
|
|
&i.MediaItems.FileSize,
|
|
&i.MediaItems.MimeType,
|
|
&i.MediaItems.CoverImagePath,
|
|
&i.MediaItems.Series,
|
|
&i.MediaItems.SeriesNumber,
|
|
&i.MediaItems.Tags,
|
|
&i.MediaItems.Narrators,
|
|
&i.MediaItems.Authors,
|
|
&i.MediaItems.AuthorsSearch,
|
|
&i.MediaItems.Publishers,
|
|
&i.MediaItems.PublishersSearch,
|
|
&i.MediaItems.Abridged,
|
|
&i.MediaItems.Explicit,
|
|
&i.MediaItems.Asin,
|
|
&i.MediaItems.DatePublished,
|
|
&i.MediaItems.Publisher,
|
|
&i.MediaItems.Contributors,
|
|
&i.MediaItems.Language,
|
|
&i.MediaItems.Edition,
|
|
&i.MediaItems.PageCount,
|
|
&i.MediaItems.Genre,
|
|
&i.MediaItems.CopyrightYear,
|
|
&i.MediaItems.GoodreadsID,
|
|
&i.MediaItems.OpenlibraryID,
|
|
&i.MediaItems.GoogleBooksID,
|
|
&i.MediaItems.AddedByAdminID,
|
|
&i.MediaItems.CreatedAt,
|
|
&i.MediaItems.ImportedAt,
|
|
&i.MediaItems.UpdatedAt,
|
|
&i.MediaItems.FormatGroup,
|
|
&i.MediaItems.FormatMimetype,
|
|
&i.MediaItems.IsReflowable,
|
|
&i.MediaItems.HasFixedLayout,
|
|
&i.MediaItems.TotalCharacters,
|
|
&i.MediaItems.ChapterCount,
|
|
&i.MediaItems.EntitlementID,
|
|
&i.MediaItems.RevisionNumber,
|
|
&i.MediaItems.KoboContentID,
|
|
&i.MediaItems.KoboMetadata,
|
|
&i.MediaItems.MangaType,
|
|
&i.MediaItems.ReadingDirection,
|
|
&i.MediaItems.SeriesCount,
|
|
&i.MediaItems.Volume,
|
|
&i.MediaItems.Imprint,
|
|
&i.MediaItems.AgeRating,
|
|
&i.MediaItems.WebUrl,
|
|
&i.MediaItems.StoryArc,
|
|
&i.MediaItems.IsBlackAndWhite,
|
|
&i.MediaItems.MetadataNotes,
|
|
&i.MediaItems.CommunityRating,
|
|
&i.MediaItems.AlternateInfo,
|
|
&i.MediaItems.ScanInformation,
|
|
&i.MediaItems.Summary,
|
|
&i.MediaItems.MetadataOverrides,
|
|
&i.MediaItems.MissingScanCount,
|
|
&i.MediaItems.ArchivedAt,
|
|
&i.MediaItems.ChapterMetadata,
|
|
&i.MediaItems.LibraryTypeName,
|
|
&i.MediaItems.AudioMetadata,
|
|
&i.MediaItems.TagsSearch,
|
|
&i.MediaItems.ContributorsSearch,
|
|
&i.MediaItems.NarratorsSearch,
|
|
&i.MediaItems.FileSha256,
|
|
&i.MediaItems.OpfIdentifier,
|
|
&i.MediaItems.OpfUuid,
|
|
&i.MediaItems.HashConfidence,
|
|
&i.MediaItems.Source,
|
|
&i.MediaItems.SourceFeedID,
|
|
&i.MediaItems.EpisodeGuid,
|
|
&i.MediaItems.FeedPosition,
|
|
&i.MediaItems.SeasonNumber,
|
|
&i.MediaItems.EpisodeNumber,
|
|
&i.MediaItems.EnclosureUrl,
|
|
&i.MediaItems.DeclaredDurationSeconds,
|
|
&i.MediaItems.TranscriptUrl,
|
|
&i.MediaItems.ChaptersUrl,
|
|
&i.MediaItems.Hosts,
|
|
&i.MediaItems.Guests,
|
|
&i.MediaItems.DownloadState,
|
|
&i.MediaItems.DownloadError,
|
|
&i.MediaItems.DownloadAttempts,
|
|
&i.MediaItems.DownloadQueuePos,
|
|
&i.ViewerPercentage,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionUpNext = `-- name: GetCollectionUpNext :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
|
WHERE ci.collection_id = $2
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (ci.excluded IS NOT TRUE)
|
|
AND COALESCE(rp.percentage, 0) < 0.999
|
|
AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
LIMIT $4
|
|
`
|
|
|
|
type GetCollectionUpNextParams struct {
|
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetCollectionUpNextRow struct {
|
|
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
|
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
|
}
|
|
|
|
// A2c §3.4: the collection's reading queue — first N un-finished items in
|
|
// position order. Strict playlist semantics: finished items drop out as
|
|
// they complete, so the head IS "up next". Library visibility is enforced
|
|
// by the family-lens array param (never NULL for user-facing callers).
|
|
func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionUpNext,
|
|
arg.ViewerUserID,
|
|
arg.CollectionID,
|
|
arg.VisibleLibraryIds,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetCollectionUpNextRow{}
|
|
for rows.Next() {
|
|
var i GetCollectionUpNextRow
|
|
if err := rows.Scan(
|
|
&i.MediaItems.ID,
|
|
&i.MediaItems.LibraryID,
|
|
&i.MediaItems.Title,
|
|
&i.MediaItems.Subtitle,
|
|
&i.MediaItems.Author,
|
|
&i.MediaItems.Isbn,
|
|
&i.MediaItems.Description,
|
|
&i.MediaItems.FilePath,
|
|
&i.MediaItems.FileSize,
|
|
&i.MediaItems.MimeType,
|
|
&i.MediaItems.CoverImagePath,
|
|
&i.MediaItems.Series,
|
|
&i.MediaItems.SeriesNumber,
|
|
&i.MediaItems.Tags,
|
|
&i.MediaItems.Narrators,
|
|
&i.MediaItems.Authors,
|
|
&i.MediaItems.AuthorsSearch,
|
|
&i.MediaItems.Publishers,
|
|
&i.MediaItems.PublishersSearch,
|
|
&i.MediaItems.Abridged,
|
|
&i.MediaItems.Explicit,
|
|
&i.MediaItems.Asin,
|
|
&i.MediaItems.DatePublished,
|
|
&i.MediaItems.Publisher,
|
|
&i.MediaItems.Contributors,
|
|
&i.MediaItems.Language,
|
|
&i.MediaItems.Edition,
|
|
&i.MediaItems.PageCount,
|
|
&i.MediaItems.Genre,
|
|
&i.MediaItems.CopyrightYear,
|
|
&i.MediaItems.GoodreadsID,
|
|
&i.MediaItems.OpenlibraryID,
|
|
&i.MediaItems.GoogleBooksID,
|
|
&i.MediaItems.AddedByAdminID,
|
|
&i.MediaItems.CreatedAt,
|
|
&i.MediaItems.ImportedAt,
|
|
&i.MediaItems.UpdatedAt,
|
|
&i.MediaItems.FormatGroup,
|
|
&i.MediaItems.FormatMimetype,
|
|
&i.MediaItems.IsReflowable,
|
|
&i.MediaItems.HasFixedLayout,
|
|
&i.MediaItems.TotalCharacters,
|
|
&i.MediaItems.ChapterCount,
|
|
&i.MediaItems.EntitlementID,
|
|
&i.MediaItems.RevisionNumber,
|
|
&i.MediaItems.KoboContentID,
|
|
&i.MediaItems.KoboMetadata,
|
|
&i.MediaItems.MangaType,
|
|
&i.MediaItems.ReadingDirection,
|
|
&i.MediaItems.SeriesCount,
|
|
&i.MediaItems.Volume,
|
|
&i.MediaItems.Imprint,
|
|
&i.MediaItems.AgeRating,
|
|
&i.MediaItems.WebUrl,
|
|
&i.MediaItems.StoryArc,
|
|
&i.MediaItems.IsBlackAndWhite,
|
|
&i.MediaItems.MetadataNotes,
|
|
&i.MediaItems.CommunityRating,
|
|
&i.MediaItems.AlternateInfo,
|
|
&i.MediaItems.ScanInformation,
|
|
&i.MediaItems.Summary,
|
|
&i.MediaItems.MetadataOverrides,
|
|
&i.MediaItems.MissingScanCount,
|
|
&i.MediaItems.ArchivedAt,
|
|
&i.MediaItems.ChapterMetadata,
|
|
&i.MediaItems.LibraryTypeName,
|
|
&i.MediaItems.AudioMetadata,
|
|
&i.MediaItems.TagsSearch,
|
|
&i.MediaItems.ContributorsSearch,
|
|
&i.MediaItems.NarratorsSearch,
|
|
&i.MediaItems.FileSha256,
|
|
&i.MediaItems.OpfIdentifier,
|
|
&i.MediaItems.OpfUuid,
|
|
&i.MediaItems.HashConfidence,
|
|
&i.MediaItems.Source,
|
|
&i.MediaItems.SourceFeedID,
|
|
&i.MediaItems.EpisodeGuid,
|
|
&i.MediaItems.FeedPosition,
|
|
&i.MediaItems.SeasonNumber,
|
|
&i.MediaItems.EpisodeNumber,
|
|
&i.MediaItems.EnclosureUrl,
|
|
&i.MediaItems.DeclaredDurationSeconds,
|
|
&i.MediaItems.TranscriptUrl,
|
|
&i.MediaItems.ChaptersUrl,
|
|
&i.MediaItems.Hosts,
|
|
&i.MediaItems.Guests,
|
|
&i.MediaItems.DownloadState,
|
|
&i.MediaItems.DownloadError,
|
|
&i.MediaItems.DownloadAttempts,
|
|
&i.MediaItems.DownloadQueuePos,
|
|
&i.ViewerPercentage,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
|
SELECT
|
|
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
|
|
COUNT(ci.id) as book_count
|
|
FROM collections c
|
|
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE c.id = $1
|
|
GROUP BY c.id
|
|
`
|
|
|
|
type GetCollectionWithBookCountRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
|
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
|
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
|
BookCount int64 `db:"book_count" json:"book_count"`
|
|
}
|
|
|
|
// Get collection with book count
|
|
func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error) {
|
|
row := q.db.QueryRow(ctx, GetCollectionWithBookCount, id)
|
|
var i GetCollectionWithBookCountRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
&i.BookCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
|
|
`
|
|
|
|
// Get collections by user
|
|
func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) ([]Collections, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionsByUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Collections{}
|
|
for rows.Next() {
|
|
var i Collections
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
|
|
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
|
|
FROM collections c
|
|
JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE ci.media_item_id = $1
|
|
AND c.deleted_at IS NULL
|
|
`
|
|
|
|
// Get collections for book
|
|
func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error) {
|
|
rows, err := q.db.Query(ctx, GetCollectionsForBook, mediaItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Collections{}
|
|
for rows.Next() {
|
|
var i Collections
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetContinueReadingItems = `-- name: GetContinueReadingItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = $1
|
|
AND percentage > 0
|
|
AND percentage < 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT $3
|
|
`
|
|
|
|
type GetContinueReadingItemsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
// Smart section queries (for system collections)
|
|
func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetContinueReadingItems, arg.UserID, arg.LibraryID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetContinueSeriesItems = `-- name: GetContinueSeriesItems :many
|
|
WITH user_series_progress AS (
|
|
SELECT mi.series,
|
|
MAX(mi.series_number) as max_read_number,
|
|
MAX(rp.last_read_at) as last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = $2
|
|
AND rp.percentage > 0
|
|
AND mi.series IS NOT NULL AND mi.series != ''
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
GROUP BY mi.series
|
|
),
|
|
next_books AS (
|
|
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos,
|
|
usp.last_read_at
|
|
FROM media_items mi
|
|
JOIN user_series_progress usp ON mi.series = usp.series
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
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)
|
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
|
)
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos, last_read_at FROM next_books
|
|
ORDER BY last_read_at DESC NULLS LAST
|
|
LIMIT $1
|
|
`
|
|
|
|
type GetContinueSeriesItemsParams struct {
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
type GetContinueSeriesItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetContinueSeriesItems, arg.Limit, arg.UserID, arg.LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetContinueSeriesItemsRow{}
|
|
for rows.Next() {
|
|
var i GetContinueSeriesItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&i.LastReadAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetDashboardPreferences = `-- name: GetDashboardPreferences :one
|
|
|
|
SELECT id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at FROM user_dashboard_preferences
|
|
WHERE user_id = $1 AND library_id = $2::uuid
|
|
`
|
|
|
|
type GetDashboardPreferencesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
// ============================================
|
|
// CAROUSEL-STYLE DASHBOARD
|
|
// ============================================
|
|
// Dashboard preferences queries
|
|
// The handler routes by scope: a Valid library_id targets the per-library
|
|
// table below; an absent one targets user_dashboard_preferences_global
|
|
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
|
// was removed because it returned an arbitrary library's row.
|
|
func (q *Queries) GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error) {
|
|
row := q.db.QueryRow(ctx, GetDashboardPreferences, arg.UserID, arg.LibraryID)
|
|
var i UserDashboardPreferences
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.LibraryID,
|
|
&i.HiddenCollections,
|
|
&i.CollectionOrder,
|
|
&i.ItemsPerSection,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDevice = `-- name: GetDevice :one
|
|
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, GetDevice, id)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceByAuthToken = `-- name: GetDeviceByAuthToken :one
|
|
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE auth_token = $1
|
|
`
|
|
|
|
func (q *Queries) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceByAuthToken, authToken)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceByIdentifier = `-- name: GetDeviceByIdentifier :one
|
|
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_identifier = $1
|
|
`
|
|
|
|
func (q *Queries) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceByIdentifier, deviceIdentifier)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceCatalogByBookhoardUUID = `-- name: GetDeviceCatalogByBookhoardUUID :one
|
|
SELECT id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method FROM device_catalogs WHERE device_id = $1 AND bookhoard_uuid = $2
|
|
`
|
|
|
|
type GetDeviceCatalogByBookhoardUUIDParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
|
|
}
|
|
|
|
// Get device catalog by Bookhoard UUID
|
|
func (q *Queries) GetDeviceCatalogByBookhoardUUID(ctx context.Context, arg GetDeviceCatalogByBookhoardUUIDParams) (DeviceCatalogs, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceCatalogByBookhoardUUID, arg.DeviceID, arg.BookhoardUuid)
|
|
var i DeviceCatalogs
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.BookhoardUuid,
|
|
&i.KoboContentID,
|
|
&i.ContentIDType,
|
|
&i.Available,
|
|
&i.DeliveryDate,
|
|
&i.DeliveryMethod,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceCatalogByKoboContentId = `-- name: GetDeviceCatalogByKoboContentId :one
|
|
SELECT id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method FROM device_catalogs WHERE kobo_content_id = $1
|
|
`
|
|
|
|
// Get device catalog by Kobo ContentId
|
|
func (q *Queries) GetDeviceCatalogByKoboContentId(ctx context.Context, koboContentID string) (DeviceCatalogs, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceCatalogByKoboContentId, koboContentID)
|
|
var i DeviceCatalogs
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.BookhoardUuid,
|
|
&i.KoboContentID,
|
|
&i.ContentIDType,
|
|
&i.Available,
|
|
&i.DeliveryDate,
|
|
&i.DeliveryMethod,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceCatalogEntries = `-- name: GetDeviceCatalogEntries :many
|
|
SELECT dc.id, dc.device_id, dc.media_item_id, dc.bookhoard_uuid, dc.kobo_content_id, dc.content_id_type, dc.available, dc.delivery_date, dc.delivery_method, mi.title, mi.author
|
|
FROM device_catalogs dc
|
|
JOIN media_items mi ON dc.media_item_id = mi.id
|
|
WHERE dc.device_id = $1 AND dc.available = true
|
|
ORDER BY dc.delivery_date DESC
|
|
`
|
|
|
|
type GetDeviceCatalogEntriesRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
|
|
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
|
|
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
|
|
Available pgtype.Bool `db:"available" json:"available"`
|
|
DeliveryDate pgtype.Timestamptz `db:"delivery_date" json:"delivery_date"`
|
|
DeliveryMethod pgtype.Text `db:"delivery_method" json:"delivery_method"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
// Get device catalog entries
|
|
func (q *Queries) GetDeviceCatalogEntries(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceCatalogEntriesRow, error) {
|
|
rows, err := q.db.Query(ctx, GetDeviceCatalogEntries, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetDeviceCatalogEntriesRow{}
|
|
for rows.Next() {
|
|
var i GetDeviceCatalogEntriesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.BookhoardUuid,
|
|
&i.KoboContentID,
|
|
&i.ContentIDType,
|
|
&i.Available,
|
|
&i.DeliveryDate,
|
|
&i.DeliveryMethod,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetDeviceFileAlias = `-- name: GetDeviceFileAlias :one
|
|
SELECT id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at FROM device_file_aliases WHERE device_id = $1 AND file_path = $2
|
|
`
|
|
|
|
type GetDeviceFileAliasParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
// Get device file alias
|
|
func (q *Queries) GetDeviceFileAlias(ctx context.Context, arg GetDeviceFileAliasParams) (DeviceFileAliases, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceFileAlias, arg.DeviceID, arg.FilePath)
|
|
var i DeviceFileAliases
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.ConfidenceScore,
|
|
&i.LastSeenAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceFileAliasBySHA256 = `-- name: GetDeviceFileAliasBySHA256 :one
|
|
SELECT dfa.id, dfa.media_item_id, dfa.device_id, dfa.file_path, dfa.file_sha256, dfa.confidence_score, dfa.last_seen_at, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.file_sha256 = $1
|
|
`
|
|
|
|
type GetDeviceFileAliasBySHA256Row struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
// Get device file alias by SHA-256
|
|
func (q *Queries) GetDeviceFileAliasBySHA256(ctx context.Context, fileSha256 pgtype.Text) (GetDeviceFileAliasBySHA256Row, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceFileAliasBySHA256, fileSha256)
|
|
var i GetDeviceFileAliasBySHA256Row
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.ConfidenceScore,
|
|
&i.LastSeenAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceFileAliasesByDevice = `-- name: GetDeviceFileAliasesByDevice :many
|
|
SELECT dfa.id, dfa.media_item_id, dfa.device_id, dfa.file_path, dfa.file_sha256, dfa.confidence_score, dfa.last_seen_at, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.device_id = $1
|
|
ORDER BY dfa.last_seen_at DESC
|
|
`
|
|
|
|
type GetDeviceFileAliasesByDeviceRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
// Get device file aliases by device
|
|
func (q *Queries) GetDeviceFileAliasesByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceFileAliasesByDeviceRow, error) {
|
|
rows, err := q.db.Query(ctx, GetDeviceFileAliasesByDevice, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetDeviceFileAliasesByDeviceRow{}
|
|
for rows.Next() {
|
|
var i GetDeviceFileAliasesByDeviceRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.ConfidenceScore,
|
|
&i.LastSeenAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetDeviceShelfMapping = `-- name: GetDeviceShelfMapping :one
|
|
SELECT id, collection_id, device_id, device_shelf_name, sync_direction, created_at FROM device_shelf_mappings WHERE device_id = $1 AND collection_id = $2
|
|
`
|
|
|
|
type GetDeviceShelfMappingParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
}
|
|
|
|
// Get device shelf mapping
|
|
func (q *Queries) GetDeviceShelfMapping(ctx context.Context, arg GetDeviceShelfMappingParams) (DeviceShelfMappings, error) {
|
|
row := q.db.QueryRow(ctx, GetDeviceShelfMapping, arg.DeviceID, arg.CollectionID)
|
|
var i DeviceShelfMappings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.DeviceID,
|
|
&i.DeviceShelfName,
|
|
&i.SyncDirection,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDeviceShelfMappings = `-- name: GetDeviceShelfMappings :many
|
|
SELECT dsm.id, dsm.collection_id, dsm.device_id, dsm.device_shelf_name, dsm.sync_direction, dsm.created_at, c.name as collection_name, c.icon as collection_icon
|
|
FROM device_shelf_mappings dsm
|
|
JOIN collections c ON dsm.collection_id = c.id
|
|
WHERE dsm.device_id = $1
|
|
`
|
|
|
|
type GetDeviceShelfMappingsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"`
|
|
SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
CollectionName string `db:"collection_name" json:"collection_name"`
|
|
CollectionIcon pgtype.Text `db:"collection_icon" json:"collection_icon"`
|
|
}
|
|
|
|
// Get device shelf mappings
|
|
func (q *Queries) GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetDeviceShelfMappings, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetDeviceShelfMappingsRow{}
|
|
for rows.Next() {
|
|
var i GetDeviceShelfMappingsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.DeviceID,
|
|
&i.DeviceShelfName,
|
|
&i.SyncDirection,
|
|
&i.CreatedAt,
|
|
&i.CollectionName,
|
|
&i.CollectionIcon,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetDictionaryEntry = `-- name: GetDictionaryEntry :one
|
|
SELECT id, word, definition, part_of_speech, example, etymology, created_at, accessed_at FROM dictionary_cache
|
|
WHERE word = $1
|
|
`
|
|
|
|
func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error) {
|
|
row := q.db.QueryRow(ctx, GetDictionaryEntry, word)
|
|
var i DictionaryCache
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Word,
|
|
&i.Definition,
|
|
&i.PartOfSpeech,
|
|
&i.Example,
|
|
&i.Etymology,
|
|
&i.CreatedAt,
|
|
&i.AccessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
|
SELECT library_id, series, COUNT(*) as book_count,
|
|
MAX(series_count) as total_in_series,
|
|
MAX(created_at) as last_entry_at,
|
|
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
|
MAX(rp.last_read_at) AS last_read_at
|
|
FROM media_items
|
|
LEFT JOIN reading_progress rp
|
|
ON rp.media_item_id = media_items.id
|
|
AND rp.user_id = $1::uuid
|
|
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
|
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
|
AND series IS NOT NULL AND series != ''
|
|
-- Multi-field search (series page): the query matches the series
|
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
|
-- a WHERE filter: a group qualifies when ANY of its rows matches,
|
|
-- so COUNT(*) keeps reporting the whole series.
|
|
AND ($4::text IS NULL OR
|
|
series ILIKE '%' || $4::text || '%'
|
|
OR author ILIKE '%' || $4::text || '%'
|
|
OR EXISTS (
|
|
SELECT 1 FROM media_items sib
|
|
WHERE sib.library_id = media_items.library_id
|
|
AND sib.series = media_items.series
|
|
AND sib.title ILIKE '%' || $4::text || '%'
|
|
))
|
|
GROUP BY library_id, series
|
|
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
|
AND (
|
|
$6::text IS NULL
|
|
OR CASE $6::text
|
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
|
ELSE true
|
|
END
|
|
)
|
|
ORDER BY
|
|
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
|
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
|
|
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
|
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
|
CASE WHEN $7::text = 'progress_desc'
|
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
|
END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'progress_asc'
|
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
|
END ASC NULLS LAST,
|
|
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
|
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
|
library_id, series
|
|
LIMIT $9 OFFSET $8
|
|
`
|
|
|
|
type GetDistinctSeriesParams struct {
|
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
|
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
|
Status pgtype.Text `db:"status" json:"status"`
|
|
Sort pgtype.Text `db:"sort" json:"sort"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetDistinctSeriesRow struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Series pgtype.Text `db:"series" json:"series"`
|
|
BookCount int64 `db:"book_count" json:"book_count"`
|
|
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
|
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
|
|
FinishedCount int64 `db:"finished_count" json:"finished_count"`
|
|
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
// Series are grouped PER LIBRARY: the same series name in two libraries
|
|
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
|
// filter enforces per-user library visibility at the source.
|
|
//
|
|
// Series-page sort/filter contract (extensible registry in
|
|
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
|
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
|
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
|
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
|
// join can't multiply rows: reading_progress is UNIQUE per
|
|
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
|
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
|
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
|
// transaction share a single NOW() timestamp, leaving many groups tied
|
|
// on MAX(created_at), and Postgres does not guarantee tie order between
|
|
// executions, so page boundaries could repeat or drop series.
|
|
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
|
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
|
arg.ViewerUserID,
|
|
arg.LibraryID,
|
|
arg.VisibleLibraryIds,
|
|
arg.SeriesQuery,
|
|
arg.AuthorQuery,
|
|
arg.Status,
|
|
arg.Sort,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetDistinctSeriesRow{}
|
|
for rows.Next() {
|
|
var i GetDistinctSeriesRow
|
|
if err := rows.Scan(
|
|
&i.LibraryID,
|
|
&i.Series,
|
|
&i.BookCount,
|
|
&i.TotalInSeries,
|
|
&i.LastEntryAt,
|
|
&i.FinishedCount,
|
|
&i.LastReadAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
|
SELECT COUNT(*)::int FROM (
|
|
SELECT library_id, series
|
|
FROM media_items
|
|
LEFT JOIN reading_progress rp
|
|
ON rp.media_item_id = media_items.id
|
|
AND rp.user_id = $1::uuid
|
|
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
|
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
|
AND series IS NOT NULL AND series != ''
|
|
AND ($4::text IS NULL OR
|
|
series ILIKE '%' || $4::text || '%'
|
|
OR author ILIKE '%' || $4::text || '%'
|
|
OR EXISTS (
|
|
SELECT 1 FROM media_items sib
|
|
WHERE sib.library_id = media_items.library_id
|
|
AND sib.series = media_items.series
|
|
AND sib.title ILIKE '%' || $4::text || '%'
|
|
))
|
|
GROUP BY library_id, series
|
|
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
|
AND (
|
|
$6::text IS NULL
|
|
OR CASE $6::text
|
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
|
ELSE true
|
|
END
|
|
)
|
|
) groups
|
|
`
|
|
|
|
type GetDistinctSeriesCountParams struct {
|
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
|
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
|
Status pgtype.Text `db:"status" json:"status"`
|
|
}
|
|
|
|
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
|
// HAVING so the pagination total matches what the page query returns
|
|
// under the same filters (Sort is a page-ORDER concern, not a set
|
|
// membership one, so it is absent here by design).
|
|
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
|
|
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
|
|
arg.ViewerUserID,
|
|
arg.LibraryID,
|
|
arg.VisibleLibraryIds,
|
|
arg.SeriesQuery,
|
|
arg.AuthorQuery,
|
|
arg.Status,
|
|
)
|
|
var column_1 int32
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const GetFailedSyncQueueItems = `-- name: GetFailedSyncQueueItems :many
|
|
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
|
|
WHERE status = 'failed' AND attempts < max_attempts
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT $1
|
|
`
|
|
|
|
func (q *Queries) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) {
|
|
rows, err := q.db.Query(ctx, GetFailedSyncQueueItems, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SyncQueue{}
|
|
for rows.Next() {
|
|
var i SyncQueue
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetFirstAdmin = `-- name: GetFirstAdmin :one
|
|
SELECT id FROM users
|
|
WHERE role = 'admin'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
`
|
|
|
|
func (q *Queries) GetFirstAdmin(ctx context.Context) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, GetFirstAdmin)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const GetFirstAdminExclude = `-- name: GetFirstAdminExclude :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
|
|
WHERE role = 'admin' AND id != $1
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetFirstAdminExcludeRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error) {
|
|
row := q.db.QueryRow(ctx, GetFirstAdminExclude, id)
|
|
var i GetFirstAdminExcludeRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.MaxDevices,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetGlobalDashboardPreferences = `-- name: GetGlobalDashboardPreferences :one
|
|
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
// Global (all-libraries) scope: exactly one row per user.
|
|
func (q *Queries) GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error) {
|
|
row := q.db.QueryRow(ctx, GetGlobalDashboardPreferences, userID)
|
|
var i UserDashboardPreferencesGlobal
|
|
err := row.Scan(
|
|
&i.UserID,
|
|
&i.HiddenCollections,
|
|
&i.CollectionOrder,
|
|
&i.ItemsPerSection,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetHashConflict = `-- name: GetHashConflict :one
|
|
SELECT id, library_id, file_sha256, status, resolution, resolved_by, created_at, resolved_at FROM hash_conflicts WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error) {
|
|
row := q.db.QueryRow(ctx, GetHashConflict, id)
|
|
var i HashConflicts
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FileSha256,
|
|
&i.Status,
|
|
&i.Resolution,
|
|
&i.ResolvedBy,
|
|
&i.CreatedAt,
|
|
&i.ResolvedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetKoboEntitlementByContentId = `-- name: GetKoboEntitlementByContentId :one
|
|
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.content_id = $2
|
|
`
|
|
|
|
type GetKoboEntitlementByContentIdParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
}
|
|
|
|
type GetKoboEntitlementByContentIdRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
PurchaseDate pgtype.Timestamptz `db:"purchase_date" json:"purchase_date"`
|
|
AccessionDate pgtype.Timestamptz `db:"accession_date" json:"accession_date"`
|
|
BookStatus pgtype.Text `db:"book_status" json:"book_status"`
|
|
SyncStatus pgtype.Text `db:"sync_status" json:"sync_status"`
|
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
func (q *Queries) GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error) {
|
|
row := q.db.QueryRow(ctx, GetKoboEntitlementByContentId, arg.DeviceID, arg.ContentID)
|
|
var i GetKoboEntitlementByContentIdRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.EntitlementID,
|
|
&i.ContentID,
|
|
&i.RevisionNumber,
|
|
&i.PurchaseDate,
|
|
&i.AccessionDate,
|
|
&i.BookStatus,
|
|
&i.SyncStatus,
|
|
&i.KoboMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetKoboEntitlementByEntitlementId = `-- name: GetKoboEntitlementByEntitlementId :one
|
|
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.entitlement_id = $2
|
|
`
|
|
|
|
type GetKoboEntitlementByEntitlementIdParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
}
|
|
|
|
type GetKoboEntitlementByEntitlementIdRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
PurchaseDate pgtype.Timestamptz `db:"purchase_date" json:"purchase_date"`
|
|
AccessionDate pgtype.Timestamptz `db:"accession_date" json:"accession_date"`
|
|
BookStatus pgtype.Text `db:"book_status" json:"book_status"`
|
|
SyncStatus pgtype.Text `db:"sync_status" json:"sync_status"`
|
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
func (q *Queries) GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error) {
|
|
row := q.db.QueryRow(ctx, GetKoboEntitlementByEntitlementId, arg.DeviceID, arg.EntitlementID)
|
|
var i GetKoboEntitlementByEntitlementIdRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.EntitlementID,
|
|
&i.ContentID,
|
|
&i.RevisionNumber,
|
|
&i.PurchaseDate,
|
|
&i.AccessionDate,
|
|
&i.BookStatus,
|
|
&i.SyncStatus,
|
|
&i.KoboMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetKoboEntitlementsForDevice = `-- name: GetKoboEntitlementsForDevice :many
|
|
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1
|
|
ORDER BY ke.accession_date DESC
|
|
`
|
|
|
|
type GetKoboEntitlementsForDeviceRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
PurchaseDate pgtype.Timestamptz `db:"purchase_date" json:"purchase_date"`
|
|
AccessionDate pgtype.Timestamptz `db:"accession_date" json:"accession_date"`
|
|
BookStatus pgtype.Text `db:"book_status" json:"book_status"`
|
|
SyncStatus pgtype.Text `db:"sync_status" json:"sync_status"`
|
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
func (q *Queries) GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error) {
|
|
rows, err := q.db.Query(ctx, GetKoboEntitlementsForDevice, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetKoboEntitlementsForDeviceRow{}
|
|
for rows.Next() {
|
|
var i GetKoboEntitlementsForDeviceRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.EntitlementID,
|
|
&i.ContentID,
|
|
&i.RevisionNumber,
|
|
&i.PurchaseDate,
|
|
&i.AccessionDate,
|
|
&i.BookStatus,
|
|
&i.SyncStatus,
|
|
&i.KoboMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetKoboShelfBookCount = `-- name: GetKoboShelfBookCount :one
|
|
SELECT COUNT(*) as book_count
|
|
FROM kobo_shelves
|
|
WHERE device_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetKoboShelfBookCount(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, GetKoboShelfBookCount, deviceID)
|
|
var book_count int64
|
|
err := row.Scan(&book_count)
|
|
return book_count, err
|
|
}
|
|
|
|
const GetKoboShelfBooks = `-- name: GetKoboShelfBooks :many
|
|
SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC
|
|
`
|
|
|
|
type GetKoboShelfBooksRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"`
|
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
|
LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
|
|
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
}
|
|
|
|
func (q *Queries) GetKoboShelfBooks(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboShelfBooksRow, error) {
|
|
rows, err := q.db.Query(ctx, GetKoboShelfBooks, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetKoboShelfBooksRow{}
|
|
for rows.Next() {
|
|
var i GetKoboShelfBooksRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.ShelfName,
|
|
&i.ShelfPosition,
|
|
&i.AddedAt,
|
|
&i.LastSyncedAt,
|
|
&i.CollectionID,
|
|
&i.PositionInCollection,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
&i.MimeType,
|
|
&i.EntitlementID,
|
|
&i.KoboContentID,
|
|
&i.RevisionNumber,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetKoboShelfBooksByShelfName = `-- name: GetKoboShelfBooksByShelfName :many
|
|
SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.shelf_name = $2
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC
|
|
`
|
|
|
|
type GetKoboShelfBooksByShelfNameParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
}
|
|
|
|
type GetKoboShelfBooksByShelfNameRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"`
|
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
|
LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
|
|
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
}
|
|
|
|
func (q *Queries) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) {
|
|
rows, err := q.db.Query(ctx, GetKoboShelfBooksByShelfName, arg.DeviceID, arg.ShelfName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetKoboShelfBooksByShelfNameRow{}
|
|
for rows.Next() {
|
|
var i GetKoboShelfBooksByShelfNameRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.ShelfName,
|
|
&i.ShelfPosition,
|
|
&i.AddedAt,
|
|
&i.LastSyncedAt,
|
|
&i.CollectionID,
|
|
&i.PositionInCollection,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
&i.MimeType,
|
|
&i.EntitlementID,
|
|
&i.KoboContentID,
|
|
&i.RevisionNumber,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetKoboShelvesByCollection = `-- name: GetKoboShelvesByCollection :many
|
|
SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, mi.title, mi.author
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.collection_id = $2
|
|
ORDER BY ks.position_in_collection ASC, ks.shelf_position ASC
|
|
`
|
|
|
|
type GetKoboShelvesByCollectionParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
}
|
|
|
|
type GetKoboShelvesByCollectionRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"`
|
|
ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"`
|
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
|
LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
// Get Kobo shelves by collection
|
|
func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) {
|
|
rows, err := q.db.Query(ctx, GetKoboShelvesByCollection, arg.DeviceID, arg.CollectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetKoboShelvesByCollectionRow{}
|
|
for rows.Next() {
|
|
var i GetKoboShelvesByCollectionRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.ShelfName,
|
|
&i.ShelfPosition,
|
|
&i.AddedAt,
|
|
&i.LastSyncedAt,
|
|
&i.CollectionID,
|
|
&i.PositionInCollection,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetKoreaderStatWatermark = `-- name: GetKoreaderStatWatermark :one
|
|
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
|
|
FROM reading_history
|
|
WHERE user_id = $1
|
|
AND media_item_id = $2
|
|
AND device_metadata->>'source' = 'koreader'
|
|
`
|
|
|
|
type GetKoreaderStatWatermarkParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
// KOReader stats conversion: the device pushes its CUMULATIVE total reading
|
|
// seconds for a book; the watermark is the highest total already converted,
|
|
// read from the koreader sessions' own device_metadata. Delta-only insertion
|
|
// means a reinstall of the device cannot double-count, and sub-threshold
|
|
// deltas are never lost (they stay folded into the next push's delta).
|
|
func (q *Queries) GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error) {
|
|
row := q.db.QueryRow(ctx, GetKoreaderStatWatermark, arg.UserID, arg.MediaItemID)
|
|
var total_seconds interface{}
|
|
err := row.Scan(&total_seconds)
|
|
return total_seconds, err
|
|
}
|
|
|
|
const GetLibrary = `-- name: GetLibrary :one
|
|
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1
|
|
`
|
|
|
|
type GetLibraryRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
TypeName string `db:"type_name" json:"type_name"`
|
|
TypeDescription pgtype.Text `db:"type_description" json:"type_description"`
|
|
}
|
|
|
|
func (q *Queries) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error) {
|
|
row := q.db.QueryRow(ctx, GetLibrary, id)
|
|
var i GetLibraryRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
&i.TypeName,
|
|
&i.TypeDescription,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryByFolder = `-- name: GetLibraryByFolder :one
|
|
SELECT lf.library_id, l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE lf.folder_path = $1
|
|
`
|
|
|
|
type GetLibraryByFolderRow struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryByFolder, folderPath)
|
|
var i GetLibraryByFolderRow
|
|
err := row.Scan(
|
|
&i.LibraryID,
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryByFolderPathPrefix = `-- name: GetLibraryByFolderPathPrefix :one
|
|
SELECT lf.library_id, lf.folder_path, l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE $1 LIKE lf.folder_path || '%'
|
|
ORDER BY LENGTH(lf.folder_path) DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetLibraryByFolderPathPrefixRow struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryByFolderPathPrefix, folderPath)
|
|
var i GetLibraryByFolderPathPrefixRow
|
|
err := row.Scan(
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryFolders = `-- name: GetLibraryFolders :many
|
|
SELECT id, library_id, folder_path, created_at, purpose FROM library_folders WHERE library_id = $1 ORDER BY created_at
|
|
`
|
|
|
|
func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryFolders, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LibraryFolders{}
|
|
for rows.Next() {
|
|
var i LibraryFolders
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.CreatedAt,
|
|
&i.Purpose,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryItems = `-- name: GetLibraryItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: collections picker)
|
|
ORDER BY mi.created_at DESC
|
|
`
|
|
|
|
func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryItems, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryType = `-- name: GetLibraryType :one
|
|
SELECT id, name, description, allowed_extensions, created_at FROM library_types WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryType, id)
|
|
var i LibraryTypes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.AllowedExtensions,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryTypeByName = `-- name: GetLibraryTypeByName :one
|
|
SELECT id, name, description, allowed_extensions, created_at FROM library_types WHERE name = $1
|
|
`
|
|
|
|
func (q *Queries) GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryTypeByName, name)
|
|
var i LibraryTypes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.AllowedExtensions,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryTypes = `-- name: GetLibraryTypes :many
|
|
SELECT id, name, description, allowed_extensions, created_at FROM library_types ORDER BY name
|
|
`
|
|
|
|
// Library Types queries
|
|
func (q *Queries) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryTypes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LibraryTypes{}
|
|
for rows.Next() {
|
|
var i LibraryTypes
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.AllowedExtensions,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryUsageAuthors = `-- name: GetLibraryUsageAuthors :many
|
|
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
|
|
FROM media_items mi
|
|
WHERE mi.author IS NOT NULL AND mi.author <> ''
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
GROUP BY mi.author
|
|
ORDER BY row_count DESC, name ASC
|
|
LIMIT 10
|
|
`
|
|
|
|
type GetLibraryUsageAuthorsParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
type GetLibraryUsageAuthorsRow struct {
|
|
Name string `db:"name" json:"name"`
|
|
RowCount int32 `db:"row_count" json:"row_count"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryUsageAuthors, arg.Column1, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetLibraryUsageAuthorsRow{}
|
|
for rows.Next() {
|
|
var i GetLibraryUsageAuthorsRow
|
|
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryUsageCards = `-- name: GetLibraryUsageCards :one
|
|
|
|
WITH usage_scope AS (
|
|
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
)
|
|
SELECT
|
|
(SELECT COUNT(*) FROM usage_scope)::int AS items,
|
|
(SELECT COUNT(DISTINCT author) FROM usage_scope
|
|
WHERE author IS NOT NULL AND author <> '')::int AS authors,
|
|
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
|
|
WHERE n <> '')::int AS narrators,
|
|
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
|
|
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
|
|
(SELECT COALESCE(
|
|
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
|
|
)::bigint FROM usage_scope s
|
|
WHERE s.audio_metadata IS NOT NULL
|
|
AND s.audio_metadata ? 'duration_seconds') AS total_seconds
|
|
`
|
|
|
|
type GetLibraryUsageCardsParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
type GetLibraryUsageCardsRow struct {
|
|
Items int32 `db:"items" json:"items"`
|
|
Authors int32 `db:"authors" json:"authors"`
|
|
Narrators int32 `db:"narrators" json:"narrators"`
|
|
TotalSizeBytes int64 `db:"total_size_bytes" json:"total_size_bytes"`
|
|
TotalPages int64 `db:"total_pages" json:"total_pages"`
|
|
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
|
|
// LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
|
|
// the library, not what was read. Every query carries BOTH filters:
|
|
//
|
|
// $1 = single-library scope (NULL = all visible libraries — "All")
|
|
// $2 = the requester's visible-library id set (NULL = no filter; the
|
|
// default-ALLOW family lens). A non-visible $1 is REJECTED at the
|
|
// service/handler layer with 403 — the SQL filter is defense only.
|
|
// + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
|
|
// archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
|
|
//
|
|
// ============================================================================
|
|
func (q *Queries) GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryUsageCards, arg.Column1, arg.Column2)
|
|
var i GetLibraryUsageCardsRow
|
|
err := row.Scan(
|
|
&i.Items,
|
|
&i.Authors,
|
|
&i.Narrators,
|
|
&i.TotalSizeBytes,
|
|
&i.TotalPages,
|
|
&i.TotalSeconds,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryUsageGenres = `-- name: GetLibraryUsageGenres :many
|
|
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
|
|
FROM media_items mi
|
|
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
GROUP BY mi.genre
|
|
ORDER BY row_count DESC, name ASC
|
|
LIMIT 5
|
|
`
|
|
|
|
type GetLibraryUsageGenresParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
type GetLibraryUsageGenresRow struct {
|
|
Name string `db:"name" json:"name"`
|
|
RowCount int32 `db:"row_count" json:"row_count"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryUsageGenres, arg.Column1, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetLibraryUsageGenresRow{}
|
|
for rows.Next() {
|
|
var i GetLibraryUsageGenresRow
|
|
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryUsageLargestItems = `-- name: GetLibraryUsageLargestItems :many
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
COALESCE(mi.author, '') AS author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND COALESCE(mi.file_size, 0) > 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
ORDER BY mi.file_size DESC, mi.title ASC
|
|
LIMIT 5
|
|
`
|
|
|
|
type GetLibraryUsageLargestItemsParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
type GetLibraryUsageLargestItemsRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author string `db:"author" json:"author"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryUsageLargestItems, arg.Column1, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetLibraryUsageLargestItemsRow{}
|
|
for rows.Next() {
|
|
var i GetLibraryUsageLargestItemsRow
|
|
if err := rows.Scan(
|
|
&i.MediaItemID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.LibraryID,
|
|
&i.CoverImagePath,
|
|
&i.FileSizeBytes,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryUsageLongestItems = `-- name: GetLibraryUsageLongestItems :many
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
COALESCE(mi.author, '') AS author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
|
|
COALESCE(mi.page_count, 0)::int AS page_count,
|
|
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
AND (CASE WHEN $3::bool
|
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
|
|
ELSE COALESCE(mi.page_count, 0) > 0
|
|
END)
|
|
ORDER BY (CASE WHEN $3::bool
|
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
|
|
ELSE COALESCE(mi.page_count, 0)::float8
|
|
END) DESC, mi.title ASC
|
|
LIMIT 5
|
|
`
|
|
|
|
type GetLibraryUsageLongestItemsParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
Column3 bool `db:"column_3" json:"column_3"`
|
|
}
|
|
|
|
type GetLibraryUsageLongestItemsRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author string `db:"author" json:"author"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
|
|
PageCount int32 `db:"page_count" json:"page_count"`
|
|
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
|
|
}
|
|
|
|
// The Longest Items panel, one query both modes: $3 = true ranks by
|
|
// AUDIO duration (audio present in scope), false by page_count (text).
|
|
func (q *Queries) GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetLibraryUsageLongestItems, arg.Column1, arg.Column2, arg.Column3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetLibraryUsageLongestItemsRow{}
|
|
for rows.Next() {
|
|
var i GetLibraryUsageLongestItemsRow
|
|
if err := rows.Scan(
|
|
&i.MediaItemID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.LibraryID,
|
|
&i.CoverImagePath,
|
|
&i.FileSizeBytes,
|
|
&i.PageCount,
|
|
&i.TotalSeconds,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetLibraryVisibility = `-- name: GetLibraryVisibility :one
|
|
SELECT id, user_id, library_id, is_visible, created_at, updated_at FROM library_visibility WHERE user_id = $1 AND library_id = $2
|
|
`
|
|
|
|
type GetLibraryVisibilityParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
func (q *Queries) GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryVisibility, arg.UserID, arg.LibraryID)
|
|
var i LibraryVisibility
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.LibraryID,
|
|
&i.IsVisible,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetLibraryWithType = `-- name: GetLibraryWithType :one
|
|
|
|
SELECT
|
|
l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds,
|
|
lt.name as type_name,
|
|
lt.description as type_description,
|
|
lt.allowed_extensions
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1
|
|
`
|
|
|
|
type GetLibraryWithTypeRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
TypeName string `db:"type_name" json:"type_name"`
|
|
TypeDescription pgtype.Text `db:"type_description" json:"type_description"`
|
|
AllowedExtensions []string `db:"allowed_extensions" json:"allowed_extensions"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// LIBRARY WITH TYPE INFO QUERIES
|
|
// ============================================================================
|
|
func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error) {
|
|
row := q.db.QueryRow(ctx, GetLibraryWithType, id)
|
|
var i GetLibraryWithTypeRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
&i.TypeName,
|
|
&i.TypeDescription,
|
|
&i.AllowedExtensions,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaBookmark = `-- name: GetMediaBookmark :one
|
|
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaBookmark, id)
|
|
var i MediaBookmarks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.PageNumber,
|
|
&i.ChapterNumber,
|
|
&i.CfiPosition,
|
|
&i.Title,
|
|
&i.Position,
|
|
&i.Notes,
|
|
&i.CreatedAt,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.DeviceSyncData,
|
|
&i.PercentageLocation,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
&i.OriginSource,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one
|
|
|
|
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetMediaBookmarkByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
// ============================================
|
|
// ANNOTATION SYNC QUERIES (bookmarks)
|
|
// ============================================
|
|
func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
var i MediaBookmarks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.PageNumber,
|
|
&i.ChapterNumber,
|
|
&i.CfiPosition,
|
|
&i.Title,
|
|
&i.Position,
|
|
&i.Notes,
|
|
&i.CreatedAt,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.DeviceSyncData,
|
|
&i.PercentageLocation,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
&i.OriginSource,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaBookmarks = `-- name: GetMediaBookmarks :many
|
|
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
|
|
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetMediaBookmarksParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaBookmarks, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaBookmarks{}
|
|
for rows.Next() {
|
|
var i MediaBookmarks
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.PageNumber,
|
|
&i.ChapterNumber,
|
|
&i.CfiPosition,
|
|
&i.Title,
|
|
&i.Position,
|
|
&i.Notes,
|
|
&i.CreatedAt,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.DeviceSyncData,
|
|
&i.PercentageLocation,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
&i.OriginSource,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetMediaHighlight = `-- name: GetMediaHighlight :one
|
|
SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE id = $1
|
|
`
|
|
|
|
// Media Highlights queries
|
|
func (q *Queries) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaHighlight, id)
|
|
var i MediaHighlights
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.NoteID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.ChapterReference,
|
|
&i.ParagraphStart,
|
|
&i.ParagraphEnd,
|
|
&i.PanelNumber,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.NoteText,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaHighlightByDedupKey = `-- name: GetMediaHighlightByDedupKey :one
|
|
|
|
SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetMediaHighlightByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
// ============================================
|
|
// ANNOTATION SYNC QUERIES (highlights)
|
|
// ============================================
|
|
func (q *Queries) GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
var i MediaHighlights
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.NoteID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.ChapterReference,
|
|
&i.ParagraphStart,
|
|
&i.ParagraphEnd,
|
|
&i.PanelNumber,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.NoteText,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaHighlights = `-- name: GetMediaHighlights :many
|
|
SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetMediaHighlightsParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaHighlights, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaHighlights{}
|
|
for rows.Next() {
|
|
var i MediaHighlights
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.NoteID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.ChapterReference,
|
|
&i.ParagraphStart,
|
|
&i.ParagraphEnd,
|
|
&i.PanelNumber,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.NoteText,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetMediaItem = `-- name: GetMediaItem :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItem, id)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1 AND library_id = $2
|
|
`
|
|
|
|
type GetMediaItemByFilePathParams struct {
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByFilePath, arg.FilePath, arg.LibraryID)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1 LIMIT 1
|
|
`
|
|
|
|
func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByFilePathAnyLibrary, filePath)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
|
|
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1
|
|
`
|
|
|
|
// ============================================
|
|
// KOREADER SYNC PROTOCOL
|
|
// ============================================
|
|
func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByFilePathForSync, filePath)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE kobo_content_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByKoboContentId, koboContentID)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE library_id = $1 AND file_path = $2
|
|
`
|
|
|
|
type GetMediaItemByLibraryAndPathParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByLibraryAndPath, arg.LibraryID, arg.FilePath)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE opf_identifier = $1
|
|
`
|
|
|
|
// Get media item by OPF identifier
|
|
func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByOPFIdentifier, opfIdentifier)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE opf_uuid = $1
|
|
`
|
|
|
|
// Get media item by OPF UUID
|
|
func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemByOPFUUID, opfUuid)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1
|
|
`
|
|
|
|
// Get media item by SHA-256 hash
|
|
func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemBySHA256, fileSha256)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1 AND library_id = $2
|
|
`
|
|
|
|
type GetMediaItemBySHA256AndLibraryParams struct {
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
// Get media item by SHA-256 hash within a specific library (content dedup)
|
|
func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMediaItemBySHA256AndLibraryParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemBySHA256AndLibrary, arg.FileSha256, arg.LibraryID)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemFormatBySHA256 = `-- name: GetMediaItemFormatBySHA256 :one
|
|
SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE file_sha256 = $1
|
|
`
|
|
|
|
// Get media item format by SHA-256
|
|
func (q *Queries) GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemFormatBySHA256, fileSha256)
|
|
var i MediaItemFormats
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.FormatType,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.FileSizeBytes,
|
|
&i.MimeType,
|
|
&i.CreatedAt,
|
|
&i.ConvertedFromFormatID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemFormatByType = `-- name: GetMediaItemFormatByType :one
|
|
SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE media_item_id = $1 AND format_type = $2
|
|
`
|
|
|
|
type GetMediaItemFormatByTypeParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
FormatType string `db:"format_type" json:"format_type"`
|
|
}
|
|
|
|
// Get media item format by type
|
|
func (q *Queries) GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemFormatByType, arg.MediaItemID, arg.FormatType)
|
|
var i MediaItemFormats
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.FormatType,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.FileSizeBytes,
|
|
&i.MimeType,
|
|
&i.CreatedAt,
|
|
&i.ConvertedFromFormatID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemFormats = `-- name: GetMediaItemFormats :many
|
|
SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE media_item_id = $1
|
|
`
|
|
|
|
// Get media item formats
|
|
func (q *Queries) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaItemFormats, mediaItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItemFormats{}
|
|
for rows.Next() {
|
|
var i MediaItemFormats
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.FormatType,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.FileSizeBytes,
|
|
&i.MimeType,
|
|
&i.CreatedAt,
|
|
&i.ConvertedFromFormatID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetMediaItemUsageCounts = `-- name: GetMediaItemUsageCounts :one
|
|
SELECT
|
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count
|
|
`
|
|
|
|
type GetMediaItemUsageCountsRow struct {
|
|
ProgressCount int64 `db:"progress_count" json:"progress_count"`
|
|
HighlightsCount int64 `db:"highlights_count" json:"highlights_count"`
|
|
BookmarksCount int64 `db:"bookmarks_count" json:"bookmarks_count"`
|
|
NotesCount int64 `db:"notes_count" json:"notes_count"`
|
|
CollectionsCount int64 `db:"collections_count" json:"collections_count"`
|
|
}
|
|
|
|
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
|
func (q *Queries) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaItemUsageCounts, mediaItemID)
|
|
var i GetMediaItemUsageCountsRow
|
|
err := row.Scan(
|
|
&i.ProgressCount,
|
|
&i.HighlightsCount,
|
|
&i.BookmarksCount,
|
|
&i.NotesCount,
|
|
&i.CollectionsCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaItemsBySHA256AndSameLibraryType = `-- name: GetMediaItemsBySHA256AndSameLibraryType :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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
|
|
JOIN libraries target_lib ON target_lib.id = $1
|
|
WHERE mi.file_sha256 = $2
|
|
AND mi.library_id <> $1
|
|
AND lt.id = target_lib.library_type_id
|
|
ORDER BY (mi.archived_at IS NOT NULL) DESC,
|
|
mi.missing_scan_count DESC,
|
|
mi.created_at ASC
|
|
`
|
|
|
|
type GetMediaItemsBySHA256AndSameLibraryTypeParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
}
|
|
|
|
type GetMediaItemsBySHA256AndSameLibraryTypeRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
}
|
|
|
|
// Cross-library move detection: identical content registered in a different
|
|
// library of the same type (ebooks stay with ebooks, comics with comics).
|
|
// Candidates may be active copies (deliberate duplicates - leave alone) or
|
|
// hidden/archived rows whose file vanished (move candidates - the scanner
|
|
// verifies disk state before repointing).
|
|
func (q *Queries) GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaItemsBySHA256AndSameLibraryType, arg.LibraryID, arg.FileSha256)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetMediaItemsBySHA256AndSameLibraryTypeRow{}
|
|
for rows.Next() {
|
|
var i GetMediaItemsBySHA256AndSameLibraryTypeRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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 GetMediaNote = `-- name: GetMediaNote :one
|
|
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1
|
|
`
|
|
|
|
// Media Notes queries
|
|
func (q *Queries) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaNote, id)
|
|
var i MediaNotes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Content,
|
|
&i.Position,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageLocation,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.ParagraphReference,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaNoteByDedupKey = `-- name: GetMediaNoteByDedupKey :one
|
|
|
|
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetMediaNoteByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
// ============================================
|
|
// ANNOTATION SYNC QUERIES (notes)
|
|
// ============================================
|
|
func (q *Queries) GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
var i MediaNotes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Content,
|
|
&i.Position,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageLocation,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.ParagraphReference,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaNotes = `-- name: GetMediaNotes :many
|
|
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetMediaNotesParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaNotes, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaNotes{}
|
|
for rows.Next() {
|
|
var i MediaNotes
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Content,
|
|
&i.Position,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageLocation,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.ParagraphReference,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetMediaRating = `-- name: GetMediaRating :one
|
|
SELECT id, media_item_id, user_id, rating, created_at, updated_at FROM media_ratings WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type GetMediaRatingParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaRating, arg.MediaItemID, arg.UserID)
|
|
var i MediaRatings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Rating,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaRatings = `-- name: GetMediaRatings :many
|
|
SELECT mr.id, mr.media_item_id, mr.user_id, mr.rating, mr.created_at, mr.updated_at, u.username
|
|
FROM media_ratings mr
|
|
JOIN users u ON mr.user_id = u.id
|
|
WHERE mr.media_item_id = $1
|
|
ORDER BY mr.created_at DESC
|
|
`
|
|
|
|
type GetMediaRatingsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Rating int32 `db:"rating" json:"rating"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
Username string `db:"username" json:"username"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetMediaRatings, mediaItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetMediaRatingsRow{}
|
|
for rows.Next() {
|
|
var i GetMediaRatingsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Rating,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Username,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetMediaReview = `-- name: GetMediaReview :one
|
|
SELECT id, media_item_id, user_id, review, created_at, updated_at FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type GetMediaReviewParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaReview, arg.MediaItemID, arg.UserID)
|
|
var i MediaReviews
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Review,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetMediaScanState = `-- name: GetMediaScanState :one
|
|
SELECT media_item_id, manifest FROM media_scan_state WHERE media_item_id = $1
|
|
`
|
|
|
|
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
|
// and the manifest integration tests).
|
|
func (q *Queries) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error) {
|
|
row := q.db.QueryRow(ctx, GetMediaScanState, mediaItemID)
|
|
var i MediaScanState
|
|
err := row.Scan(&i.MediaItemID, &i.Manifest)
|
|
return i, err
|
|
}
|
|
|
|
const GetNextRetryTime = `-- name: GetNextRetryTime :one
|
|
SELECT
|
|
CASE
|
|
WHEN attempts = 0 THEN NOW()
|
|
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
|
|
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
|
|
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
|
|
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
|
|
ELSE NOW() + INTERVAL '24 hours'
|
|
END as next_retry_time
|
|
`
|
|
|
|
func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) {
|
|
row := q.db.QueryRow(ctx, GetNextRetryTime)
|
|
var next_retry_time interface{}
|
|
err := row.Scan(&next_retry_time)
|
|
return next_retry_time, err
|
|
}
|
|
|
|
const GetNotStartedItems = `-- name: GetNotStartedItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM reading_progress rp
|
|
WHERE rp.media_item_id = mi.id
|
|
AND rp.user_id = $2
|
|
AND rp.percentage > 0
|
|
)
|
|
ORDER BY mi.created_at DESC
|
|
LIMIT $3
|
|
`
|
|
|
|
type GetNotStartedItemsParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetNotStartedItems, arg.LibraryID, arg.UserID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetOpdsToken = `-- name: GetOpdsToken :one
|
|
SELECT ot.id, ot.device_id, ot.token, ot.token_type, ot.expires_at, ot.created_at, d.device_name, d.device_type
|
|
FROM opds_tokens ot
|
|
JOIN devices d ON ot.device_id = d.id
|
|
WHERE ot.token = $1 AND ot.expires_at > NOW()
|
|
`
|
|
|
|
type GetOpdsTokenRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
Token string `db:"token" json:"token"`
|
|
TokenType pgtype.Text `db:"token_type" json:"token_type"`
|
|
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Get OPDS token
|
|
func (q *Queries) GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error) {
|
|
row := q.db.QueryRow(ctx, GetOpdsToken, token)
|
|
var i GetOpdsTokenRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.Token,
|
|
&i.TokenType,
|
|
&i.ExpiresAt,
|
|
&i.CreatedAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetOpdsTokensByDevice = `-- name: GetOpdsTokensByDevice :many
|
|
SELECT id, device_id, token, token_type, expires_at, created_at FROM opds_tokens WHERE device_id = $1 AND expires_at > NOW()
|
|
`
|
|
|
|
// Get OPDS tokens by device
|
|
func (q *Queries) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) {
|
|
rows, err := q.db.Query(ctx, GetOpdsTokensByDevice, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []OpdsTokens{}
|
|
for rows.Next() {
|
|
var i OpdsTokens
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.Token,
|
|
&i.TokenType,
|
|
&i.ExpiresAt,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetPanelData = `-- name: GetPanelData :one
|
|
SELECT id, media_item_id, page_number, detection_method, panels, created_at, updated_at FROM panel_data
|
|
WHERE media_item_id = $1 AND page_number = $2
|
|
`
|
|
|
|
type GetPanelDataParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
PageNumber int32 `db:"page_number" json:"page_number"`
|
|
}
|
|
|
|
func (q *Queries) GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error) {
|
|
row := q.db.QueryRow(ctx, GetPanelData, arg.MediaItemID, arg.PageNumber)
|
|
var i PanelData
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.PageNumber,
|
|
&i.DetectionMethod,
|
|
&i.Panels,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetPodcastContinueForUser = `-- name: GetPodcastContinueForUser :many
|
|
SELECT mi.source_feed_id,
|
|
mi.id,
|
|
mi.title,
|
|
mi.feed_position,
|
|
rp.percentage,
|
|
rp.last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = $1
|
|
AND mi.source = 'podcast_episode'
|
|
AND rp.percentage > 0
|
|
AND rp.percentage < 0.995
|
|
ORDER BY rp.last_read_at DESC
|
|
`
|
|
|
|
type GetPodcastContinueForUserRow struct {
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
|
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
|
// recent first. Go groups per feed — the first row per feed IS the
|
|
// continue target.
|
|
func (q *Queries) GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, GetPodcastContinueForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetPodcastContinueForUserRow{}
|
|
for rows.Next() {
|
|
var i GetPodcastContinueForUserRow
|
|
if err := rows.Scan(
|
|
&i.SourceFeedID,
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.FeedPosition,
|
|
&i.Percentage,
|
|
&i.LastReadAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetPodcastEpisode = `-- name: GetPodcastEpisode :one
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE id = $1 AND source = 'podcast_episode'
|
|
`
|
|
|
|
func (q *Queries) GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, GetPodcastEpisode, id)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetPodcastEpisodeProgress = `-- name: GetPodcastEpisodeProgress :many
|
|
SELECT rp.media_item_id,
|
|
rp.percentage,
|
|
rp.last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = $1
|
|
AND mi.source_feed_id = $2
|
|
AND mi.source = 'podcast_episode'
|
|
`
|
|
|
|
type GetPodcastEpisodeProgressParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
}
|
|
|
|
type GetPodcastEpisodeProgressRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
// Per-user progress for a feed's episodes (row badges + the Phase-2
|
|
// continue fields). reading_progress is UNIQUE per (media_item, user).
|
|
func (q *Queries) GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error) {
|
|
rows, err := q.db.Query(ctx, GetPodcastEpisodeProgress, arg.UserID, arg.SourceFeedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetPodcastEpisodeProgressRow{}
|
|
for rows.Next() {
|
|
var i GetPodcastEpisodeProgressRow
|
|
if err := rows.Scan(&i.MediaItemID, &i.Percentage, &i.LastReadAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetPodcastFeed = `-- name: GetPodcastFeed :one
|
|
SELECT id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at FROM podcast_feeds WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error) {
|
|
row := q.db.QueryRow(ctx, GetPodcastFeed, id)
|
|
var i PodcastFeeds
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetPodcastFeedByGuid = `-- name: GetPodcastFeedByGuid :one
|
|
SELECT id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at FROM podcast_feeds
|
|
WHERE library_id = $1 AND podcast_guid = $2
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetPodcastFeedByGuidParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
|
}
|
|
|
|
// URL-migration dedupe: same canonical guid within the library reuses
|
|
// the existing row.
|
|
func (q *Queries) GetPodcastFeedByGuid(ctx context.Context, arg GetPodcastFeedByGuidParams) (PodcastFeeds, error) {
|
|
row := q.db.QueryRow(ctx, GetPodcastFeedByGuid, arg.LibraryID, arg.PodcastGuid)
|
|
var i PodcastFeeds
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetPodcastLibraryFolder = `-- name: GetPodcastLibraryFolder :many
|
|
SELECT lf.id, lf.library_id, lf.folder_path, lf.created_at, lf.purpose
|
|
FROM library_folders lf
|
|
WHERE lf.library_id = $1 AND lf.purpose = 'podcast'
|
|
`
|
|
|
|
// The download root (purpose='podcast') of a podcasts library.
|
|
func (q *Queries) GetPodcastLibraryFolder(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error) {
|
|
rows, err := q.db.Query(ctx, GetPodcastLibraryFolder, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LibraryFolders{}
|
|
for rows.Next() {
|
|
var i LibraryFolders
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FolderPath,
|
|
&i.CreatedAt,
|
|
&i.Purpose,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetPodcastSubscription = `-- name: GetPodcastSubscription :one
|
|
SELECT id, user_id, feed_id, display_name, auto_download, auto_download_count, created_at FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2
|
|
`
|
|
|
|
type GetPodcastSubscriptionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
|
}
|
|
|
|
func (q *Queries) GetPodcastSubscription(ctx context.Context, arg GetPodcastSubscriptionParams) (PodcastSubscriptions, error) {
|
|
row := q.db.QueryRow(ctx, GetPodcastSubscription, arg.UserID, arg.FeedID)
|
|
var i PodcastSubscriptions
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.FeedID,
|
|
&i.DisplayName,
|
|
&i.AutoDownload,
|
|
&i.AutoDownloadCount,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetPodcastsLibraryForUser = `-- name: GetPodcastsLibraryForUser :many
|
|
SELECT l.id, l.name
|
|
FROM libraries l
|
|
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
|
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
ORDER BY l.created_at ASC
|
|
`
|
|
|
|
type GetPodcastsLibraryForUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
}
|
|
|
|
// The podcasts library/ies visible to the user (add-feed target list;
|
|
// a self-hosted instance typically has exactly one).
|
|
func (q *Queries) GetPodcastsLibraryForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastsLibraryForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, GetPodcastsLibraryForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetPodcastsLibraryForUserRow{}
|
|
for rows.Next() {
|
|
var i GetPodcastsLibraryForUserRow
|
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetPopularBooks = `-- name: GetPopularBooks :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
COUNT(*) as read_count,
|
|
AVG(rh.progress_percentage) as avg_completion,
|
|
MAX(rh.created_at) as last_read
|
|
FROM media_items mi
|
|
JOIN reading_history rh ON rh.media_item_id = mi.id
|
|
WHERE rh.user_id = $1
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
GROUP BY mi.id, mi.title, mi.author
|
|
ORDER BY read_count DESC
|
|
LIMIT $2
|
|
`
|
|
|
|
type GetPopularBooksParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetPopularBooksRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ReadCount int64 `db:"read_count" json:"read_count"`
|
|
AvgCompletion float64 `db:"avg_completion" json:"avg_completion"`
|
|
LastRead interface{} `db:"last_read" json:"last_read"`
|
|
}
|
|
|
|
// Get most popular books for a user
|
|
func (q *Queries) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) {
|
|
rows, err := q.db.Query(ctx, GetPopularBooks, arg.UserID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetPopularBooksRow{}
|
|
for rows.Next() {
|
|
var i GetPopularBooksRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ReadCount,
|
|
&i.AvgCompletion,
|
|
&i.LastRead,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetProcessingIssue = `-- name: GetProcessingIssue :one
|
|
SELECT id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path FROM processing_issues WHERE id = $1
|
|
`
|
|
|
|
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
|
func (q *Queries) GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
|
|
row := q.db.QueryRow(ctx, GetProcessingIssue, id)
|
|
var i ProcessingIssues
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.LibraryID,
|
|
&i.IssueType,
|
|
&i.IssueDescription,
|
|
&i.Severity,
|
|
&i.Resolved,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetProcessingIssueStats = `-- name: GetProcessingIssueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
|
|
COUNT(*) FILTER (WHERE severity = 'warning' AND resolved = false) as warning_count,
|
|
COUNT(*) FILTER (WHERE severity = 'info' AND resolved = false) as info_count
|
|
FROM processing_issues
|
|
WHERE library_id = $1
|
|
`
|
|
|
|
type GetProcessingIssueStatsRow struct {
|
|
ErrorCount int64 `db:"error_count" json:"error_count"`
|
|
WarningCount int64 `db:"warning_count" json:"warning_count"`
|
|
InfoCount int64 `db:"info_count" json:"info_count"`
|
|
}
|
|
|
|
func (q *Queries) GetProcessingIssueStats(ctx context.Context, libraryID pgtype.UUID) (GetProcessingIssueStatsRow, error) {
|
|
row := q.db.QueryRow(ctx, GetProcessingIssueStats, libraryID)
|
|
var i GetProcessingIssueStatsRow
|
|
err := row.Scan(&i.ErrorCount, &i.WarningCount, &i.InfoCount)
|
|
return i, err
|
|
}
|
|
|
|
const GetReaderSettings = `-- name: GetReaderSettings :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = 'reader_settings'
|
|
`
|
|
|
|
func (q *Queries) GetReaderSettings(ctx context.Context, userID pgtype.UUID) ([]byte, error) {
|
|
row := q.db.QueryRow(ctx, GetReaderSettings, userID)
|
|
var setting_value []byte
|
|
err := row.Scan(&setting_value)
|
|
return setting_value, err
|
|
}
|
|
|
|
const GetReadingHistory = `-- name: GetReadingHistory :many
|
|
SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
|
|
FROM reading_history
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT $3
|
|
`
|
|
|
|
type GetReadingHistoryParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
// Get reading history for a user and book
|
|
func (q *Queries) GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) {
|
|
rows, err := q.db.Query(ctx, GetReadingHistory, arg.UserID, arg.MediaItemID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ReadingHistory{}
|
|
for rows.Next() {
|
|
var i ReadingHistory
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.ProgressPercentage,
|
|
&i.ReadingSessionStart,
|
|
&i.ReadingSessionEnd,
|
|
&i.PagesRead,
|
|
&i.TimeSpentSeconds,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetReadingProgress = `-- name: GetReadingProgress :one
|
|
SELECT id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved FROM reading_progress WHERE media_item_id = $1 AND user_id = $2
|
|
`
|
|
|
|
type GetReadingProgressParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) {
|
|
row := q.db.QueryRow(ctx, GetReadingProgress, arg.MediaItemID, arg.UserID)
|
|
var i ReadingProgress
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.CurrentPage,
|
|
&i.TotalPages,
|
|
&i.LastReadAt,
|
|
&i.Percentage,
|
|
&i.CharacterOffset,
|
|
&i.Epubcfi,
|
|
&i.ContextText,
|
|
&i.Chapter,
|
|
&i.ChapterProgress,
|
|
&i.AudioOffsetSeconds,
|
|
&i.ViewportX,
|
|
&i.ViewportY,
|
|
&i.ZoomLevel,
|
|
&i.ScrollPositionX,
|
|
&i.ScrollPositionY,
|
|
&i.PanelNumber,
|
|
&i.ReadingMode,
|
|
&i.LastSyncDevice,
|
|
&i.LastSyncSource,
|
|
&i.LastSyncTimestamp,
|
|
&i.ConflictDetected,
|
|
&i.ConflictResolved,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetReadingSpeed = `-- name: GetReadingSpeed :one
|
|
SELECT id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at, updated_at FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
`
|
|
|
|
type GetReadingSpeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams) (ReadingSpeed, error) {
|
|
row := q.db.QueryRow(ctx, GetReadingSpeed, arg.UserID, arg.MediaItemID)
|
|
var i ReadingSpeed
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.WordsPerMinute,
|
|
&i.PagesPerMinute,
|
|
&i.PagesRead,
|
|
&i.TotalReadingMinutes,
|
|
&i.LastReadAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
|
LIMIT $2
|
|
`
|
|
|
|
type GetRecentlyAddedItemsParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAddedItemsParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetRecentlyAddedItems, arg.LibraryID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = $1
|
|
AND percentage >= 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT $3
|
|
`
|
|
|
|
type GetRecentlyReadItemsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadItemsParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetRecentlyReadItems, arg.UserID, arg.LibraryID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetRefreshToken = `-- name: GetRefreshToken :one
|
|
SELECT rt.id, rt.user_id, rt.token, rt.expires_at, rt.created_at, rt.revoked_at, u.email, u.username, u.role
|
|
FROM refresh_tokens rt
|
|
JOIN users u ON rt.user_id = u.id
|
|
WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW()
|
|
`
|
|
|
|
type GetRefreshTokenRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Token pgtype.UUID `db:"token" json:"token"`
|
|
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Role string `db:"role" json:"role"`
|
|
}
|
|
|
|
func (q *Queries) GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error) {
|
|
row := q.db.QueryRow(ctx, GetRefreshToken, token)
|
|
var i GetRefreshTokenRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.CreatedAt,
|
|
&i.RevokedAt,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Role,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSavedFilterByID = `-- name: GetSavedFilterByID :one
|
|
SELECT id, user_id, name, resource_type, filters, created_at, updated_at FROM saved_filters
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type GetSavedFilterByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error) {
|
|
row := q.db.QueryRow(ctx, GetSavedFilterByID, arg.ID, arg.UserID)
|
|
var i SavedFilters
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.ResourceType,
|
|
&i.Filters,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSavedFilters = `-- name: GetSavedFilters :many
|
|
SELECT id, user_id, name, resource_type, filters, created_at, updated_at FROM saved_filters
|
|
WHERE user_id = $1 AND resource_type = $2
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
type GetSavedFiltersParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
|
}
|
|
|
|
func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error) {
|
|
rows, err := q.db.Query(ctx, GetSavedFilters, arg.UserID, arg.ResourceType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SavedFilters{}
|
|
for rows.Next() {
|
|
var i SavedFilters
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.ResourceType,
|
|
&i.Filters,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetScanStateByLibrary = `-- name: GetScanStateByLibrary :many
|
|
SELECT
|
|
mi.id,
|
|
mi.file_path,
|
|
mi.file_size,
|
|
mi.archived_at,
|
|
mi.missing_scan_count,
|
|
mi.file_sha256,
|
|
s.manifest
|
|
FROM media_items mi
|
|
LEFT JOIN media_scan_state s ON s.media_item_id = mi.id
|
|
WHERE mi.library_id = $1
|
|
`
|
|
|
|
type GetScanStateByLibraryRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
Manifest []byte `db:"manifest" json:"manifest"`
|
|
}
|
|
|
|
// GetScanStateByLibrary: the scanner's ONE bulk query per library scan
|
|
// (faceted plan Part 2 §2) — every item's existence + scanner-owned
|
|
// facts + sidecar manifest in a single pass. Replaces the per-file
|
|
// GetMediaItemByFilePath flood (~3,094 queries per scan in prod).
|
|
// Lean projection: metadata blobs (audio_metadata, chapter_metadata,
|
|
// authors, description...) are NEVER loaded — the skip path never
|
|
// needs them, and the change path re-reads the full row.
|
|
// archived_at + missing_scan_count + file_sha256 ride along so the
|
|
// skip path's archive-restore and hash self-heal checks stay
|
|
// DB-free too.
|
|
func (q *Queries) GetScanStateByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]GetScanStateByLibraryRow, error) {
|
|
rows, err := q.db.Query(ctx, GetScanStateByLibrary, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetScanStateByLibraryRow{}
|
|
for rows.Next() {
|
|
var i GetScanStateByLibraryRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.ArchivedAt,
|
|
&i.MissingScanCount,
|
|
&i.FileSha256,
|
|
&i.Manifest,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetSeriesBooks = `-- name: GetSeriesBooks :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE series = $1
|
|
AND library_id = $2
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
ORDER BY series_number ASC NULLS LAST, title ASC
|
|
`
|
|
|
|
type GetSeriesBooksParams struct {
|
|
Series pgtype.Text `db:"series" json:"series"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
// Scoped to ONE library: same-name series in different libraries are
|
|
// different stories (manga adaptations, LN splits). Callers must pass a
|
|
// library_id and verify the requester can see that library.
|
|
func (q *Queries) GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, GetSeriesBooks, arg.Series, arg.LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetSeriesCovers = `-- name: GetSeriesCovers :many
|
|
SELECT cover_image_path, library_id
|
|
FROM media_items
|
|
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND series = $3 AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
|
ORDER BY series_number ASC NULLS LAST
|
|
LIMIT $4
|
|
`
|
|
|
|
type GetSeriesCoversParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
Series pgtype.Text `db:"series" json:"series"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetSeriesCoversRow struct {
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
func (q *Queries) GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error) {
|
|
rows, err := q.db.Query(ctx, GetSeriesCovers,
|
|
arg.LibraryID,
|
|
arg.VisibleLibraryIds,
|
|
arg.Series,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetSeriesCoversRow{}
|
|
for rows.Next() {
|
|
var i GetSeriesCoversRow
|
|
if err := rows.Scan(&i.CoverImagePath, &i.LibraryID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetStuckSyncQueueItems = `-- name: GetStuckSyncQueueItems :many
|
|
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
|
|
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
|
|
ORDER BY priority ASC, created_at ASC
|
|
`
|
|
|
|
func (q *Queries) GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error) {
|
|
rows, err := q.db.Query(ctx, GetStuckSyncQueueItems)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SyncQueue{}
|
|
for rows.Next() {
|
|
var i SyncQueue
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetSyncConflict = `-- name: GetSyncConflict :one
|
|
SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) {
|
|
row := q.db.QueryRow(ctx, GetSyncConflict, id)
|
|
var i SyncConflicts
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSyncQueueItem = `-- name: GetSyncQueueItem :one
|
|
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) {
|
|
row := q.db.QueryRow(ctx, GetSyncQueueItem, id)
|
|
var i SyncQueue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSyncQueueStats = `-- name: GetSyncQueueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
|
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
|
|
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
|
|
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
|
|
COUNT(*) as total_count
|
|
FROM sync_queue
|
|
WHERE device_id = $1
|
|
`
|
|
|
|
type GetSyncQueueStatsRow struct {
|
|
PendingCount int64 `db:"pending_count" json:"pending_count"`
|
|
ProcessingCount int64 `db:"processing_count" json:"processing_count"`
|
|
FailedCount int64 `db:"failed_count" json:"failed_count"`
|
|
CompletedCount int64 `db:"completed_count" json:"completed_count"`
|
|
TotalCount int64 `db:"total_count" json:"total_count"`
|
|
}
|
|
|
|
func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error) {
|
|
row := q.db.QueryRow(ctx, GetSyncQueueStats, deviceID)
|
|
var i GetSyncQueueStatsRow
|
|
err := row.Scan(
|
|
&i.PendingCount,
|
|
&i.ProcessingCount,
|
|
&i.FailedCount,
|
|
&i.CompletedCount,
|
|
&i.TotalCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
|
|
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
|
WHERE user_id = $1
|
|
AND query_type = $2
|
|
AND is_system_collection = true
|
|
`
|
|
|
|
type GetSystemCollectionByQueryTypeParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
|
}
|
|
|
|
// System collections are permanent: identity is (user_id, query_type).
|
|
// Restore/reset operate IN PLACE (the row id — and everything referencing
|
|
// it: shelf mappings, prefs order, links — never churns). Deleted sections
|
|
// are TOMBSTONED (deleted_at) and stay invisible until restored.
|
|
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
|
func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, GetSystemCollectionByQueryType, arg.UserID, arg.QueryType)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
|
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
|
WHERE user_id = $1
|
|
AND is_system_collection = true
|
|
AND show_on_dashboard = true
|
|
AND deleted_at IS NULL
|
|
ORDER BY priority ASC
|
|
`
|
|
|
|
// Dashboard collections queries
|
|
func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) {
|
|
rows, err := q.db.Query(ctx, GetSystemCollectionsForDashboard, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Collections{}
|
|
for rows.Next() {
|
|
var i Collections
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetSystemConfig = `-- name: GetSystemConfig :one
|
|
|
|
SELECT key, value, updated_at, updated_by FROM system_config WHERE key = $1
|
|
`
|
|
|
|
// SYSTEM CONFIG QUERIES
|
|
// Get system config
|
|
func (q *Queries) GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) {
|
|
row := q.db.QueryRow(ctx, GetSystemConfig, key)
|
|
var i SystemConfig
|
|
err := row.Scan(
|
|
&i.Key,
|
|
&i.Value,
|
|
&i.UpdatedAt,
|
|
&i.UpdatedBy,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSystemSetting = `-- name: GetSystemSetting :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = $1
|
|
`
|
|
|
|
// System Settings queries
|
|
func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (string, error) {
|
|
row := q.db.QueryRow(ctx, GetSystemSetting, settingKey)
|
|
var setting_value string
|
|
err := row.Scan(&setting_value)
|
|
return setting_value, err
|
|
}
|
|
|
|
const GetSystemSettingFull = `-- name: GetSystemSettingFull :one
|
|
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1
|
|
`
|
|
|
|
func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) {
|
|
row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey)
|
|
var i SystemSettings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.SettingKey,
|
|
&i.SettingValue,
|
|
&i.Description,
|
|
&i.UpdatedAt,
|
|
&i.SettingType,
|
|
&i.MinValue,
|
|
&i.MaxValue,
|
|
&i.RequiresRestart,
|
|
&i.Category,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetSystemTimezone = `-- name: GetSystemTimezone :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
|
|
`
|
|
|
|
func (q *Queries) GetSystemTimezone(ctx context.Context) (string, error) {
|
|
row := q.db.QueryRow(ctx, GetSystemTimezone)
|
|
var setting_value string
|
|
err := row.Scan(&setting_value)
|
|
return setting_value, err
|
|
}
|
|
|
|
const GetTombstonedAnnotationsForBook = `-- name: GetTombstonedAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.device_sync_data,
|
|
mh.deleted_at,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.device_sync_data,
|
|
mn.deleted_at,
|
|
mn.position as start_position,
|
|
NULL as end_position,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.device_sync_data,
|
|
mb.deleted_at,
|
|
mb.position as start_position,
|
|
NULL as end_position,
|
|
mb.cfi_position as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
|
ORDER BY deleted_at DESC
|
|
`
|
|
|
|
type GetTombstonedAnnotationsForBookParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
|
}
|
|
|
|
type GetTombstonedAnnotationsForBookRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
|
}
|
|
|
|
func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) {
|
|
rows, err := q.db.Query(ctx, GetTombstonedAnnotationsForBook, arg.MediaItemID, arg.UserID, arg.DeletedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetTombstonedAnnotationsForBookRow{}
|
|
for rows.Next() {
|
|
var i GetTombstonedAnnotationsForBookRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DedupKey,
|
|
&i.AnnotationType,
|
|
&i.DeviceSyncData,
|
|
&i.DeletedAt,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetTopBooksByMinutes = `-- name: GetTopBooksByMinutes :many
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(*)::int AS session_count,
|
|
MAX(rh.created_at) AS last_read
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = $1
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
|
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
|
ORDER BY total_seconds DESC
|
|
LIMIT $5
|
|
`
|
|
|
|
type GetTopBooksByMinutesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type GetTopBooksByMinutesRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
|
|
TotalPages int32 `db:"total_pages" json:"total_pages"`
|
|
SessionCount int32 `db:"session_count" json:"session_count"`
|
|
LastRead interface{} `db:"last_read" json:"last_read"`
|
|
}
|
|
|
|
// Most-read books for a range (minutes), for the top list.
|
|
func (q *Queries) GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error) {
|
|
rows, err := q.db.Query(ctx, GetTopBooksByMinutes,
|
|
arg.UserID,
|
|
arg.StartTime,
|
|
arg.EndTime,
|
|
arg.VisibleLibraryIds,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetTopBooksByMinutesRow{}
|
|
for rows.Next() {
|
|
var i GetTopBooksByMinutesRow
|
|
if err := rows.Scan(
|
|
&i.MediaItemID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.LibraryID,
|
|
&i.CoverImagePath,
|
|
&i.TotalSeconds,
|
|
&i.TotalPages,
|
|
&i.SessionCount,
|
|
&i.LastRead,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUniversalProgress = `-- name: GetUniversalProgress :one
|
|
SELECT
|
|
rp.id,
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.context_text,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.audio_offset_seconds,
|
|
rp.viewport_x,
|
|
rp.viewport_y,
|
|
rp.zoom_level,
|
|
rp.scroll_position_x,
|
|
rp.scroll_position_y,
|
|
rp.panel_number,
|
|
rp.reading_mode,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
rp.last_sync_timestamp,
|
|
rp.conflict_detected,
|
|
rp.conflict_resolved,
|
|
mi.format_group,
|
|
mi.format_mimetype,
|
|
mi.is_reflowable,
|
|
mi.has_fixed_layout,
|
|
mi.total_characters,
|
|
mi.chapter_count
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.media_item_id = $1 AND rp.user_id = $2
|
|
`
|
|
|
|
type GetUniversalProgressParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
type GetUniversalProgressRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
|
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
|
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
|
AudioOffsetSeconds pgtype.Int4 `db:"audio_offset_seconds" json:"audio_offset_seconds"`
|
|
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
|
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
|
|
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
|
|
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
|
|
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
|
|
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
|
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
|
|
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
|
|
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
|
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
|
|
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
|
|
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
|
|
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"`
|
|
}
|
|
|
|
// Get universal progress for a book
|
|
func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUniversalProgress, arg.MediaItemID, arg.UserID)
|
|
var i GetUniversalProgressRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.CurrentPage,
|
|
&i.TotalPages,
|
|
&i.LastReadAt,
|
|
&i.Percentage,
|
|
&i.CharacterOffset,
|
|
&i.Epubcfi,
|
|
&i.ContextText,
|
|
&i.Chapter,
|
|
&i.ChapterProgress,
|
|
&i.AudioOffsetSeconds,
|
|
&i.ViewportX,
|
|
&i.ViewportY,
|
|
&i.ZoomLevel,
|
|
&i.ScrollPositionX,
|
|
&i.ScrollPositionY,
|
|
&i.PanelNumber,
|
|
&i.ReadingMode,
|
|
&i.LastSyncDevice,
|
|
&i.LastSyncSource,
|
|
&i.LastSyncTimestamp,
|
|
&i.ConflictDetected,
|
|
&i.ConflictResolved,
|
|
&i.FormatGroup,
|
|
&i.FormatMimetype,
|
|
&i.IsReflowable,
|
|
&i.HasFixedLayout,
|
|
&i.TotalCharacters,
|
|
&i.ChapterCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
|
|
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
|
|
`
|
|
|
|
type GetUnlinkedBookByContentIdParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
}
|
|
|
|
// Get unlinked book by ContentId
|
|
func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, GetUnlinkedBookByContentId, arg.DeviceID, arg.ContentID)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
|
|
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
|
|
`
|
|
|
|
// Get unlinked book by ID
|
|
func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, GetUnlinkedBookByID, id)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
|
|
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.id = $1
|
|
`
|
|
|
|
type GetUnlinkedBookWithOwnerRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Fetch one row together with its device's owner for authorization.
|
|
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
|
|
var i GetUnlinkedBookWithOwnerRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
|
|
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
|
ORDER BY ub.last_seen_at DESC
|
|
`
|
|
|
|
type GetUnlinkedBooksByDeviceRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Get unlinked books for a device (ignored rows stay hidden)
|
|
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUnlinkedBooksByDeviceRow{}
|
|
for rows.Next() {
|
|
var i GetUnlinkedBooksByDeviceRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUser = `-- name: GetUser :one
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
u.timezone,
|
|
u.default_audio_speed,
|
|
u.keep_screen_awake,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
WHERE u.id = $1
|
|
`
|
|
|
|
type GetUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
|
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
|
DeviceCount int64 `db:"device_count" json:"device_count"`
|
|
}
|
|
|
|
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUser, id)
|
|
var i GetUserRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.MaxDevices,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Timezone,
|
|
&i.DefaultAudioSpeed,
|
|
&i.KeepScreenAwake,
|
|
&i.DeviceCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUserByEmail = `-- name: GetUserByEmail :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1
|
|
`
|
|
|
|
type GetUserByEmailRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUserByEmail, email)
|
|
var i GetUserByEmailRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1
|
|
`
|
|
|
|
type GetUserByEmailOrUsernameRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUserByEmailOrUsername, email)
|
|
var i GetUserByEmailOrUsernameRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUserByUsername = `-- name: GetUserByUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE username = $1
|
|
`
|
|
|
|
type GetUserByUsernameRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUserByUsername, username)
|
|
var i GetUserByUsernameRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
|
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
|
WHERE c.user_id = $1
|
|
AND c.is_system_collection = false
|
|
AND c.deleted_at IS NULL
|
|
ORDER BY priority ASC
|
|
`
|
|
|
|
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
|
// show_on_dashboard gated them forever-false (no writer ever set it on
|
|
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
|
// The flag remains system-section provisioning state only.
|
|
func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) {
|
|
rows, err := q.db.Query(ctx, GetUserCollectionsForDashboard, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Collections{}
|
|
for rows.Next() {
|
|
var i Collections
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserDeviceBreakdown = `-- name: GetUserDeviceBreakdown :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*)::int AS session_count,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
LEFT JOIN devices d ON d.id = rh.device_id
|
|
WHERE rh.user_id = $1
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY total_seconds DESC
|
|
`
|
|
|
|
type GetUserDeviceBreakdownParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
type GetUserDeviceBreakdownRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceName pgtype.Text `db:"device_name" json:"device_name"`
|
|
DeviceType pgtype.Text `db:"device_type" json:"device_type"`
|
|
SessionCount int32 `db:"session_count" json:"session_count"`
|
|
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
|
|
TotalPages int32 `db:"total_pages" json:"total_pages"`
|
|
BookCount int32 `db:"book_count" json:"book_count"`
|
|
}
|
|
|
|
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
|
// carry no device (web reader until it identifies) still appear as one
|
|
// "unattributed" bucket — device minutes must reconcile with the totals.
|
|
func (q *Queries) GetUserDeviceBreakdown(ctx context.Context, arg GetUserDeviceBreakdownParams) ([]GetUserDeviceBreakdownRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserDeviceBreakdown,
|
|
arg.UserID,
|
|
arg.StartTime,
|
|
arg.EndTime,
|
|
arg.VisibleLibraryIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserDeviceBreakdownRow{}
|
|
for rows.Next() {
|
|
var i GetUserDeviceBreakdownRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.SessionCount,
|
|
&i.TotalSeconds,
|
|
&i.TotalPages,
|
|
&i.BookCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserDeviceUsage = `-- name: GetUserDeviceUsage :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*) as sync_count,
|
|
MAX(rh.created_at) as last_sync,
|
|
SUM(rh.time_spent_seconds) as total_time_seconds
|
|
FROM devices d
|
|
JOIN reading_history rh ON rh.device_id = d.id
|
|
WHERE d.user_id = $1
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY sync_count DESC
|
|
`
|
|
|
|
type GetUserDeviceUsageRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
SyncCount int64 `db:"sync_count" json:"sync_count"`
|
|
LastSync interface{} `db:"last_sync" json:"last_sync"`
|
|
TotalTimeSeconds int64 `db:"total_time_seconds" json:"total_time_seconds"`
|
|
}
|
|
|
|
// Get user device usage statistics
|
|
func (q *Queries) GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserDeviceUsage, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserDeviceUsageRow{}
|
|
for rows.Next() {
|
|
var i GetUserDeviceUsageRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.SyncCount,
|
|
&i.LastSync,
|
|
&i.TotalTimeSeconds,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserForLogin = `-- name: GetUserForLogin :one
|
|
SELECT id, email, username, password_hash, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1
|
|
`
|
|
|
|
type GetUserForLoginRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
func (q *Queries) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) {
|
|
row := q.db.QueryRow(ctx, GetUserForLogin, email)
|
|
var i GetUserForLoginRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.PasswordHash,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const GetUserLibraryBreakdown = `-- name: GetUserLibraryBreakdown :many
|
|
SELECT
|
|
l.id::uuid AS library_id,
|
|
l.name,
|
|
COUNT(*)::int AS session_count,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
JOIN libraries l ON l.id = mi.library_id
|
|
WHERE rh.user_id = $1
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
|
GROUP BY l.id, l.name
|
|
ORDER BY total_seconds DESC
|
|
`
|
|
|
|
type GetUserLibraryBreakdownParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
type GetUserLibraryBreakdownRow struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Name string `db:"name" json:"name"`
|
|
SessionCount int32 `db:"session_count" json:"session_count"`
|
|
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
|
|
TotalPages int32 `db:"total_pages" json:"total_pages"`
|
|
BookCount int32 `db:"book_count" json:"book_count"`
|
|
}
|
|
|
|
// Per-library aggregation for the same range.
|
|
func (q *Queries) GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserLibraryBreakdown,
|
|
arg.UserID,
|
|
arg.StartTime,
|
|
arg.EndTime,
|
|
arg.VisibleLibraryIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserLibraryBreakdownRow{}
|
|
for rows.Next() {
|
|
var i GetUserLibraryBreakdownRow
|
|
if err := rows.Scan(
|
|
&i.LibraryID,
|
|
&i.Name,
|
|
&i.SessionCount,
|
|
&i.TotalSeconds,
|
|
&i.TotalPages,
|
|
&i.BookCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserMediaItemsForSync = `-- name: GetUserMediaItemsForSync :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path,
|
|
mi.mime_type,
|
|
mi.page_count,
|
|
mi.format_group,
|
|
mi.total_characters,
|
|
mi.chapter_count,
|
|
mi.entitlement_id,
|
|
mi.revision_number,
|
|
mi.file_size,
|
|
mi.file_sha256
|
|
FROM media_items mi
|
|
JOIN library_visibility lv ON mi.library_id = lv.library_id
|
|
WHERE lv.user_id = $1
|
|
AND lv.is_visible = true
|
|
ORDER BY mi.title ASC
|
|
LIMIT 1000
|
|
`
|
|
|
|
type GetUserMediaItemsForSyncRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
|
|
FormatGroup string `db:"format_group" json:"format_group"`
|
|
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"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
}
|
|
|
|
func (q *Queries) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserMediaItemsForSync, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserMediaItemsForSyncRow{}
|
|
for rows.Next() {
|
|
var i GetUserMediaItemsForSyncRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
&i.MimeType,
|
|
&i.PageCount,
|
|
&i.FormatGroup,
|
|
&i.TotalCharacters,
|
|
&i.ChapterCount,
|
|
&i.EntitlementID,
|
|
&i.RevisionNumber,
|
|
&i.FileSize,
|
|
&i.FileSha256,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserPasswordHash = `-- name: GetUserPasswordHash :one
|
|
SELECT password_hash FROM users WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) {
|
|
row := q.db.QueryRow(ctx, GetUserPasswordHash, id)
|
|
var password_hash string
|
|
err := row.Scan(&password_hash)
|
|
return password_hash, err
|
|
}
|
|
|
|
const GetUserProgressForBooks = `-- name: GetUserProgressForBooks :many
|
|
SELECT
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.user_id = $1
|
|
AND rp.media_item_id = ANY($2::uuid[])
|
|
ORDER BY rp.last_read_at DESC
|
|
`
|
|
|
|
type GetUserProgressForBooksParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
type GetUserProgressForBooksRow struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
|
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
|
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
|
|
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserProgressForBooks, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserProgressForBooksRow{}
|
|
for rows.Next() {
|
|
var i GetUserProgressForBooksRow
|
|
if err := rows.Scan(
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Percentage,
|
|
&i.CharacterOffset,
|
|
&i.Epubcfi,
|
|
&i.Chapter,
|
|
&i.ChapterProgress,
|
|
&i.CurrentPage,
|
|
&i.TotalPages,
|
|
&i.LastReadAt,
|
|
&i.LastSyncDevice,
|
|
&i.LastSyncSource,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FilePath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserReadingActiveDays = `-- name: GetUserReadingActiveDays :many
|
|
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE $1::text)::date::text AS day
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = $2
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $3
|
|
AND ($4::uuid IS NULL OR mi.library_id = $4::uuid)
|
|
AND ($5::uuid[] IS NULL OR mi.library_id = ANY($5::uuid[]))
|
|
ORDER BY day ASC
|
|
`
|
|
|
|
type GetUserReadingActiveDaysParams struct {
|
|
Tz string `db:"tz" json:"tz"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
// Distinct ACTIVE days (any captured session) bucketed in the user's
|
|
// timezone — the streak calendar. Scoped to a long look-back so the
|
|
// "longest streak" and a current streak longer than the selected range
|
|
// are both real.
|
|
func (q *Queries) GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error) {
|
|
rows, err := q.db.Query(ctx, GetUserReadingActiveDays,
|
|
arg.Tz,
|
|
arg.UserID,
|
|
arg.StartTime,
|
|
arg.LibraryID,
|
|
arg.VisibleLibraryIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []string{}
|
|
for rows.Next() {
|
|
var day string
|
|
if err := rows.Scan(&day); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, day)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserReadingHistory = `-- name: GetUserReadingHistory :many
|
|
|
|
SELECT
|
|
rh.id,
|
|
rh.user_id,
|
|
rh.media_item_id,
|
|
rh.device_id,
|
|
rh.progress_percentage,
|
|
rh.reading_session_start,
|
|
rh.reading_session_end,
|
|
rh.pages_read,
|
|
rh.time_spent_seconds,
|
|
rh.device_metadata,
|
|
rh.created_at,
|
|
mi.title,
|
|
mi.author,
|
|
d.device_name,
|
|
d.device_type
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON rh.media_item_id = mi.id
|
|
LEFT JOIN devices d ON rh.device_id = d.id
|
|
WHERE rh.user_id = $1
|
|
AND rh.created_at >= $2
|
|
AND rh.created_at <= $3
|
|
ORDER BY rh.created_at DESC
|
|
`
|
|
|
|
type GetUserReadingHistoryParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
CreatedAt_2 pgtype.Timestamptz `db:"created_at_2" json:"created_at_2"`
|
|
}
|
|
|
|
type GetUserReadingHistoryRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
DeviceName pgtype.Text `db:"device_name" json:"device_name"`
|
|
DeviceType pgtype.Text `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Analytics queries
|
|
// Get user reading history for analytics
|
|
func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserReadingHistory, arg.UserID, arg.CreatedAt, arg.CreatedAt_2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserReadingHistoryRow{}
|
|
for rows.Next() {
|
|
var i GetUserReadingHistoryRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.ProgressPercentage,
|
|
&i.ReadingSessionStart,
|
|
&i.ReadingSessionEnd,
|
|
&i.PagesRead,
|
|
&i.TimeSpentSeconds,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserReadingSessionsRange = `-- name: GetUserReadingSessionsRange :many
|
|
|
|
SELECT
|
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
|
rh.reading_session_start, rh.reading_session_end,
|
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
|
mi.title, mi.author, mi.library_id, mi.cover_image_path,
|
|
mi.library_type_name
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = $1
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
|
AND ($4::uuid IS NULL OR mi.library_id = $4::uuid)
|
|
AND ($5::uuid[] IS NULL OR mi.library_id = ANY($5::uuid[]))
|
|
ORDER BY rh.created_at ASC
|
|
`
|
|
|
|
type GetUserReadingSessionsRangeParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
type GetUserReadingSessionsRangeRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
|
}
|
|
|
|
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
|
// Every stats query scopes to the requesting user's VISIBLE libraries
|
|
// (family lens at the source) and single scan of reading_history.
|
|
// Full session rows for a range (summary math runs in Go: tz buckets,
|
|
// streaks, finished-book detection). The bucket/range key is the session
|
|
// END instant (the push time; NULL falls back to created_at) — the same
|
|
// value the capture stamps every write with.
|
|
func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserReadingSessionsRange,
|
|
arg.UserID,
|
|
arg.StartTime,
|
|
arg.EndTime,
|
|
arg.LibraryID,
|
|
arg.VisibleLibraryIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserReadingSessionsRangeRow{}
|
|
for rows.Next() {
|
|
var i GetUserReadingSessionsRangeRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.ProgressPercentage,
|
|
&i.ReadingSessionStart,
|
|
&i.ReadingSessionEnd,
|
|
&i.PagesRead,
|
|
&i.TimeSpentSeconds,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.LibraryID,
|
|
&i.CoverImagePath,
|
|
&i.LibraryTypeName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetUserSettingByKey = `-- name: GetUserSettingByKey :one
|
|
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = $2
|
|
`
|
|
|
|
type GetUserSettingByKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// GENERIC reader_settings KV (podcast order override, plan D5: key
|
|
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
|
|
// hard-keyed to 'reader_settings'; these two are the generic form.
|
|
// ============================================================================
|
|
func (q *Queries) GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error) {
|
|
row := q.db.QueryRow(ctx, GetUserSettingByKey, arg.UserID, arg.SettingKey)
|
|
var setting_value []byte
|
|
err := row.Scan(&setting_value)
|
|
return setting_value, err
|
|
}
|
|
|
|
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
|
|
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds, lt.name as type_name, lt.description as type_description,
|
|
COALESCE(lv.is_visible, true) as is_visible
|
|
FROM libraries l
|
|
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
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
ORDER BY l.created_at ASC
|
|
`
|
|
|
|
type GetUserVisibleLibrariesRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
TypeName string `db:"type_name" json:"type_name"`
|
|
TypeDescription pgtype.Text `db:"type_description" json:"type_description"`
|
|
IsVisible bool `db:"is_visible" json:"is_visible"`
|
|
}
|
|
|
|
func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) {
|
|
rows, err := q.db.Query(ctx, GetUserVisibleLibraries, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetUserVisibleLibrariesRow{}
|
|
for rows.Next() {
|
|
var i GetUserVisibleLibrariesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
&i.TypeName,
|
|
&i.TypeDescription,
|
|
&i.IsVisible,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetVisibleLibraryMediaCounts = `-- name: GetVisibleLibraryMediaCounts :many
|
|
SELECT l.id, COUNT(mi.id) as media_count
|
|
FROM libraries l
|
|
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 AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND mi.missing_scan_count = 0
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
GROUP BY l.id
|
|
`
|
|
|
|
type GetVisibleLibraryMediaCountsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaCount int64 `db:"media_count" json:"media_count"`
|
|
}
|
|
|
|
func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) {
|
|
rows, err := q.db.Query(ctx, GetVisibleLibraryMediaCounts, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetVisibleLibraryMediaCountsRow{}
|
|
for rows.Next() {
|
|
var i GetVisibleLibraryMediaCountsRow
|
|
if err := rows.Scan(&i.ID, &i.MediaCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
|
WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.title ASC
|
|
`
|
|
|
|
type GetVisibleLiveMediaForRulesParams struct {
|
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
type GetVisibleLiveMediaForRulesRow struct {
|
|
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
|
ViewerPercentage float64 `db:"viewer_percentage" json:"viewer_percentage"`
|
|
}
|
|
|
|
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
// membership comes from the candidate book set instead of collection_items.
|
|
// The set is scoped to the requesting user's visible libraries (family
|
|
// lens enforced at the source) and live items only — the same filters the
|
|
// static collection paths apply. The viewer's progress rides along (plan
|
|
// item 2.4): the played/unplayed episode rule evaluates per-user, exactly
|
|
// like the hide_finished predicate (percentage >= 0.999 = finished).
|
|
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) {
|
|
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.ViewerUserID, arg.LibraryID, arg.VisibleLibraryIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GetVisibleLiveMediaForRulesRow{}
|
|
for rows.Next() {
|
|
var i GetVisibleLiveMediaForRulesRow
|
|
if err := rows.Scan(
|
|
&i.MediaItems.ID,
|
|
&i.MediaItems.LibraryID,
|
|
&i.MediaItems.Title,
|
|
&i.MediaItems.Subtitle,
|
|
&i.MediaItems.Author,
|
|
&i.MediaItems.Isbn,
|
|
&i.MediaItems.Description,
|
|
&i.MediaItems.FilePath,
|
|
&i.MediaItems.FileSize,
|
|
&i.MediaItems.MimeType,
|
|
&i.MediaItems.CoverImagePath,
|
|
&i.MediaItems.Series,
|
|
&i.MediaItems.SeriesNumber,
|
|
&i.MediaItems.Tags,
|
|
&i.MediaItems.Narrators,
|
|
&i.MediaItems.Authors,
|
|
&i.MediaItems.AuthorsSearch,
|
|
&i.MediaItems.Publishers,
|
|
&i.MediaItems.PublishersSearch,
|
|
&i.MediaItems.Abridged,
|
|
&i.MediaItems.Explicit,
|
|
&i.MediaItems.Asin,
|
|
&i.MediaItems.DatePublished,
|
|
&i.MediaItems.Publisher,
|
|
&i.MediaItems.Contributors,
|
|
&i.MediaItems.Language,
|
|
&i.MediaItems.Edition,
|
|
&i.MediaItems.PageCount,
|
|
&i.MediaItems.Genre,
|
|
&i.MediaItems.CopyrightYear,
|
|
&i.MediaItems.GoodreadsID,
|
|
&i.MediaItems.OpenlibraryID,
|
|
&i.MediaItems.GoogleBooksID,
|
|
&i.MediaItems.AddedByAdminID,
|
|
&i.MediaItems.CreatedAt,
|
|
&i.MediaItems.ImportedAt,
|
|
&i.MediaItems.UpdatedAt,
|
|
&i.MediaItems.FormatGroup,
|
|
&i.MediaItems.FormatMimetype,
|
|
&i.MediaItems.IsReflowable,
|
|
&i.MediaItems.HasFixedLayout,
|
|
&i.MediaItems.TotalCharacters,
|
|
&i.MediaItems.ChapterCount,
|
|
&i.MediaItems.EntitlementID,
|
|
&i.MediaItems.RevisionNumber,
|
|
&i.MediaItems.KoboContentID,
|
|
&i.MediaItems.KoboMetadata,
|
|
&i.MediaItems.MangaType,
|
|
&i.MediaItems.ReadingDirection,
|
|
&i.MediaItems.SeriesCount,
|
|
&i.MediaItems.Volume,
|
|
&i.MediaItems.Imprint,
|
|
&i.MediaItems.AgeRating,
|
|
&i.MediaItems.WebUrl,
|
|
&i.MediaItems.StoryArc,
|
|
&i.MediaItems.IsBlackAndWhite,
|
|
&i.MediaItems.MetadataNotes,
|
|
&i.MediaItems.CommunityRating,
|
|
&i.MediaItems.AlternateInfo,
|
|
&i.MediaItems.ScanInformation,
|
|
&i.MediaItems.Summary,
|
|
&i.MediaItems.MetadataOverrides,
|
|
&i.MediaItems.MissingScanCount,
|
|
&i.MediaItems.ArchivedAt,
|
|
&i.MediaItems.ChapterMetadata,
|
|
&i.MediaItems.LibraryTypeName,
|
|
&i.MediaItems.AudioMetadata,
|
|
&i.MediaItems.TagsSearch,
|
|
&i.MediaItems.ContributorsSearch,
|
|
&i.MediaItems.NarratorsSearch,
|
|
&i.MediaItems.FileSha256,
|
|
&i.MediaItems.OpfIdentifier,
|
|
&i.MediaItems.OpfUuid,
|
|
&i.MediaItems.HashConfidence,
|
|
&i.MediaItems.Source,
|
|
&i.MediaItems.SourceFeedID,
|
|
&i.MediaItems.EpisodeGuid,
|
|
&i.MediaItems.FeedPosition,
|
|
&i.MediaItems.SeasonNumber,
|
|
&i.MediaItems.EpisodeNumber,
|
|
&i.MediaItems.EnclosureUrl,
|
|
&i.MediaItems.DeclaredDurationSeconds,
|
|
&i.MediaItems.TranscriptUrl,
|
|
&i.MediaItems.ChaptersUrl,
|
|
&i.MediaItems.Hosts,
|
|
&i.MediaItems.Guests,
|
|
&i.MediaItems.DownloadState,
|
|
&i.MediaItems.DownloadError,
|
|
&i.MediaItems.DownloadAttempts,
|
|
&i.MediaItems.DownloadQueuePos,
|
|
&i.ViewerPercentage,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM sync_conflicts
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND resolution_status != 'unresolved'
|
|
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
|
)
|
|
`
|
|
|
|
type HasRecentConflictResolutionParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) {
|
|
row := q.db.QueryRow(ctx, HasRecentConflictResolution, arg.MediaItemID, arg.UserID)
|
|
var exists bool
|
|
err := row.Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
|
|
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
// Soft-dismiss one row (ownership is checked by the caller against the
|
|
// device's user_id before calling this).
|
|
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
|
|
return err
|
|
}
|
|
|
|
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
|
|
UPDATE sync_queue
|
|
SET
|
|
attempts = attempts + 1,
|
|
status = CASE
|
|
WHEN attempts + 1 >= max_attempts THEN 'failed'
|
|
ELSE 'pending'
|
|
END,
|
|
error_message = $2
|
|
WHERE id = $1
|
|
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
|
|
`
|
|
|
|
type IncrementSyncQueueAttemptsParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
|
|
}
|
|
|
|
func (q *Queries) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) {
|
|
row := q.db.QueryRow(ctx, IncrementSyncQueueAttempts, arg.ID, arg.ErrorMessage)
|
|
var i SyncQueue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const IsBookInCollection = `-- name: IsBookInCollection :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM collection_items
|
|
WHERE collection_id = $1 AND media_item_id = $2
|
|
) as in_collection
|
|
`
|
|
|
|
type IsBookInCollectionParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
// Check if book is in collection
|
|
func (q *Queries) IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) {
|
|
row := q.db.QueryRow(ctx, IsBookInCollection, arg.CollectionID, arg.MediaItemID)
|
|
var in_collection bool
|
|
err := row.Scan(&in_collection)
|
|
return in_collection, err
|
|
}
|
|
|
|
const IsBookOnKoboShelf = `-- name: IsBookOnKoboShelf :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM kobo_shelves
|
|
WHERE device_id = $1 AND media_item_id = $2
|
|
) as on_shelf
|
|
`
|
|
|
|
type IsBookOnKoboShelfParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error) {
|
|
row := q.db.QueryRow(ctx, IsBookOnKoboShelf, arg.DeviceID, arg.MediaItemID)
|
|
var on_shelf bool
|
|
err := row.Scan(&on_shelf)
|
|
return on_shelf, err
|
|
}
|
|
|
|
const LinkUnlinkedBook = `-- name: LinkUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
media_item_id = $2,
|
|
confidence_score = $3,
|
|
resolved = true,
|
|
resolved_at = NOW(),
|
|
resolution_method = 'manual_link'
|
|
WHERE id = $1
|
|
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
|
`
|
|
|
|
type LinkUnlinkedBookParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// Link unlinked book to media item
|
|
func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, LinkUnlinkedBook, arg.ID, arg.MediaItemID, arg.ConfidenceScore)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const ListAllConflictsByUserAndStatus = `-- name: ListAllConflictsByUserAndStatus :many
|
|
SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
|
ORDER BY sc.created_at DESC
|
|
`
|
|
|
|
type ListAllConflictsByUserAndStatusParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
|
}
|
|
|
|
type ListAllConflictsByUserAndStatusRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
|
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
|
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
|
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) {
|
|
rows, err := q.db.Query(ctx, ListAllConflictsByUserAndStatus, arg.UserID, arg.ResolutionStatus)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListAllConflictsByUserAndStatusRow{}
|
|
for rows.Next() {
|
|
var i ListAllConflictsByUserAndStatusRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListAllPodcastFeedsAdmin = `-- name: ListAllPodcastFeedsAdmin :many
|
|
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
|
l.name AS library_name,
|
|
(SELECT COUNT(*) FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
|
(SELECT COUNT(*) FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
|
AND m.download_state = 'downloaded') AS episode_downloaded,
|
|
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
|
AND m.download_state = 'downloaded') AS disk_bytes,
|
|
(SELECT COUNT(*) FROM podcast_subscriptions s
|
|
WHERE s.feed_id = f.id) AS subscriber_count,
|
|
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
|
FROM podcast_subscriptions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.feed_id = f.id) AS subscriber_names
|
|
FROM podcast_feeds f
|
|
JOIN libraries l ON l.id = f.library_id
|
|
ORDER BY 9 DESC, f.title ASC
|
|
`
|
|
|
|
type ListAllPodcastFeedsAdminRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Host pgtype.Text `db:"host" json:"host"`
|
|
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
EpisodeTotal int64 `db:"episode_total" json:"episode_total"`
|
|
EpisodeDownloaded int64 `db:"episode_downloaded" json:"episode_downloaded"`
|
|
DiskBytes int64 `db:"disk_bytes" json:"disk_bytes"`
|
|
SubscriberCount int64 `db:"subscriber_count" json:"subscriber_count"`
|
|
SubscriberNames string `db:"subscriber_names" json:"subscriber_names"`
|
|
}
|
|
|
|
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
|
// EVERY user with its maintenance aggregates. Runs under the first-
|
|
// admin identity (invariant 12 — same contract as the poller). Sorted
|
|
// by disk usage DESC: the maintenance question is "what's eating the
|
|
// disk", so the hogs surface first.
|
|
func (q *Queries) ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error) {
|
|
rows, err := q.db.Query(ctx, ListAllPodcastFeedsAdmin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListAllPodcastFeedsAdminRow{}
|
|
for rows.Next() {
|
|
var i ListAllPodcastFeedsAdminRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.Host,
|
|
&i.LastFetchedAt,
|
|
&i.LibraryID,
|
|
&i.LibraryName,
|
|
&i.EpisodeTotal,
|
|
&i.EpisodeDownloaded,
|
|
&i.DiskBytes,
|
|
&i.SubscriberCount,
|
|
&i.SubscriberNames,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
|
|
SELECT
|
|
sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at,
|
|
d.device_name,
|
|
d.device_type,
|
|
u.email as user_email,
|
|
mi.title as media_title
|
|
FROM sync_queue sq
|
|
JOIN devices d ON sq.device_id = d.id
|
|
JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
|
|
ORDER BY sq.priority ASC, sq.created_at DESC
|
|
LIMIT $1 OFFSET $2
|
|
`
|
|
|
|
type ListAllSyncQueueItemsParams struct {
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
Offset int32 `db:"offset" json:"offset"`
|
|
}
|
|
|
|
type ListAllSyncQueueItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
SyncType string `db:"sync_type" json:"sync_type"`
|
|
SyncData []byte `db:"sync_data" json:"sync_data"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
Attempts pgtype.Int4 `db:"attempts" json:"attempts"`
|
|
MaxAttempts pgtype.Int4 `db:"max_attempts" json:"max_attempts"`
|
|
Status pgtype.Text `db:"status" json:"status"`
|
|
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
ProcessedAt pgtype.Timestamptz `db:"processed_at" json:"processed_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
UserEmail string `db:"user_email" json:"user_email"`
|
|
MediaTitle pgtype.Text `db:"media_title" json:"media_title"`
|
|
}
|
|
|
|
func (q *Queries) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, ListAllSyncQueueItems, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListAllSyncQueueItemsRow{}
|
|
for rows.Next() {
|
|
var i ListAllSyncQueueItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.UserEmail,
|
|
&i.MediaTitle,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListConflictsByUser = `-- name: ListConflictsByUser :many
|
|
SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1
|
|
ORDER BY sc.created_at DESC
|
|
`
|
|
|
|
type ListConflictsByUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
|
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
|
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
|
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
func (q *Queries) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error) {
|
|
rows, err := q.db.Query(ctx, ListConflictsByUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListConflictsByUserRow{}
|
|
for rows.Next() {
|
|
var i ListConflictsByUserRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListDeletedAnnotationsForBook = `-- name: ListDeletedAnnotationsForBook :many
|
|
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.selection_text as display_text,
|
|
mh.note_text as secondary_text,
|
|
mh.color,
|
|
mh.deleted_at,
|
|
mh.created_at
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.content as display_text,
|
|
NULL::text as secondary_text,
|
|
NULL::text as color,
|
|
mn.deleted_at,
|
|
mn.created_at
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.title as display_text,
|
|
mb.notes as secondary_text,
|
|
NULL::text as color,
|
|
mb.deleted_at,
|
|
mb.created_at
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
|
ORDER BY deleted_at DESC
|
|
`
|
|
|
|
type ListDeletedAnnotationsForBookParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
type ListDeletedAnnotationsForBookRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
|
DisplayText string `db:"display_text" json:"display_text"`
|
|
SecondaryText pgtype.Text `db:"secondary_text" json:"secondary_text"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// ============================================
|
|
// ANNOTATION HISTORY (deleted-annotation archive)
|
|
// ============================================
|
|
// Lists every currently-tombstoned annotation for a book regardless of the
|
|
// tombstone TTL: this backs the book page's "recently deleted" history where
|
|
// users can restore or permanently remove entries. Rows whose tombstones have
|
|
// been purged by the daily maintenance sweep no longer exist at all.
|
|
func (q *Queries) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) {
|
|
rows, err := q.db.Query(ctx, ListDeletedAnnotationsForBook, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDeletedAnnotationsForBookRow{}
|
|
for rows.Next() {
|
|
var i ListDeletedAnnotationsForBookRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DedupKey,
|
|
&i.AnnotationType,
|
|
&i.DisplayText,
|
|
&i.SecondaryText,
|
|
&i.Color,
|
|
&i.DeletedAt,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListDevicesByType = `-- name: ListDevicesByType :many
|
|
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC
|
|
`
|
|
|
|
func (q *Queries) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) {
|
|
rows, err := q.db.Query(ctx, ListDevicesByType, deviceType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Devices{}
|
|
for rows.Next() {
|
|
var i Devices
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListDevicesByUser = `-- name: ListDevicesByUser :many
|
|
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE user_id = $1 ORDER BY created_at DESC
|
|
`
|
|
|
|
func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) {
|
|
rows, err := q.db.Query(ctx, ListDevicesByUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Devices{}
|
|
for rows.Next() {
|
|
var i Devices
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListFeedSubscribers = `-- name: ListFeedSubscribers :many
|
|
SELECT user_id, auto_download, auto_download_count
|
|
FROM podcast_subscriptions
|
|
WHERE feed_id = $1
|
|
`
|
|
|
|
type ListFeedSubscribersRow struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
|
}
|
|
|
|
// Download-worker policy union (D6): most aggressive policy wins for
|
|
// what arrives. Runs under an admin identity (background job).
|
|
func (q *Queries) ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ([]ListFeedSubscribersRow, error) {
|
|
rows, err := q.db.Query(ctx, ListFeedSubscribers, feedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListFeedSubscribersRow{}
|
|
for rows.Next() {
|
|
var i ListFeedSubscribersRow
|
|
if err := rows.Scan(&i.UserID, &i.AutoDownload, &i.AutoDownloadCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name,
|
|
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
|
|
twin_library.name AS match_library_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
|
|
LEFT JOIN LATERAL (
|
|
SELECT t.id, t.title, t.library_id
|
|
FROM media_items t
|
|
JOIN libraries tl ON t.library_id = tl.id
|
|
JOIN library_types tlt ON tl.library_type_id = tlt.id
|
|
WHERE t.file_sha256 = mi.file_sha256
|
|
AND t.id <> mi.id
|
|
AND t.archived_at IS NULL
|
|
AND t.missing_scan_count = 0
|
|
ORDER BY (tlt.id = lt.id) DESC,
|
|
(tl.id = l.id) DESC,
|
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
|
|
t.created_at ASC
|
|
LIMIT 1
|
|
) twin ON true
|
|
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
|
|
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
|
|
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC
|
|
`
|
|
|
|
type ListHiddenMediaItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
MatchID pgtype.UUID `db:"match_id" json:"match_id"`
|
|
MatchTitle string `db:"match_title" json:"match_title"`
|
|
MatchLibraryName pgtype.Text `db:"match_library_name" json:"match_library_name"`
|
|
}
|
|
|
|
// Items hidden from libraries: either missing from disk (waiting on the
|
|
// second scan that archives them) or already archived and pending purge.
|
|
// Feeds the admin archived-items page. match_* columns identify an active
|
|
// item with identical content (same SHA-256) so the UI can offer merging the
|
|
// archived row's reading data into its active twin instead of waiting for a
|
|
// file that will never return.
|
|
func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, ListHiddenMediaItems)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListHiddenMediaItemsRow{}
|
|
for rows.Next() {
|
|
var i ListHiddenMediaItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&i.LibraryName,
|
|
&i.LibraryTypeName_2,
|
|
&i.MatchID,
|
|
&i.MatchTitle,
|
|
&i.MatchLibraryName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
|
|
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
|
ORDER BY ub.ignored_at DESC
|
|
`
|
|
|
|
type ListIgnoredUnlinkedBooksForUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Page list, ignored bucket: rows the user dismissed, kept for the
|
|
// collapsible "Ignored" section so a misclick is recoverable.
|
|
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListIgnoredUnlinkedBooksForUserRow{}
|
|
for rows.Next() {
|
|
var i ListIgnoredUnlinkedBooksForUserRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListLibraries = `-- name: ListLibraries :many
|
|
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
ORDER BY l.created_at DESC
|
|
`
|
|
|
|
type ListLibrariesRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
LibraryTypeID pgtype.UUID `db:"library_type_id" json:"library_type_id"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
|
TypeName string `db:"type_name" json:"type_name"`
|
|
TypeDescription pgtype.Text `db:"type_description" json:"type_description"`
|
|
}
|
|
|
|
func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) {
|
|
rows, err := q.db.Query(ctx, ListLibraries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListLibrariesRow{}
|
|
for rows.Next() {
|
|
var i ListLibrariesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
&i.TypeName,
|
|
&i.TypeDescription,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListMediaItems = `-- name: ListMediaItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts')
|
|
AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
|
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
|
|
`
|
|
|
|
type ListMediaItemsParams struct {
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
Offset int32 `db:"offset" json:"offset"`
|
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
|
}
|
|
|
|
type ListMediaItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
}
|
|
|
|
// Podcasts exclusion (plan §9): generic book surfaces never list
|
|
// podcast episodes (library_type_name is the denormalized, trigger/
|
|
// fetcher-maintained discriminator; NULL rows predate it and stay).
|
|
func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, ListMediaItems, arg.Limit, arg.Offset, arg.VisibleLibraryIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListMediaItemsRow{}
|
|
for rows.Next() {
|
|
var i ListMediaItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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 ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.created_at DESC
|
|
`
|
|
|
|
type ListMediaItemsByLibraryRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
}
|
|
|
|
func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) {
|
|
rows, err := q.db.Query(ctx, ListMediaItemsByLibrary, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListMediaItemsByLibraryRow{}
|
|
for rows.Next() {
|
|
var i ListMediaItemsByLibraryRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
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.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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 ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
|
|
`
|
|
|
|
type ListMediaItemsBySHA256AndLibraryParams struct {
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
|
func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, ListMediaItemsBySHA256AndLibrary, arg.FileSha256, arg.LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos 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)
|
|
func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, ListMediaItemsMissingHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.library_id = $1
|
|
ORDER BY
|
|
CASE
|
|
WHEN $2 = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'series ASC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'series DESC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'date_published ASC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'date_published DESC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $2 = 'genre ASC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $2 = 'genre DESC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END DESC,
|
|
mi.created_at DESC
|
|
LIMIT $4 OFFSET $3
|
|
`
|
|
|
|
type ListMediaItemsSortedParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Sort interface{} `db:"sort" json:"sort"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type ListMediaItemsSortedRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
}
|
|
|
|
func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error) {
|
|
rows, err := q.db.Query(ctx, ListMediaItemsSorted,
|
|
arg.LibraryID,
|
|
arg.Sort,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListMediaItemsSortedRow{}
|
|
for rows.Next() {
|
|
var i ListMediaItemsSortedRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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 ListMyPodcastFeeds = `-- name: ListMyPodcastFeeds :many
|
|
SELECT f.id, f.library_id, f.feed_url, f.podcast_guid, f.title, f.description, f.host, f.publisher, f.owner_name, f.owner_email, f.language, f.tags, f.explicit, f.artwork_path, f.feed_type, f.medium, f.is_complete, f.etag, f.last_modified, f.last_fetched_at, f.last_build_date, f.created_by_admin_id, f.created_at, f.updated_at,
|
|
s.display_name,
|
|
s.auto_download,
|
|
s.auto_download_count,
|
|
s.created_at AS subscribed_at
|
|
FROM podcast_subscriptions s
|
|
JOIN podcast_feeds f ON f.id = s.feed_id
|
|
WHERE s.user_id = $1
|
|
ORDER BY COALESCE(s.display_name, f.title) ASC
|
|
`
|
|
|
|
type ListMyPodcastFeedsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
|
Title string `db:"title" json:"title"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Host pgtype.Text `db:"host" json:"host"`
|
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
|
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
|
|
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
|
|
Language pgtype.Text `db:"language" json:"language"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
|
|
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
|
|
Medium pgtype.Text `db:"medium" json:"medium"`
|
|
IsComplete bool `db:"is_complete" json:"is_complete"`
|
|
Etag pgtype.Text `db:"etag" json:"etag"`
|
|
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
|
|
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
|
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
|
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
|
SubscribedAt pgtype.Timestamptz `db:"subscribed_at" json:"subscribed_at"`
|
|
}
|
|
|
|
// The podcasts page: the acting user's subscriptions with feed metadata
|
|
// and the user-owned subscription fields. Facet values derive in Go.
|
|
func (q *Queries) ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error) {
|
|
rows, err := q.db.Query(ctx, ListMyPodcastFeeds, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListMyPodcastFeedsRow{}
|
|
for rows.Next() {
|
|
var i ListMyPodcastFeedsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.DisplayName,
|
|
&i.AutoDownload,
|
|
&i.AutoDownloadCount,
|
|
&i.SubscribedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListPendingHashConflicts = `-- name: ListPendingHashConflicts :many
|
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
|
l.name AS library_name,
|
|
COUNT(mi.id) AS item_count
|
|
FROM hash_conflicts hc
|
|
JOIN libraries l ON l.id = hc.library_id
|
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
|
WHERE hc.status = 'pending'
|
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
|
ORDER BY hc.created_at
|
|
`
|
|
|
|
type ListPendingHashConflictsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
ItemCount int64 `db:"item_count" json:"item_count"`
|
|
}
|
|
|
|
func (q *Queries) ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error) {
|
|
rows, err := q.db.Query(ctx, ListPendingHashConflicts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListPendingHashConflictsRow{}
|
|
for rows.Next() {
|
|
var i ListPendingHashConflictsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FileSha256,
|
|
&i.CreatedAt,
|
|
&i.LibraryName,
|
|
&i.ItemCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListPendingSyncQueueItems = `-- name: ListPendingSyncQueueItems :many
|
|
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
|
|
WHERE ($1::uuid IS NULL OR device_id = $1::uuid) AND status = 'pending'
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT $2::int
|
|
`
|
|
|
|
type ListPendingSyncQueueItemsParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
RowLimit int32 `db:"row_limit" json:"row_limit"`
|
|
}
|
|
|
|
// DeviceID NULL = every device's pending items (the queue processor's
|
|
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
|
// silently disabled the offline flush until 2026-10-17).
|
|
func (q *Queries) ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) {
|
|
rows, err := q.db.Query(ctx, ListPendingSyncQueueItems, arg.DeviceID, arg.RowLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SyncQueue{}
|
|
for rows.Next() {
|
|
var i SyncQueue
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListPodcastEpisodes = `-- name: ListPodcastEpisodes :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC
|
|
`
|
|
|
|
// Feed detail / sequence source. RLS scopes to subscriber-or-admin;
|
|
// ordering: native position, then pub date, then guid (stable across
|
|
// re-fetches for historical rows whose position went stale).
|
|
func (q *Queries) ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, ListPodcastEpisodes, sourceFeedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListPodcastFeedsForPolling = `-- name: ListPodcastFeedsForPolling :many
|
|
SELECT id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at FROM podcast_feeds ORDER BY last_fetched_at ASC NULLS FIRST
|
|
`
|
|
|
|
// Poller worklist (admin identity): every feed with its validators and
|
|
// the back-off flag (is_complete feeds poll rarely, never disappear).
|
|
func (q *Queries) ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error) {
|
|
rows, err := q.db.Query(ctx, ListPodcastFeedsForPolling)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []PodcastFeeds{}
|
|
for rows.Next() {
|
|
var i PodcastFeeds
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListProcessingIssuesByLibrary = `-- name: ListProcessingIssuesByLibrary :many
|
|
SELECT
|
|
pi.id,
|
|
pi.media_item_id,
|
|
pi.issue_type,
|
|
pi.issue_description,
|
|
pi.severity,
|
|
pi.resolved,
|
|
pi.resolved_at,
|
|
pi.created_at,
|
|
COALESCE(mi.title, '') AS title,
|
|
COALESCE(mi.file_path, pi.file_path, '') AS file_path,
|
|
COALESCE(mi.format_group, '') AS format_group,
|
|
lt.name as library_type_name
|
|
FROM processing_issues pi
|
|
LEFT JOIN media_items mi ON pi.media_item_id = mi.id
|
|
JOIN libraries l ON pi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE pi.library_id = $1
|
|
AND pi.resolved = false
|
|
ORDER BY
|
|
CASE pi.severity
|
|
WHEN 'error' THEN 1
|
|
WHEN 'warning' THEN 2
|
|
WHEN 'info' THEN 3
|
|
END,
|
|
pi.created_at DESC
|
|
`
|
|
|
|
type ListProcessingIssuesByLibraryRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
IssueType string `db:"issue_type" json:"issue_type"`
|
|
IssueDescription string `db:"issue_description" json:"issue_description"`
|
|
Severity string `db:"severity" json:"severity"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FormatGroup string `db:"format_group" json:"format_group"`
|
|
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
|
}
|
|
|
|
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
|
// row — an inner join would silently drop the ledger's newest kind.
|
|
// Item-side projections COALESCE to keep them non-nullable strings;
|
|
// the display file path falls back to the issue's own for row-less rows.
|
|
func (q *Queries) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) {
|
|
rows, err := q.db.Query(ctx, ListProcessingIssuesByLibrary, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListProcessingIssuesByLibraryRow{}
|
|
for rows.Next() {
|
|
var i ListProcessingIssuesByLibraryRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.IssueType,
|
|
&i.IssueDescription,
|
|
&i.Severity,
|
|
&i.Resolved,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.FilePath,
|
|
&i.FormatGroup,
|
|
&i.LibraryTypeName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListQueuedPodcastEpisodes = `-- name: ListQueuedPodcastEpisodes :many
|
|
SELECT id, source_feed_id FROM media_items
|
|
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
|
ORDER BY download_queue_pos ASC NULLS LAST
|
|
`
|
|
|
|
type ListQueuedPodcastEpisodesRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
}
|
|
|
|
// Fair-share candidate scan (§0 item 4): the download pool claims in
|
|
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
|
// enqueue loop's ordering IS the download order, item 5). The worker
|
|
// groups these per feed and prefers the least-recently-served feed.
|
|
func (q *Queries) ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error) {
|
|
rows, err := q.db.Query(ctx, ListQueuedPodcastEpisodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListQueuedPodcastEpisodesRow{}
|
|
for rows.Next() {
|
|
var i ListQueuedPodcastEpisodesRow
|
|
if err := rows.Scan(&i.ID, &i.SourceFeedID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListSyncConflictsByMediaItem = `-- name: ListSyncConflictsByMediaItem :many
|
|
SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
type ListSyncConflictsByMediaItemParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) {
|
|
rows, err := q.db.Query(ctx, ListSyncConflictsByMediaItem, arg.MediaItemID, arg.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SyncConflicts{}
|
|
for rows.Next() {
|
|
var i SyncConflicts
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListSyncConflictsByUser = `-- name: ListSyncConflictsByUser :many
|
|
SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = 'unresolved'
|
|
ORDER BY sc.created_at DESC
|
|
`
|
|
|
|
type ListSyncConflictsByUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
|
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
|
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
|
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
}
|
|
|
|
func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) {
|
|
rows, err := q.db.Query(ctx, ListSyncConflictsByUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListSyncConflictsByUserRow{}
|
|
for rows.Next() {
|
|
var i ListSyncConflictsByUserRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.Title,
|
|
&i.Author,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListUndownloadedEpisodes = `-- name: ListUndownloadedEpisodes :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE source_feed_id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state <> 'downloaded'
|
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC
|
|
`
|
|
|
|
// Download All + policy union worklist.
|
|
func (q *Queries) ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, ListUndownloadedEpisodes, sourceFeedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
|
|
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.resolved = false
|
|
ORDER BY ub.last_seen_at DESC
|
|
LIMIT $1 OFFSET $2
|
|
`
|
|
|
|
type ListUnresolvedUnlinkedBooksParams struct {
|
|
Limit int32 `db:"limit" json:"limit"`
|
|
Offset int32 `db:"offset" json:"offset"`
|
|
}
|
|
|
|
type ListUnresolvedUnlinkedBooksRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// List unresolved unlinked books with pagination
|
|
func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) {
|
|
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooks, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListUnresolvedUnlinkedBooksRow{}
|
|
for rows.Next() {
|
|
var i ListUnresolvedUnlinkedBooksRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
|
|
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
|
ORDER BY ub.last_seen_at DESC
|
|
`
|
|
|
|
type ListUnresolvedUnlinkedBooksForUserRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
DeviceType string `db:"device_type" json:"device_type"`
|
|
}
|
|
|
|
// Page list: unresolved queue rows for the caller's own devices (ignored
|
|
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
|
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListUnresolvedUnlinkedBooksForUserRow{}
|
|
for rows.Next() {
|
|
var i ListUnresolvedUnlinkedBooksForUserRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ListUsers = `-- name: ListUsers :many
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
ORDER BY u.created_at DESC
|
|
`
|
|
|
|
type ListUsersRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
Role string `db:"role" json:"role"`
|
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
DeviceCount int64 `db:"device_count" json:"device_count"`
|
|
}
|
|
|
|
func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|
rows, err := q.db.Query(ctx, ListUsers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListUsersRow{}
|
|
for rows.Next() {
|
|
var i ListUsersRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Theme,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.MaxDevices,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.DeviceCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
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 MarkPodcastEpisodeDownloaded = `-- name: MarkPodcastEpisodeDownloaded :one
|
|
|
|
UPDATE media_items
|
|
SET download_state = 'downloaded',
|
|
download_error = NULL,
|
|
file_path = $2,
|
|
file_size = $3,
|
|
mime_type = $4,
|
|
cover_image_path = COALESCE($5, cover_image_path),
|
|
audio_metadata = $6,
|
|
chapter_count = $7,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND source = 'podcast_episode'
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type MarkPodcastEpisodeDownloadedParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
|
}
|
|
|
|
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
|
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
|
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
|
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
|
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
|
// reconcile the feed's declared values.
|
|
func (q *Queries) MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, MarkPodcastEpisodeDownloaded,
|
|
arg.ID,
|
|
arg.FilePath,
|
|
arg.FileSize,
|
|
arg.MimeType,
|
|
arg.CoverImagePath,
|
|
arg.AudioMetadata,
|
|
arg.ChapterCount,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const MarkPodcastEpisodeFailed = `-- name: MarkPodcastEpisodeFailed :exec
|
|
UPDATE media_items
|
|
SET download_state = 'failed',
|
|
download_error = $2,
|
|
download_attempts = CASE WHEN $3::boolean THEN 0 ELSE download_attempts END,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND source = 'podcast_episode'
|
|
`
|
|
|
|
type MarkPodcastEpisodeFailedParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
Column3 bool `db:"column_3" json:"column_3"`
|
|
}
|
|
|
|
// Failure/rejection write: state + reason + whether a human already
|
|
// intervened (manual_retry resets the auto-retry budget). Existing
|
|
// metadata/file facts are never cleared.
|
|
func (q *Queries) MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error {
|
|
_, err := q.db.Exec(ctx, MarkPodcastEpisodeFailed, arg.ID, arg.DownloadError, arg.Column3)
|
|
return err
|
|
}
|
|
|
|
const MoveMediaItemFilePath = `-- name: MoveMediaItemFilePath :exec
|
|
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
|
|
archived_at = NULL, updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type MoveMediaItemFilePathParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
}
|
|
|
|
// Content reappeared at a new location while the old path vanished: treat as
|
|
// a move. Repoints the row (and refreshes file-level fields) so reading
|
|
// history follows the book; archive/missing state is cleared separately.
|
|
func (q *Queries) MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error {
|
|
_, err := q.db.Exec(ctx, MoveMediaItemFilePath, arg.ID, arg.FilePath, arg.FileSize)
|
|
return err
|
|
}
|
|
|
|
const MoveMediaItemToLibrary = `-- name: MoveMediaItemToLibrary :exec
|
|
UPDATE media_items
|
|
SET library_id = $2,
|
|
file_path = $3,
|
|
file_size = $4,
|
|
archived_at = NULL,
|
|
missing_scan_count = 0,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type MoveMediaItemToLibraryParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FilePath string `db:"file_path" json:"file_path"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
}
|
|
|
|
// Cross-library move: identical content reappeared in a different library of
|
|
// the same type while the old file vanished. Repoints the row so reading
|
|
// history, annotations and collections follow the book; archive/missing
|
|
// state is cleared. library_type_name is refreshed by trigger.
|
|
func (q *Queries) MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) error {
|
|
_, err := q.db.Exec(ctx, MoveMediaItemToLibrary,
|
|
arg.ID,
|
|
arg.LibraryID,
|
|
arg.FilePath,
|
|
arg.FileSize,
|
|
)
|
|
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
|
|
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1
|
|
`
|
|
|
|
func (q *Queries) PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error {
|
|
_, err := q.db.Exec(ctx, PurgeExpiredBookmarkTombstones, deletedAt)
|
|
return err
|
|
}
|
|
|
|
const PurgeExpiredHighlightTombstones = `-- name: PurgeExpiredHighlightTombstones :exec
|
|
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1
|
|
`
|
|
|
|
func (q *Queries) PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error {
|
|
_, err := q.db.Exec(ctx, PurgeExpiredHighlightTombstones, deletedAt)
|
|
return err
|
|
}
|
|
|
|
const PurgeExpiredNoteTombstones = `-- name: PurgeExpiredNoteTombstones :exec
|
|
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1
|
|
`
|
|
|
|
func (q *Queries) PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error {
|
|
_, err := q.db.Exec(ctx, PurgeExpiredNoteTombstones, deletedAt)
|
|
return err
|
|
}
|
|
|
|
const PurgeMediaBookmarkByID = `-- name: PurgeMediaBookmarkByID :execrows
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type PurgeMediaBookmarkByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, PurgeMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const PurgeMediaHighlightByID = `-- name: PurgeMediaHighlightByID :execrows
|
|
DELETE FROM media_highlights
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type PurgeMediaHighlightByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
// Permanent removal from the history (distinct from the TTL-driven purge,
|
|
// which is maintenance). Scoped to the owning user and book.
|
|
func (q *Queries) PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, PurgeMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const PurgeMediaNoteByID = `-- name: PurgeMediaNoteByID :execrows
|
|
DELETE FROM media_notes
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type PurgeMediaNoteByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, PurgeMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const QueryMediaItemsByIdentifiers = `-- name: QueryMediaItemsByIdentifiers :many
|
|
SELECT
|
|
mi.id,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.isbn,
|
|
mi.asin,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_size,
|
|
mi.hash_confidence,
|
|
CASE
|
|
WHEN $1::uuid IS NOT NULL AND mi.id = $1::uuid THEN 1.0
|
|
WHEN mi.opf_uuid IS NOT NULL AND mi.opf_uuid = $2::text THEN 0.95
|
|
WHEN mi.file_sha256 IS NOT NULL AND mi.file_sha256 = $3::text THEN 0.9
|
|
WHEN mi.opf_identifier IS NOT NULL AND mi.opf_identifier = $4::text THEN 0.85
|
|
WHEN mi.isbn IS NOT NULL AND mi.isbn = $5::text THEN 0.8
|
|
WHEN mi.asin IS NOT NULL AND mi.asin = $6::text THEN 0.8
|
|
ELSE 0.5
|
|
END as confidence_score
|
|
FROM media_items mi
|
|
WHERE
|
|
($1::uuid IS NULL OR mi.id = $1::uuid)
|
|
OR ($2::text IS NULL OR mi.opf_uuid = $2::text)
|
|
OR ($3::text IS NULL OR mi.file_sha256 = $3::text)
|
|
OR ($4::text IS NULL OR mi.opf_identifier = $4::text)
|
|
OR ($5::text IS NULL OR mi.isbn = $5::text)
|
|
OR ($6::text IS NULL OR mi.asin = $6::text)
|
|
OR ($7::text IS NULL OR mi.title ILIKE '%' || $7::text || '%')
|
|
ORDER BY confidence_score DESC
|
|
`
|
|
|
|
type QueryMediaItemsByIdentifiersParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 string `db:"column_2" json:"column_2"`
|
|
Column3 string `db:"column_3" json:"column_3"`
|
|
Column4 string `db:"column_4" json:"column_4"`
|
|
Column5 string `db:"column_5" json:"column_5"`
|
|
Column6 string `db:"column_6" json:"column_6"`
|
|
Column7 string `db:"column_7" json:"column_7"`
|
|
}
|
|
|
|
type QueryMediaItemsByIdentifiersRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
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"`
|
|
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
ConfidenceScore float64 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// Query media items by multiple identifiers with confidence scoring
|
|
func (q *Queries) QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) {
|
|
rows, err := q.db.Query(ctx, QueryMediaItemsByIdentifiers,
|
|
arg.Column1,
|
|
arg.Column2,
|
|
arg.Column3,
|
|
arg.Column4,
|
|
arg.Column5,
|
|
arg.Column6,
|
|
arg.Column7,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []QueryMediaItemsByIdentifiersRow{}
|
|
for rows.Next() {
|
|
var i QueryMediaItemsByIdentifiersRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.Isbn,
|
|
&i.Asin,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.FileSize,
|
|
&i.HashConfidence,
|
|
&i.ConfidenceScore,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const ReassignLibraries = `-- name: ReassignLibraries :exec
|
|
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1
|
|
`
|
|
|
|
type ReassignLibrariesParams struct {
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedByAdminID_2 pgtype.UUID `db:"created_by_admin_id_2" json:"created_by_admin_id_2"`
|
|
}
|
|
|
|
func (q *Queries) ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error {
|
|
_, err := q.db.Exec(ctx, ReassignLibraries, arg.CreatedByAdminID, arg.CreatedByAdminID_2)
|
|
return err
|
|
}
|
|
|
|
const ReassignMediaItems = `-- name: ReassignMediaItems :exec
|
|
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1
|
|
`
|
|
|
|
type ReassignMediaItemsParams struct {
|
|
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
|
AddedByAdminID_2 pgtype.UUID `db:"added_by_admin_id_2" json:"added_by_admin_id_2"`
|
|
}
|
|
|
|
func (q *Queries) ReassignMediaItems(ctx context.Context, arg ReassignMediaItemsParams) error {
|
|
_, err := q.db.Exec(ctx, ReassignMediaItems, arg.AddedByAdminID, arg.AddedByAdminID_2)
|
|
return err
|
|
}
|
|
|
|
const RemoveBookFromCollection = `-- name: RemoveBookFromCollection :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2
|
|
`
|
|
|
|
type RemoveBookFromCollectionParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
// Remove book from collection
|
|
func (q *Queries) RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error {
|
|
_, err := q.db.Exec(ctx, RemoveBookFromCollection, arg.CollectionID, arg.MediaItemID)
|
|
return err
|
|
}
|
|
|
|
const RemoveBookFromKoboShelf = `-- name: RemoveBookFromKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND media_item_id = $2
|
|
`
|
|
|
|
type RemoveBookFromKoboShelfParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error {
|
|
_, err := q.db.Exec(ctx, RemoveBookFromKoboShelf, arg.DeviceID, arg.MediaItemID)
|
|
return err
|
|
}
|
|
|
|
const ReorderCollectionItems = `-- name: ReorderCollectionItems :exec
|
|
UPDATE collection_items AS ci
|
|
SET position = d.ord
|
|
FROM unnest($2::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
|
WHERE ci.collection_id = $1::uuid
|
|
AND ci.media_item_id = d.media_item_id
|
|
`
|
|
|
|
type ReorderCollectionItemsParams struct {
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
MediaItemIds []pgtype.UUID `db:"media_item_ids" json:"media_item_ids"`
|
|
}
|
|
|
|
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
|
// the complete ordered id list; positions become 1..n in payload order.
|
|
func (q *Queries) ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error {
|
|
_, err := q.db.Exec(ctx, ReorderCollectionItems, arg.CollectionID, arg.MediaItemIds)
|
|
return err
|
|
}
|
|
|
|
const ReparentMediaItemChildren = `-- name: ReparentMediaItemChildren :exec
|
|
SELECT reparent_media_item_children($1::uuid, $2::uuid)
|
|
`
|
|
|
|
type ReparentMediaItemChildrenParams struct {
|
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
|
Column2 pgtype.UUID `db:"column_2" json:"column_2"`
|
|
}
|
|
|
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
|
func (q *Queries) ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error {
|
|
_, err := q.db.Exec(ctx, ReparentMediaItemChildren, arg.Column1, arg.Column2)
|
|
return err
|
|
}
|
|
|
|
const ResetPodcastEpisodeFileDeleted = `-- name: ResetPodcastEpisodeFileDeleted :exec
|
|
UPDATE media_items
|
|
SET download_state = 'pending',
|
|
download_error = NULL,
|
|
download_attempts = 0,
|
|
file_size = NULL,
|
|
mime_type = NULL,
|
|
cover_image_path = NULL,
|
|
audio_metadata = NULL,
|
|
chapter_count = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded'
|
|
`
|
|
|
|
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
|
// was removed — reset the row to pending and clear the download-derived
|
|
// facts. file_path is deliberately KEPT: it is the fetch-maintained
|
|
// layout (written at fetch time, not download time), so a re-download
|
|
// lands on the same path. The poll upsert never touches download_state,
|
|
// so a deleted file stays deleted (invariant 2: polling never deletes;
|
|
// admins delete knowingly).
|
|
func (q *Queries) ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, ResetPodcastEpisodeFileDeleted, id)
|
|
return err
|
|
}
|
|
|
|
const ResetPodcastFeedFilesDeleted = `-- name: ResetPodcastFeedFilesDeleted :execrows
|
|
UPDATE media_items
|
|
SET download_state = 'pending',
|
|
download_error = NULL,
|
|
download_attempts = 0,
|
|
file_size = NULL,
|
|
mime_type = NULL,
|
|
cover_image_path = NULL,
|
|
audio_metadata = NULL,
|
|
chapter_count = NULL,
|
|
updated_at = NOW()
|
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
|
AND download_state = 'downloaded'
|
|
`
|
|
|
|
// Same reset for EVERY downloaded episode of a feed (per-feed
|
|
// "Delete files" action on the admin view).
|
|
func (q *Queries) ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, ResetPodcastFeedFilesDeleted, sourceFeedID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
|
|
UPDATE collections
|
|
SET description = $3,
|
|
icon = $4,
|
|
color = $5,
|
|
priority = $6
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true
|
|
`
|
|
|
|
type ResetSystemCollectionMetadataParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
}
|
|
|
|
func (q *Queries) ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error {
|
|
_, err := q.db.Exec(ctx, ResetSystemCollectionMetadata,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Icon,
|
|
arg.Color,
|
|
arg.Priority,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const ResolveHashConflict = `-- name: ResolveHashConflict :exec
|
|
UPDATE hash_conflicts
|
|
SET status = 'resolved',
|
|
resolution = $2,
|
|
resolved_by = $3,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type ResolveHashConflictParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Resolution pgtype.Text `db:"resolution" json:"resolution"`
|
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
|
}
|
|
|
|
func (q *Queries) ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error {
|
|
_, err := q.db.Exec(ctx, ResolveHashConflict, arg.ID, arg.Resolution, arg.ResolvedBy)
|
|
return err
|
|
}
|
|
|
|
const ResolveOpenProcessingIssuesForItem = `-- name: ResolveOpenProcessingIssuesForItem :execrows
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE media_item_id = $1
|
|
AND resolved = false
|
|
`
|
|
|
|
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
|
// item successfully closes its open issues (extraction failures heal).
|
|
func (q *Queries) ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForItem, mediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const ResolveOpenProcessingIssuesForPath = `-- name: ResolveOpenProcessingIssuesForPath :execrows
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE media_item_id IS NULL
|
|
AND library_id = $1
|
|
AND file_path = $2
|
|
AND resolved = false
|
|
`
|
|
|
|
type ResolveOpenProcessingIssuesForPathParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
}
|
|
|
|
// Row-less issues resolve by (library, file_path) when the file finally
|
|
// imports — the page heals itself without admin action.
|
|
func (q *Queries) ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForPath, arg.LibraryID, arg.FilePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const ResolveProcessingIssue = `-- name: ResolveProcessingIssue :one
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
AND media_item_id = $2
|
|
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
|
`
|
|
|
|
type ResolveProcessingIssueParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error) {
|
|
row := q.db.QueryRow(ctx, ResolveProcessingIssue, arg.ID, arg.MediaItemID)
|
|
var i ProcessingIssues
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.LibraryID,
|
|
&i.IssueType,
|
|
&i.IssueDescription,
|
|
&i.Severity,
|
|
&i.Resolved,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
&i.FilePath,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const ResolveSyncConflict = `-- name: ResolveSyncConflict :one
|
|
UPDATE sync_conflicts
|
|
SET
|
|
resolution_status = $2,
|
|
resolution_data = $3,
|
|
resolved_by = $4,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at
|
|
`
|
|
|
|
type ResolveSyncConflictParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
|
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
|
}
|
|
|
|
func (q *Queries) ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) {
|
|
row := q.db.QueryRow(ctx, ResolveSyncConflict,
|
|
arg.ID,
|
|
arg.ResolutionStatus,
|
|
arg.ResolutionData,
|
|
arg.ResolvedBy,
|
|
)
|
|
var i SyncConflicts
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.ConflictType,
|
|
&i.ConflictData,
|
|
&i.ResolutionStatus,
|
|
&i.ResolutionData,
|
|
&i.ResolvedBy,
|
|
&i.ResolvedAt,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const ResolveUnlinkedBook = `-- name: ResolveUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
resolved = true,
|
|
media_item_id = $2,
|
|
resolved_at = NOW(),
|
|
resolution_method = $3
|
|
WHERE id = $1
|
|
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
|
`
|
|
|
|
type ResolveUnlinkedBookParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
|
}
|
|
|
|
// Resolve unlinked book
|
|
func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, ResolveUnlinkedBook, arg.ID, arg.MediaItemID, arg.ResolutionMethod)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const RestoreMediaBookmarkByID = `-- name: RestoreMediaBookmarkByID :execrows
|
|
UPDATE media_bookmarks SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type RestoreMediaBookmarkByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, RestoreMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const RestoreMediaHighlightByID = `-- name: RestoreMediaHighlightByID :execrows
|
|
UPDATE media_highlights SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type RestoreMediaHighlightByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, RestoreMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const RestoreMediaNoteByID = `-- name: RestoreMediaNoteByID :execrows
|
|
UPDATE media_notes SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
|
`
|
|
|
|
type RestoreMediaNoteByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
}
|
|
|
|
func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, RestoreMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const RestoreSystemCollectionRow = `-- name: RestoreSystemCollectionRow :one
|
|
UPDATE collections
|
|
SET name = $2,
|
|
description = $3,
|
|
icon = $4,
|
|
color = $5,
|
|
priority = $6,
|
|
show_on_dashboard = true,
|
|
deleted_at = NULL
|
|
WHERE id = $1
|
|
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
|
`
|
|
|
|
type RestoreSystemCollectionRowParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
|
}
|
|
|
|
// Full reset, in place: canonical metadata, back on the dashboard,
|
|
// un-tombstoned. Item assignments are cleared by the caller ("full").
|
|
func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, RestoreSystemCollectionRow,
|
|
arg.ID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Icon,
|
|
arg.Color,
|
|
arg.Priority,
|
|
)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
|
|
`
|
|
|
|
func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, RevokeAllUserRefreshTokens, userID)
|
|
return err
|
|
}
|
|
|
|
const RevokeDevice = `-- name: RevokeDevice :exec
|
|
UPDATE devices
|
|
SET
|
|
auth_token = NULL,
|
|
sync_enabled = false,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) RevokeDevice(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, RevokeDevice, id)
|
|
return err
|
|
}
|
|
|
|
const RevokeOpdsToken = `-- name: RevokeOpdsToken :exec
|
|
DELETE FROM opds_tokens WHERE token = $1
|
|
`
|
|
|
|
// Revoke OPDS token
|
|
func (q *Queries) RevokeOpdsToken(ctx context.Context, token string) error {
|
|
_, err := q.db.Exec(ctx, RevokeOpdsToken, token)
|
|
return err
|
|
}
|
|
|
|
const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1
|
|
`
|
|
|
|
func (q *Queries) RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, RevokeRefreshToken, token)
|
|
return err
|
|
}
|
|
|
|
const SearchAuthorValues = `-- name: SearchAuthorValues :many
|
|
SELECT
|
|
mi.author as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, COALESCE(mi.author, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND word_similarity($1, COALESCE(mi.author, '')) > 0.3
|
|
AND mi.author IS NOT NULL
|
|
AND mi.author != ''
|
|
GROUP BY mi.author, word_similarity($1, COALESCE(mi.author, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchAuthorValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchAuthorValuesRow struct {
|
|
Value pgtype.Text `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchAuthorValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchAuthorValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchAuthorValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchEpisodesForPicker = `-- name: SearchEpisodesForPicker :many
|
|
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
|
|
mi.format_group, mi.download_state,
|
|
pf.title AS feed_title
|
|
FROM media_items mi
|
|
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
|
WHERE mi.source = 'podcast_episode'
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::text = '' OR mi.title ILIKE '%' || $1::text || '%')
|
|
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
|
|
LIMIT 60
|
|
`
|
|
|
|
type SearchEpisodesForPickerRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FormatGroup string `db:"format_group" json:"format_group"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
FeedTitle string `db:"feed_title" json:"feed_title"`
|
|
}
|
|
|
|
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
|
|
// The podcasts exclusion in SearchMediaItemsUnified stays (books
|
|
// surfaces never list podcasts — plan §9); THIS query is the episode
|
|
// picker's dedicated scope. Visibility is the engine's job (D20): the
|
|
// media_items policy drops episodes from unsubscribed feeds and the
|
|
// podcast_feeds join is governed the same way — non-subscribers see
|
|
// nothing here without any Go-side clause.
|
|
func (q *Queries) SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchEpisodesForPicker, q_)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchEpisodesForPickerRow{}
|
|
for rows.Next() {
|
|
var i SearchEpisodesForPickerRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.CoverImagePath,
|
|
&i.LibraryID,
|
|
&i.FormatGroup,
|
|
&i.DownloadState,
|
|
&i.FeedTitle,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchGenreValues = `-- name: SearchGenreValues :many
|
|
SELECT
|
|
mi.genre as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, COALESCE(mi.genre, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3
|
|
AND mi.genre IS NOT NULL
|
|
AND mi.genre != ''
|
|
GROUP BY mi.genre, word_similarity($1, COALESCE(mi.genre, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchGenreValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchGenreValuesRow struct {
|
|
Value pgtype.Text `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchGenreValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchGenreValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchGenreValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchLanguageValues = `-- name: SearchLanguageValues :many
|
|
SELECT
|
|
mi.language as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, COALESCE(mi.language, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND word_similarity($1, COALESCE(mi.language, '')) > 0.3
|
|
AND mi.language IS NOT NULL
|
|
AND mi.language != ''
|
|
GROUP BY mi.language, word_similarity($1, COALESCE(mi.language, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchLanguageValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchLanguageValuesRow struct {
|
|
Value pgtype.Text `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchLanguageValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchLanguageValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchLanguageValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchMediaItems = `-- name: SearchMediaItems :many
|
|
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, 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.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, 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
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
|
AND (
|
|
mi.title ILIKE $3 OR
|
|
mi.author ILIKE $3 OR
|
|
mi.series ILIKE $3 OR
|
|
$3 = ANY(mi.tags_search) OR
|
|
$3 = ANY(mi.contributors_search)
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN mi.title ILIKE $3 THEN 1
|
|
WHEN mi.author ILIKE $3 THEN 2
|
|
WHEN mi.series ILIKE $3 THEN 3
|
|
WHEN $3 = ANY(mi.tags_search) THEN 4
|
|
ELSE 5
|
|
END,
|
|
mi.title ASC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchMediaItemsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
SearchPattern pgtype.Text `db:"search_pattern" json:"search_pattern"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchMediaItemsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
Source string `db:"source" json:"source"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
DownloadState string `db:"download_state" json:"download_state"`
|
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
|
LibraryName string `db:"library_name" json:"library_name"`
|
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
|
}
|
|
|
|
// Search Media Items queries
|
|
func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchMediaItems,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.SearchPattern,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchMediaItemsRow{}
|
|
for rows.Next() {
|
|
var i SearchMediaItemsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
&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 SearchMediaItemsUnified = `-- name: SearchMediaItemsUnified :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.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.tags_search,
|
|
mi.contributors_search,
|
|
mi.narrators,
|
|
mi.narrators_search,
|
|
mi.abridged,
|
|
mi.subtitle,
|
|
mi.explicit,
|
|
mi.authors,
|
|
mi.authors_search,
|
|
mi.publishers,
|
|
mi.publishers_search,
|
|
mi.audio_metadata,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.hash_confidence,
|
|
l.name as library_name,
|
|
lt.name as library_type_name,
|
|
-- Viewer's own progress (search-result badges): NULL when the
|
|
-- requester never opened the book. Reuses the SAME requester param
|
|
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
|
-- join row-neutral.
|
|
rp.percentage as viewer_percentage
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.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 reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND ($2::uuid IS NULL
|
|
OR mi.library_id = $2::uuid)
|
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
|
-- §4.1): a co-authored book matches ANY one of its authors, and a
|
|
-- comma-joined param is multi-select (OR within the facet).
|
|
AND ($3 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($3, ',')) AS f
|
|
JOIN unnest(mi.authors) AS a ON true
|
|
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
|
|
))
|
|
-- Fuzzy series filter
|
|
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
|
|
-- Fuzzy genre filter
|
|
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
|
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
|
|
AND ($6 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($6, ',')) AS f
|
|
JOIN unnest(mi.publishers) AS p ON true
|
|
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
|
|
))
|
|
-- Fuzzy language filter
|
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
|
|
-- Tags filter (NEW - fuzzy match against tags array)
|
|
AND ($8 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($8, tag) > 0.3
|
|
))
|
|
-- Narrator filter (A2a — arrays use the *_search column)
|
|
AND ($9 = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($9, narrator) > 0.3
|
|
))
|
|
-- Facet registry Batch A additions (services/facet_registry.go is the
|
|
-- Go-side source of truth for these params). Comma = OR-within for
|
|
-- every text facet; scalar-fuzzy uses word_similarity per value,
|
|
-- exact enums use ANY(), bools are presence-bound (NULL = unset).
|
|
AND ($10 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($10, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND ($11 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($11, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND ($12 = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array($12, ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND ($13 = '' OR mi.age_rating = ANY(string_to_array($13, ',')))
|
|
AND ($14 = '' OR mi.format_group = ANY(string_to_array($14, ',')))
|
|
AND ($15 = '' OR mi.manga_type = ANY(string_to_array($15, ',')))
|
|
AND ($16 = '' OR mi.reading_direction = ANY(string_to_array($16, ',')))
|
|
AND ($17::bool IS NULL OR mi.explicit = $17::bool)
|
|
AND ($18::bool IS NULL OR mi.abridged = $18::bool)
|
|
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
|
AND (
|
|
$19 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= $19 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= $19)
|
|
)
|
|
AND (
|
|
$20 = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= $20 OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= $20)
|
|
)
|
|
-- Boolean (exact)
|
|
AND (
|
|
$21::bool IS NULL OR -- Not specified = show all
|
|
($21::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
($21::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
-- Search query (fuzzy or exact based on quotes)
|
|
AND (
|
|
$22 = '' OR
|
|
-- Fuzzy search (default)
|
|
$23 = false AND (
|
|
word_similarity($22, mi.title) > 0.3 OR
|
|
word_similarity($22, COALESCE(mi.author, '')) > 0.3 OR
|
|
word_similarity($22, COALESCE(mi.series, '')) > 0.3 OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity($22, tag) > 0.3
|
|
LIMIT 1
|
|
) OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
|
WHERE word_similarity($22, contributor) > 0.3
|
|
LIMIT 1
|
|
) OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity($22, narrator) > 0.3
|
|
LIMIT 1
|
|
)
|
|
) OR
|
|
-- Exact search (with quotes)
|
|
-- Keeping old pattern commented out in case we want wildcard exact back. See search.go line 62
|
|
-- sqlc.narg('is_exact_search') = true AND (
|
|
-- mi.title ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.author ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.series ILIKE sqlc.narg('search_pattern') OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
|
-- )
|
|
-- Exact search (with quotes) - true exact match, not substring
|
|
$23 = true AND (
|
|
mi.title = $22 OR
|
|
COALESCE(mi.author, '') = $22 OR
|
|
COALESCE(mi.series, '') = $22 OR
|
|
$22 = ANY(mi.tags_search) OR
|
|
$22 = ANY(mi.contributors_search) OR
|
|
$22 = ANY(mi.narrators_search)
|
|
)
|
|
)
|
|
ORDER BY
|
|
-- Primary sort: relevance score when searching
|
|
CASE
|
|
WHEN $22 != '' THEN
|
|
GREATEST(
|
|
CASE WHEN $23 = false THEN
|
|
word_similarity($22, mi.title)
|
|
ELSE 0 END,
|
|
CASE WHEN $23 = false THEN
|
|
word_similarity($22, COALESCE(mi.author, ''))
|
|
ELSE 0 END,
|
|
word_similarity($3, COALESCE(mi.author, '')),
|
|
word_similarity($5, COALESCE(mi.genre, '')),
|
|
word_similarity($6, COALESCE(mi.publisher, '')),
|
|
(SELECT MAX(word_similarity($8, tag))
|
|
FROM unnest(mi.tags_search) AS tag)
|
|
)
|
|
ELSE 0
|
|
END DESC,
|
|
-- Secondary sort: user-specified sort parameter
|
|
CASE
|
|
WHEN $24 = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $24 = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $24 = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $24 = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN $24 = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN $24 = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN $24 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN $24 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
-- Tertiary sort: title (default fallback)
|
|
mi.title ASC
|
|
LIMIT $26 OFFSET $25
|
|
`
|
|
|
|
type SearchMediaItemsUnifiedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
|
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
|
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
|
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
|
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
|
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
|
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
|
StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"`
|
|
ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"`
|
|
EditionFilter interface{} `db:"edition_filter" json:"edition_filter"`
|
|
AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"`
|
|
FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"`
|
|
MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"`
|
|
ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"`
|
|
ExplicitFilter pgtype.Bool `db:"explicit_filter" json:"explicit_filter"`
|
|
AbridgedFilter pgtype.Bool `db:"abridged_filter" json:"abridged_filter"`
|
|
YearMin interface{} `db:"year_min" json:"year_min"`
|
|
YearMax interface{} `db:"year_max" json:"year_max"`
|
|
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
|
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
|
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
|
Sort interface{} `db:"sort" json:"sort"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchMediaItemsUnifiedRow 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.Float8 `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"`
|
|
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"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
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 string `db:"library_type_name" json:"library_type_name"`
|
|
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
|
}
|
|
|
|
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchMediaItemsUnified,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.AuthorFilter,
|
|
arg.SeriesFilter,
|
|
arg.GenreFilter,
|
|
arg.PublisherFilter,
|
|
arg.LanguageFilter,
|
|
arg.TagsFilter,
|
|
arg.NarratorFilter,
|
|
arg.StoryArcFilter,
|
|
arg.ImprintFilter,
|
|
arg.EditionFilter,
|
|
arg.AgeRatingFilter,
|
|
arg.FormatGroupFilter,
|
|
arg.MangaTypeFilter,
|
|
arg.ReadingDirectionFilter,
|
|
arg.ExplicitFilter,
|
|
arg.AbridgedFilter,
|
|
arg.YearMin,
|
|
arg.YearMax,
|
|
arg.HasCover,
|
|
arg.SearchQuery,
|
|
arg.IsExactSearch,
|
|
arg.Sort,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchMediaItemsUnifiedRow{}
|
|
for rows.Next() {
|
|
var i SearchMediaItemsUnifiedRow
|
|
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.UpdatedAt,
|
|
&i.FormatGroup,
|
|
&i.FormatMimetype,
|
|
&i.IsReflowable,
|
|
&i.HasFixedLayout,
|
|
&i.TotalCharacters,
|
|
&i.ChapterCount,
|
|
&i.EntitlementID,
|
|
&i.RevisionNumber,
|
|
&i.KoboContentID,
|
|
&i.KoboMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.Narrators,
|
|
&i.NarratorsSearch,
|
|
&i.Abridged,
|
|
&i.Subtitle,
|
|
&i.Explicit,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.AudioMetadata,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.LibraryName,
|
|
&i.LibraryTypeName,
|
|
&i.ViewerPercentage,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchMyPodcastEpisodes = `-- name: SearchMyPodcastEpisodes :many
|
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items
|
|
WHERE source = 'podcast_episode'
|
|
AND ($1::text = '' OR title ILIKE '%' || $1::text || '%' OR description ILIKE '%' || $1::text || '%')
|
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST
|
|
LIMIT 50
|
|
`
|
|
|
|
// Subscription-scoped episode search (D14): title + description cover
|
|
// un-downloaded AND historical rows (fetcher persists metadata-only).
|
|
// RLS already restricts to the caller's subscriptions — the explicit
|
|
// source filter keeps scan items out of podcast search.
|
|
func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error) {
|
|
rows, err := q.db.Query(ctx, SearchMyPodcastEpisodes, searchQuery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []MediaItems{}
|
|
for rows.Next() {
|
|
var i MediaItems
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchMyPodcastFeeds = `-- name: SearchMyPodcastFeeds :many
|
|
SELECT f.id, f.library_id, f.feed_url, f.podcast_guid, f.title, f.description, f.host, f.publisher, f.owner_name, f.owner_email, f.language, f.tags, f.explicit, f.artwork_path, f.feed_type, f.medium, f.is_complete, f.etag, f.last_modified, f.last_fetched_at, f.last_build_date, f.created_by_admin_id, f.created_at, f.updated_at,
|
|
s.display_name,
|
|
s.auto_download,
|
|
s.auto_download_count,
|
|
s.created_at AS subscribed_at
|
|
FROM podcast_subscriptions s
|
|
JOIN podcast_feeds f ON f.id = s.feed_id
|
|
WHERE s.user_id = $1
|
|
AND ($2::text = '' OR f.title ILIKE '%' || $2::text || '%'
|
|
OR COALESCE(s.display_name, '') ILIKE '%' || $2::text || '%'
|
|
OR COALESCE(f.host, '') ILIKE '%' || $2::text || '%'
|
|
OR COALESCE(f.publisher, '') ILIKE '%' || $2::text || '%')
|
|
ORDER BY COALESCE(s.display_name, f.title) ASC
|
|
`
|
|
|
|
type SearchMyPodcastFeedsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SearchQuery string `db:"search_query" json:"search_query"`
|
|
}
|
|
|
|
type SearchMyPodcastFeedsRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
|
Title string `db:"title" json:"title"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Host pgtype.Text `db:"host" json:"host"`
|
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
|
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
|
|
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
|
|
Language pgtype.Text `db:"language" json:"language"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
|
|
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
|
|
Medium pgtype.Text `db:"medium" json:"medium"`
|
|
IsComplete bool `db:"is_complete" json:"is_complete"`
|
|
Etag pgtype.Text `db:"etag" json:"etag"`
|
|
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
|
|
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
|
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
|
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
|
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
|
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
|
SubscribedAt pgtype.Timestamptz `db:"subscribed_at" json:"subscribed_at"`
|
|
}
|
|
|
|
// Feed-title AND subscription-display-name search (D14) + the facet
|
|
// fields the podcasts page filters on.
|
|
func (q *Queries) SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchMyPodcastFeeds, arg.UserID, arg.SearchQuery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchMyPodcastFeedsRow{}
|
|
for rows.Next() {
|
|
var i SearchMyPodcastFeedsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.DisplayName,
|
|
&i.AutoDownload,
|
|
&i.AutoDownloadCount,
|
|
&i.SubscribedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchNarratorValues = `-- name: SearchNarratorValues :many
|
|
SELECT
|
|
narrator::TEXT as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, narrator)::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND narrator IS NOT NULL
|
|
AND word_similarity($1, narrator) > 0.3
|
|
GROUP BY narrator, word_similarity($1, narrator)::float8
|
|
ORDER BY word_similarity($1, narrator)::float8 DESC, COUNT DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchNarratorValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchNarratorValuesRow struct {
|
|
Value string `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchNarratorValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchNarratorValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchNarratorValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchSeriesValues = `-- name: SearchSeriesValues :many
|
|
SELECT
|
|
mi.series as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, COALESCE(mi.series, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND word_similarity($1, COALESCE(mi.series, '')) > 0.3
|
|
AND mi.series IS NOT NULL
|
|
AND mi.series != ''
|
|
GROUP BY mi.series, word_similarity($1, COALESCE(mi.series, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchSeriesValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchSeriesValuesRow struct {
|
|
Value pgtype.Text `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchSeriesValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchSeriesValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchSeriesValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SearchTagsValues = `-- name: SearchTagsValues :many
|
|
SELECT
|
|
tag::TEXT as value,
|
|
COUNT(*) as count,
|
|
word_similarity($1, tag)::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
|
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
|
AND tag IS NOT NULL
|
|
AND word_similarity($1, tag) > 0.3
|
|
GROUP BY tag, word_similarity($1, tag)::float8
|
|
ORDER BY word_similarity($1, tag)::float8 DESC, COUNT DESC
|
|
LIMIT $5 OFFSET $4
|
|
`
|
|
|
|
type SearchTagsValuesParams struct {
|
|
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
|
}
|
|
|
|
type SearchTagsValuesRow struct {
|
|
Value string `db:"value" json:"value"`
|
|
Count int64 `db:"count" json:"count"`
|
|
Score float64 `db:"score" json:"score"`
|
|
}
|
|
|
|
func (q *Queries) SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) {
|
|
rows, err := q.db.Query(ctx, SearchTagsValues,
|
|
arg.SearchQuery,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.Offset,
|
|
arg.Limit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SearchTagsValuesRow{}
|
|
for rows.Next() {
|
|
var i SearchTagsValuesRow
|
|
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const SetDeviceSyncEnabled = `-- name: SetDeviceSyncEnabled :one
|
|
UPDATE devices
|
|
SET
|
|
sync_enabled = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
type SetDeviceSyncEnabledParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"`
|
|
}
|
|
|
|
func (q *Queries) SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, SetDeviceSyncEnabled, arg.ID, arg.SyncEnabled)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const SetLibraryVisibility = `-- name: SetLibraryVisibility :one
|
|
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
is_visible = EXCLUDED.is_visible,
|
|
updated_at = NOW()
|
|
RETURNING id, user_id, library_id, is_visible, created_at, updated_at
|
|
`
|
|
|
|
type SetLibraryVisibilityParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
IsVisible bool `db:"is_visible" json:"is_visible"`
|
|
}
|
|
|
|
// Library Visibility queries
|
|
func (q *Queries) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) {
|
|
row := q.db.QueryRow(ctx, SetLibraryVisibility, arg.UserID, arg.LibraryID, arg.IsVisible)
|
|
var i LibraryVisibility
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.LibraryID,
|
|
&i.IsVisible,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const SetPodcastEpisodeDownloadQueued = `-- name: SetPodcastEpisodeDownloadQueued :execrows
|
|
UPDATE media_items
|
|
SET download_state = 'queued',
|
|
download_queue_pos = nextval('podcast_queue_seq'),
|
|
download_error = NULL,
|
|
download_attempts = download_attempts + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state IN ('pending', 'failed')
|
|
`
|
|
|
|
// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
|
|
// (§0 items 4/5) so claim order = enqueue order (the effective
|
|
// listening order the enqueue loop applied). Attempts increment at
|
|
// enqueue so a crashed worker still counts the try.
|
|
func (q *Queries) SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, SetPodcastEpisodeDownloadQueued, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const SetPodcastEpisodePending = `-- name: SetPodcastEpisodePending :exec
|
|
UPDATE media_items
|
|
SET download_state = 'pending',
|
|
download_error = NULL,
|
|
download_attempts = 0,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND source = 'podcast_episode'
|
|
`
|
|
|
|
// Manual retry (same endpoint as download): reset to pending so the
|
|
// worker re-claims it; the reason text clears with the fresh attempt.
|
|
func (q *Queries) SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, SetPodcastEpisodePending, id)
|
|
return err
|
|
}
|
|
|
|
const SetSystemConfig = `-- name: SetSystemConfig :one
|
|
INSERT INTO system_config (key, value, updated_by)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = NOW()
|
|
RETURNING key, value, updated_at, updated_by
|
|
`
|
|
|
|
type SetSystemConfigParams struct {
|
|
Key string `db:"key" json:"key"`
|
|
Value string `db:"value" json:"value"`
|
|
UpdatedBy pgtype.UUID `db:"updated_by" json:"updated_by"`
|
|
}
|
|
|
|
// Set system config
|
|
func (q *Queries) SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) {
|
|
row := q.db.QueryRow(ctx, SetSystemConfig, arg.Key, arg.Value, arg.UpdatedBy)
|
|
var i SystemConfig
|
|
err := row.Scan(
|
|
&i.Key,
|
|
&i.Value,
|
|
&i.UpdatedAt,
|
|
&i.UpdatedBy,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const SubscribePodcastFeed = `-- name: SubscribePodcastFeed :one
|
|
INSERT INTO podcast_subscriptions (user_id, feed_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (user_id, feed_id) DO NOTHING
|
|
RETURNING id, user_id, feed_id, display_name, auto_download, auto_download_count, created_at
|
|
`
|
|
|
|
type SubscribePodcastFeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
|
}
|
|
|
|
func (q *Queries) SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error) {
|
|
row := q.db.QueryRow(ctx, SubscribePodcastFeed, arg.UserID, arg.FeedID)
|
|
var i PodcastSubscriptions
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.FeedID,
|
|
&i.DisplayName,
|
|
&i.AutoDownload,
|
|
&i.AutoDownloadCount,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const SyncLibraryTypeExtensions = `-- name: SyncLibraryTypeExtensions :exec
|
|
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1
|
|
`
|
|
|
|
type SyncLibraryTypeExtensionsParams struct {
|
|
Name string `db:"name" json:"name"`
|
|
AllowedExtensions []string `db:"allowed_extensions" json:"allowed_extensions"`
|
|
}
|
|
|
|
func (q *Queries) SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error {
|
|
_, err := q.db.Exec(ctx, SyncLibraryTypeExtensions, arg.Name, arg.AllowedExtensions)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaBookmarkByDedupKey = `-- name: TombstoneMediaBookmarkByDedupKey :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE
|
|
`
|
|
|
|
type TombstoneMediaBookmarkByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaBookmarkByID = `-- name: TombstoneMediaBookmarkByID :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type TombstoneMediaBookmarkByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaBookmarkByID, arg.ID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaHighlightByDedupKey = `-- name: TombstoneMediaHighlightByDedupKey :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE
|
|
`
|
|
|
|
type TombstoneMediaHighlightByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaHighlightByID = `-- name: TombstoneMediaHighlightByID :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type TombstoneMediaHighlightByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaHighlightByID(ctx context.Context, arg TombstoneMediaHighlightByIDParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaHighlightByID, arg.ID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaNoteByDedupKey = `-- name: TombstoneMediaNoteByDedupKey :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE
|
|
`
|
|
|
|
type TombstoneMediaNoteByDedupKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey)
|
|
return err
|
|
}
|
|
|
|
const TombstoneMediaNoteByID = `-- name: TombstoneMediaNoteByID :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type TombstoneMediaNoteByIDParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error {
|
|
_, err := q.db.Exec(ctx, TombstoneMediaNoteByID, arg.ID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const TombstoneSystemCollection = `-- name: TombstoneSystemCollection :exec
|
|
UPDATE collections SET deleted_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, TombstoneSystemCollection, id)
|
|
return err
|
|
}
|
|
|
|
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
|
|
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
|
|
`
|
|
|
|
// Undo a soft-dismiss.
|
|
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
|
|
return err
|
|
}
|
|
|
|
const UpdateCollection = `-- name: UpdateCollection :one
|
|
UPDATE collections
|
|
SET
|
|
name = $2,
|
|
description = $3,
|
|
color = $4,
|
|
icon = $5,
|
|
auto_assign_rules = $6,
|
|
view_settings = $7,
|
|
rule_match_mode = $8,
|
|
filter_tree = $9
|
|
WHERE id = $1
|
|
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
|
`
|
|
|
|
type UpdateCollectionParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
|
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
|
}
|
|
|
|
// Update collection
|
|
func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, UpdateCollection,
|
|
arg.ID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Color,
|
|
arg.Icon,
|
|
arg.AutoAssignRules,
|
|
arg.ViewSettings,
|
|
arg.RuleMatchMode,
|
|
arg.FilterTree,
|
|
)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateCollectionMatchMode = `-- name: UpdateCollectionMatchMode :exec
|
|
UPDATE collections SET rule_match_mode = $2 WHERE id = $1
|
|
`
|
|
|
|
type UpdateCollectionMatchModeParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
|
}
|
|
|
|
// Smart-collection match semantics (plan item 2.4): flip any|all on its
|
|
// own channel — the rules page toggle writes exactly this column.
|
|
func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateCollectionMatchMode, arg.ID, arg.RuleMatchMode)
|
|
return err
|
|
}
|
|
|
|
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one
|
|
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
|
`
|
|
|
|
type UpdateCollectionRulesJSONParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
|
}
|
|
|
|
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
// of the JSONB without touching metadata stale clients did not send).
|
|
func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) {
|
|
row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules)
|
|
var i Collections
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Color,
|
|
&i.Icon,
|
|
&i.AutoAssignRules,
|
|
&i.RuleMatchMode,
|
|
&i.FilterTree,
|
|
&i.ViewSettings,
|
|
&i.ShowOnDashboard,
|
|
&i.QueryType,
|
|
&i.Priority,
|
|
&i.IsSystemCollection,
|
|
&i.CreatedAt,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateCollectionViewSettings = `-- name: UpdateCollectionViewSettings :exec
|
|
UPDATE collections SET view_settings = $2 WHERE id = $1
|
|
`
|
|
|
|
type UpdateCollectionViewSettingsParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
|
}
|
|
|
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
|
// touching the rest of the row.
|
|
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
|
// collections has no updated_at column — the scanner's series-import
|
|
// call has been failing silently, its error discarded. Fixed by
|
|
// dropping the phantom column write.)
|
|
func (q *Queries) UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateCollectionViewSettings, arg.ID, arg.ViewSettings)
|
|
return err
|
|
}
|
|
|
|
const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one
|
|
UPDATE user_dashboard_preferences
|
|
SET hidden_collections = $2,
|
|
collection_order = $3,
|
|
items_per_section = $4,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND library_id = $5
|
|
RETURNING id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at
|
|
`
|
|
|
|
type UpdateDashboardPreferencesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
|
|
CollectionOrder []string `db:"collection_order" json:"collection_order"`
|
|
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
}
|
|
|
|
func (q *Queries) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDashboardPreferences,
|
|
arg.UserID,
|
|
arg.HiddenCollections,
|
|
arg.CollectionOrder,
|
|
arg.ItemsPerSection,
|
|
arg.LibraryID,
|
|
)
|
|
var i UserDashboardPreferences
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.LibraryID,
|
|
&i.HiddenCollections,
|
|
&i.CollectionOrder,
|
|
&i.ItemsPerSection,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDevice = `-- name: UpdateDevice :one
|
|
UPDATE devices
|
|
SET
|
|
device_name = $2,
|
|
sync_enabled = $3,
|
|
auto_sync = $4,
|
|
sync_frequency_minutes = $5,
|
|
device_metadata = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
type UpdateDeviceParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceName string `db:"device_name" json:"device_name"`
|
|
SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"`
|
|
AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"`
|
|
SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"`
|
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
|
}
|
|
|
|
func (q *Queries) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDevice,
|
|
arg.ID,
|
|
arg.DeviceName,
|
|
arg.SyncEnabled,
|
|
arg.AutoSync,
|
|
arg.SyncFrequencyMinutes,
|
|
arg.DeviceMetadata,
|
|
)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceAuthToken = `-- name: UpdateDeviceAuthToken :one
|
|
UPDATE devices
|
|
SET
|
|
auth_token = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
type UpdateDeviceAuthTokenParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
AuthToken string `db:"auth_token" json:"auth_token"`
|
|
}
|
|
|
|
func (q *Queries) UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceAuthToken, arg.ID, arg.AuthToken)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceCatalogAvailability = `-- name: UpdateDeviceCatalogAvailability :exec
|
|
UPDATE device_catalogs
|
|
SET available = $2
|
|
WHERE id = $1
|
|
`
|
|
|
|
type UpdateDeviceCatalogAvailabilityParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Available pgtype.Bool `db:"available" json:"available"`
|
|
}
|
|
|
|
// Update device catalog availability
|
|
func (q *Queries) UpdateDeviceCatalogAvailability(ctx context.Context, arg UpdateDeviceCatalogAvailabilityParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateDeviceCatalogAvailability, arg.ID, arg.Available)
|
|
return err
|
|
}
|
|
|
|
const UpdateDeviceFileAlias = `-- name: UpdateDeviceFileAlias :one
|
|
UPDATE device_file_aliases
|
|
SET
|
|
media_item_id = $2,
|
|
file_sha256 = $3,
|
|
confidence_score = $4,
|
|
last_seen_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at
|
|
`
|
|
|
|
type UpdateDeviceFileAliasParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// Update device file alias
|
|
func (q *Queries) UpdateDeviceFileAlias(ctx context.Context, arg UpdateDeviceFileAliasParams) (DeviceFileAliases, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceFileAlias,
|
|
arg.ID,
|
|
arg.MediaItemID,
|
|
arg.FileSha256,
|
|
arg.ConfidenceScore,
|
|
)
|
|
var i DeviceFileAliases
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.DeviceID,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.ConfidenceScore,
|
|
&i.LastSeenAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceLastSeen = `-- name: UpdateDeviceLastSeen :one
|
|
UPDATE devices
|
|
SET
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
func (q *Queries) UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceLastSeen, id)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceLastSync = `-- name: UpdateDeviceLastSync :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceLastSync, id)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceShelfMapping = `-- name: UpdateDeviceShelfMapping :one
|
|
UPDATE device_shelf_mappings
|
|
SET
|
|
device_shelf_name = $2,
|
|
sync_direction = $3
|
|
WHERE id = $1
|
|
RETURNING id, collection_id, device_id, device_shelf_name, sync_direction, created_at
|
|
`
|
|
|
|
type UpdateDeviceShelfMappingParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"`
|
|
SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"`
|
|
}
|
|
|
|
// Update device shelf mapping
|
|
func (q *Queries) UpdateDeviceShelfMapping(ctx context.Context, arg UpdateDeviceShelfMappingParams) (DeviceShelfMappings, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceShelfMapping, arg.ID, arg.DeviceShelfName, arg.SyncDirection)
|
|
var i DeviceShelfMappings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.CollectionID,
|
|
&i.DeviceID,
|
|
&i.DeviceShelfName,
|
|
&i.SyncDirection,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDeviceSyncTimestamp = `-- name: UpdateDeviceSyncTimestamp :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
|
`
|
|
|
|
func (q *Queries) UpdateDeviceSyncTimestamp(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDeviceSyncTimestamp, id)
|
|
var i Devices
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.DeviceName,
|
|
&i.DeviceType,
|
|
&i.DeviceIdentifier,
|
|
&i.AuthToken,
|
|
&i.LastSync,
|
|
&i.LastSeen,
|
|
&i.SyncEnabled,
|
|
&i.AutoSync,
|
|
&i.SyncFrequencyMinutes,
|
|
&i.DeviceMetadata,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateDictionaryAccessed = `-- name: UpdateDictionaryAccessed :one
|
|
UPDATE dictionary_cache
|
|
SET accessed_at = NOW()
|
|
WHERE word = $1
|
|
RETURNING id, word, definition, part_of_speech, example, etymology, created_at, accessed_at
|
|
`
|
|
|
|
func (q *Queries) UpdateDictionaryAccessed(ctx context.Context, word string) (DictionaryCache, error) {
|
|
row := q.db.QueryRow(ctx, UpdateDictionaryAccessed, word)
|
|
var i DictionaryCache
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Word,
|
|
&i.Definition,
|
|
&i.PartOfSpeech,
|
|
&i.Example,
|
|
&i.Etymology,
|
|
&i.CreatedAt,
|
|
&i.AccessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateEmail = `-- name: UpdateEmail :exec
|
|
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateEmailParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
}
|
|
|
|
func (q *Queries) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateEmail, arg.ID, arg.Email)
|
|
return err
|
|
}
|
|
|
|
const UpdateKoboEntitlementRevision = `-- name: UpdateKoboEntitlementRevision :exec
|
|
UPDATE kobo_entitlements
|
|
SET revision_number = $3, updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2
|
|
`
|
|
|
|
type UpdateKoboEntitlementRevisionParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
}
|
|
|
|
func (q *Queries) UpdateKoboEntitlementRevision(ctx context.Context, arg UpdateKoboEntitlementRevisionParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateKoboEntitlementRevision, arg.DeviceID, arg.EntitlementID, arg.RevisionNumber)
|
|
return err
|
|
}
|
|
|
|
const UpdateKoboEntitlementStatus = `-- name: UpdateKoboEntitlementStatus :exec
|
|
UPDATE kobo_entitlements
|
|
SET book_status = $3, sync_status = 'synced', updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2
|
|
`
|
|
|
|
type UpdateKoboEntitlementStatusParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
EntitlementID string `db:"entitlement_id" json:"entitlement_id"`
|
|
BookStatus pgtype.Text `db:"book_status" json:"book_status"`
|
|
}
|
|
|
|
func (q *Queries) UpdateKoboEntitlementStatus(ctx context.Context, arg UpdateKoboEntitlementStatusParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateKoboEntitlementStatus, arg.DeviceID, arg.EntitlementID, arg.BookStatus)
|
|
return err
|
|
}
|
|
|
|
const UpdateKoboShelfBookPosition = `-- name: UpdateKoboShelfBookPosition :exec
|
|
UPDATE kobo_shelves
|
|
SET shelf_position = $3, last_synced_at = NOW()
|
|
WHERE device_id = $1 AND media_item_id = $2
|
|
`
|
|
|
|
type UpdateKoboShelfBookPositionParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"`
|
|
}
|
|
|
|
func (q *Queries) UpdateKoboShelfBookPosition(ctx context.Context, arg UpdateKoboShelfBookPositionParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateKoboShelfBookPosition, arg.DeviceID, arg.MediaItemID, arg.ShelfPosition)
|
|
return err
|
|
}
|
|
|
|
const UpdateKoboShelfCollection = `-- name: UpdateKoboShelfCollection :one
|
|
UPDATE kobo_shelves
|
|
SET
|
|
collection_id = $2,
|
|
position_in_collection = $3,
|
|
last_synced_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, device_id, media_item_id, shelf_name, shelf_position, added_at, last_synced_at, collection_id, position_in_collection
|
|
`
|
|
|
|
type UpdateKoboShelfCollectionParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
|
PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"`
|
|
}
|
|
|
|
// Update Kobo shelves to support collections
|
|
func (q *Queries) UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error) {
|
|
row := q.db.QueryRow(ctx, UpdateKoboShelfCollection, arg.ID, arg.CollectionID, arg.PositionInCollection)
|
|
var i KoboShelves
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.ShelfName,
|
|
&i.ShelfPosition,
|
|
&i.AddedAt,
|
|
&i.LastSyncedAt,
|
|
&i.CollectionID,
|
|
&i.PositionInCollection,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateLibrary = `-- name: UpdateLibrary :one
|
|
UPDATE libraries SET
|
|
name = $2,
|
|
description = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, name, description, library_type_id, created_by_admin_id, created_at, updated_at, allow_member_feeds
|
|
`
|
|
|
|
type UpdateLibraryParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
}
|
|
|
|
func (q *Queries) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) {
|
|
row := q.db.QueryRow(ctx, UpdateLibrary, arg.ID, arg.Name, arg.Description)
|
|
var i Libraries
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.LibraryTypeID,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.AllowMemberFeeds,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaBookmarkForSync = `-- name: UpdateMediaBookmarkForSync :one
|
|
UPDATE media_bookmarks SET
|
|
page_number = $2,
|
|
chapter_number = $3,
|
|
cfi_position = $4,
|
|
title = $5,
|
|
position = $6,
|
|
notes = $7,
|
|
percentage_location = $8,
|
|
epubcfi_location = $9,
|
|
chapter_reference = $10,
|
|
last_modified_at = $11,
|
|
last_modified_source = $12,
|
|
device_sync_data = $13,
|
|
created_at = created_at,
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $14
|
|
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
|
|
`
|
|
|
|
type UpdateMediaBookmarkForSyncParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
|
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
|
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
|
Title string `db:"title" json:"title"`
|
|
Position pgtype.Text `db:"position" json:"position"`
|
|
Notes pgtype.Text `db:"notes" json:"notes"`
|
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaBookmarkForSync,
|
|
arg.ID,
|
|
arg.PageNumber,
|
|
arg.ChapterNumber,
|
|
arg.CfiPosition,
|
|
arg.Title,
|
|
arg.Position,
|
|
arg.Notes,
|
|
arg.PercentageLocation,
|
|
arg.EpubcfiLocation,
|
|
arg.ChapterReference,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
arg.UserID,
|
|
)
|
|
var i MediaBookmarks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.PageNumber,
|
|
&i.ChapterNumber,
|
|
&i.CfiPosition,
|
|
&i.Title,
|
|
&i.Position,
|
|
&i.Notes,
|
|
&i.CreatedAt,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.DeviceSyncData,
|
|
&i.PercentageLocation,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
&i.OriginSource,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaHighlightForSync = `-- name: UpdateMediaHighlightForSync :one
|
|
UPDATE media_highlights SET
|
|
selection_text = $2,
|
|
start_position = $3,
|
|
end_position = $4,
|
|
color = $5,
|
|
note_text = $6,
|
|
percentage_start = $7,
|
|
percentage_end = $8,
|
|
epubcfi_start = $9,
|
|
epubcfi_end = $10,
|
|
chapter_reference = $11,
|
|
last_modified_at = $12,
|
|
last_modified_source = $13,
|
|
device_sync_data = $14,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $15
|
|
RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at
|
|
`
|
|
|
|
type UpdateMediaHighlightForSyncParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
|
Color pgtype.Text `db:"color" json:"color"`
|
|
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaHighlightForSync,
|
|
arg.ID,
|
|
arg.SelectionText,
|
|
arg.StartPosition,
|
|
arg.EndPosition,
|
|
arg.Color,
|
|
arg.NoteText,
|
|
arg.PercentageStart,
|
|
arg.PercentageEnd,
|
|
arg.EpubcfiStart,
|
|
arg.EpubcfiEnd,
|
|
arg.ChapterReference,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
arg.UserID,
|
|
)
|
|
var i MediaHighlights
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.SelectionText,
|
|
&i.StartPosition,
|
|
&i.EndPosition,
|
|
&i.Color,
|
|
&i.NoteID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageStart,
|
|
&i.PercentageEnd,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiStart,
|
|
&i.EpubcfiEnd,
|
|
&i.ChapterReference,
|
|
&i.ParagraphStart,
|
|
&i.ParagraphEnd,
|
|
&i.PanelNumber,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.NoteText,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItem = `-- name: UpdateMediaItem :one
|
|
UPDATE media_items SET
|
|
title = $2,
|
|
subtitle = $3,
|
|
authors = $4,
|
|
authors_search = $5,
|
|
isbn = $6,
|
|
description = $7,
|
|
cover_image_path = $8,
|
|
series = $9,
|
|
series_number = $10,
|
|
tags = $11,
|
|
tags_search = $12,
|
|
asin = $13,
|
|
date_published = $14,
|
|
publishers = $15,
|
|
publishers_search = $16,
|
|
contributors = $17,
|
|
contributors_search = $18,
|
|
language = $19,
|
|
edition = $20,
|
|
page_count = $21,
|
|
genre = $22,
|
|
copyright_year = $23,
|
|
goodreads_id = $24,
|
|
openlibrary_id = $25,
|
|
google_books_id = $26,
|
|
manga_type = $27,
|
|
reading_direction = $28,
|
|
series_count = $29,
|
|
volume = $30,
|
|
imprint = $31,
|
|
age_rating = $32,
|
|
web_url = $33,
|
|
metadata_notes = $34,
|
|
community_rating = $35,
|
|
story_arc = $36,
|
|
is_black_and_white = $37,
|
|
alternate_info = $38,
|
|
scan_information = $39,
|
|
summary = $40,
|
|
metadata_overrides = $41,
|
|
audio_metadata = $42,
|
|
narrators = $43,
|
|
narrators_search = $44,
|
|
abridged = $45,
|
|
explicit = $46,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type UpdateMediaItemParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
|
Series pgtype.Text `db:"series" json:"series"`
|
|
SeriesNumber pgtype.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
Publishers []string `db:"publishers" json:"publishers"`
|
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
|
Contributors []string `db:"contributors" json:"contributors"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
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"`
|
|
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"`
|
|
MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"`
|
|
CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"`
|
|
StoryArc pgtype.Text `db:"story_arc" json:"story_arc"`
|
|
IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItem,
|
|
arg.ID,
|
|
arg.Title,
|
|
arg.Subtitle,
|
|
arg.Authors,
|
|
arg.AuthorsSearch,
|
|
arg.Isbn,
|
|
arg.Description,
|
|
arg.CoverImagePath,
|
|
arg.Series,
|
|
arg.SeriesNumber,
|
|
arg.Tags,
|
|
arg.TagsSearch,
|
|
arg.Asin,
|
|
arg.DatePublished,
|
|
arg.Publishers,
|
|
arg.PublishersSearch,
|
|
arg.Contributors,
|
|
arg.ContributorsSearch,
|
|
arg.Language,
|
|
arg.Edition,
|
|
arg.PageCount,
|
|
arg.Genre,
|
|
arg.CopyrightYear,
|
|
arg.GoodreadsID,
|
|
arg.OpenlibraryID,
|
|
arg.GoogleBooksID,
|
|
arg.MangaType,
|
|
arg.ReadingDirection,
|
|
arg.SeriesCount,
|
|
arg.Volume,
|
|
arg.Imprint,
|
|
arg.AgeRating,
|
|
arg.WebUrl,
|
|
arg.MetadataNotes,
|
|
arg.CommunityRating,
|
|
arg.StoryArc,
|
|
arg.IsBlackAndWhite,
|
|
arg.AlternateInfo,
|
|
arg.ScanInformation,
|
|
arg.Summary,
|
|
arg.MetadataOverrides,
|
|
arg.AudioMetadata,
|
|
arg.Narrators,
|
|
arg.NarratorsSearch,
|
|
arg.Abridged,
|
|
arg.Explicit,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItemAudioBookPayload = `-- name: UpdateMediaItemAudioBookPayload :one
|
|
UPDATE media_items
|
|
SET file_size = $2,
|
|
mime_type = $3,
|
|
audio_metadata = $4,
|
|
chapter_count = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
|
`
|
|
|
|
type UpdateMediaItemAudioBookPayloadParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
|
}
|
|
|
|
type UpdateMediaItemAudioBookPayloadRow 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.Float8 `db:"series_number" json:"series_number"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Narrators []string `db:"narrators" json:"narrators"`
|
|
Abridged bool `db:"abridged" json:"abridged"`
|
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
|
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"`
|
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
|
}
|
|
|
|
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
|
|
// books): rescans recompute the track manifest and the aggregate facts
|
|
// that follow from it. Editorial fields stay untouched — admins may have
|
|
// edited title/narrators after ingest.
|
|
func (q *Queries) UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (UpdateMediaItemAudioBookPayloadRow, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItemAudioBookPayload,
|
|
arg.ID,
|
|
arg.FileSize,
|
|
arg.MimeType,
|
|
arg.AudioMetadata,
|
|
arg.ChapterCount,
|
|
)
|
|
var i UpdateMediaItemAudioBookPayloadRow
|
|
err := row.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.Narrators,
|
|
&i.Abridged,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata :one
|
|
UPDATE media_items
|
|
SET chapter_metadata = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type UpdateMediaItemChapterMetadataParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItemChapterMetadata, arg.ID, arg.ChapterMetadata)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItemFormat = `-- name: UpdateMediaItemFormat :one
|
|
UPDATE media_item_formats
|
|
SET
|
|
file_path = $2,
|
|
file_sha256 = $3,
|
|
file_size_bytes = $4,
|
|
mime_type = $5
|
|
WHERE id = $1
|
|
RETURNING id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id
|
|
`
|
|
|
|
type UpdateMediaItemFormatParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
|
FileSizeBytes pgtype.Int8 `db:"file_size_bytes" json:"file_size_bytes"`
|
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
|
}
|
|
|
|
// Update media item format
|
|
func (q *Queries) UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItemFormat,
|
|
arg.ID,
|
|
arg.FilePath,
|
|
arg.FileSha256,
|
|
arg.FileSizeBytes,
|
|
arg.MimeType,
|
|
)
|
|
var i MediaItemFormats
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.FormatType,
|
|
&i.FilePath,
|
|
&i.FileSha256,
|
|
&i.FileSizeBytes,
|
|
&i.MimeType,
|
|
&i.CreatedAt,
|
|
&i.ConvertedFromFormatID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItemFormatGroup = `-- name: UpdateMediaItemFormatGroup :exec
|
|
|
|
UPDATE media_items
|
|
SET
|
|
format_group = $2,
|
|
format_mimetype = $3,
|
|
is_reflowable = $4,
|
|
has_fixed_layout = $5,
|
|
total_characters = $6,
|
|
chapter_count = $7,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type UpdateMediaItemFormatGroupParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
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"`
|
|
}
|
|
|
|
// ============================================
|
|
// FORMAT DETECTION & PROGRESS
|
|
// ============================================
|
|
// Update media item format group information
|
|
func (q *Queries) UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateMediaItemFormatGroup,
|
|
arg.ID,
|
|
arg.FormatGroup,
|
|
arg.FormatMimetype,
|
|
arg.IsReflowable,
|
|
arg.HasFixedLayout,
|
|
arg.TotalCharacters,
|
|
arg.ChapterCount,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const UpdateMediaItemIdentifiers = `-- name: UpdateMediaItemIdentifiers :one
|
|
|
|
|
|
UPDATE media_items
|
|
SET
|
|
file_sha256 = $2,
|
|
opf_identifier = $3,
|
|
opf_uuid = $4,
|
|
hash_confidence = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type UpdateMediaItemIdentifiersParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
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"`
|
|
}
|
|
|
|
// Media Items Admin Operations
|
|
// ============================================
|
|
// UNIVERSAL BOOK IDENTIFIERS
|
|
// ============================================
|
|
// Update media item with universal identifiers
|
|
func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItemIdentifiers,
|
|
arg.ID,
|
|
arg.FileSha256,
|
|
arg.OpfIdentifier,
|
|
arg.OpfUuid,
|
|
arg.HashConfidence,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaItemKoboMetadata = `-- name: UpdateMediaItemKoboMetadata :one
|
|
UPDATE media_items
|
|
SET entitlement_id = $2,
|
|
kobo_content_id = $3,
|
|
revision_number = COALESCE($4, revision_number) + 1,
|
|
kobo_metadata = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type UpdateMediaItemKoboMetadataParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
|
|
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
|
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaItemKoboMetadata,
|
|
arg.ID,
|
|
arg.EntitlementID,
|
|
arg.KoboContentID,
|
|
arg.RevisionNumber,
|
|
arg.KoboMetadata,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaNoteForSync = `-- name: UpdateMediaNoteForSync :one
|
|
UPDATE media_notes SET
|
|
content = $2,
|
|
position = $3,
|
|
percentage_location = $4,
|
|
character_start = $5,
|
|
character_end = $6,
|
|
epubcfi_location = $7,
|
|
chapter_reference = $8,
|
|
paragraph_reference = $9,
|
|
last_modified_at = $10,
|
|
last_modified_source = $11,
|
|
device_sync_data = $12,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $13
|
|
RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at
|
|
`
|
|
|
|
type UpdateMediaNoteForSyncParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Content string `db:"content" json:"content"`
|
|
Position pgtype.Text `db:"position" json:"position"`
|
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
|
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
|
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
|
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaNoteForSync,
|
|
arg.ID,
|
|
arg.Content,
|
|
arg.Position,
|
|
arg.PercentageLocation,
|
|
arg.CharacterStart,
|
|
arg.CharacterEnd,
|
|
arg.EpubcfiLocation,
|
|
arg.ChapterReference,
|
|
arg.ParagraphReference,
|
|
arg.LastModifiedAt,
|
|
arg.LastModifiedSource,
|
|
arg.DeviceSyncData,
|
|
arg.UserID,
|
|
)
|
|
var i MediaNotes
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Content,
|
|
&i.Position,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.PercentageLocation,
|
|
&i.CharacterStart,
|
|
&i.CharacterEnd,
|
|
&i.EpubcfiLocation,
|
|
&i.ChapterReference,
|
|
&i.ParagraphReference,
|
|
&i.DeviceSyncData,
|
|
&i.DedupKey,
|
|
&i.LastModifiedAt,
|
|
&i.LastModifiedSource,
|
|
&i.Deleted,
|
|
&i.DeletedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateMediaRating = `-- name: UpdateMediaRating :one
|
|
UPDATE media_ratings SET
|
|
rating = $3,
|
|
updated_at = NOW()
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
RETURNING id, media_item_id, user_id, rating, created_at, updated_at
|
|
`
|
|
|
|
type UpdateMediaRatingParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Rating int32 `db:"rating" json:"rating"`
|
|
}
|
|
|
|
func (q *Queries) UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error) {
|
|
row := q.db.QueryRow(ctx, UpdateMediaRating, arg.MediaItemID, arg.UserID, arg.Rating)
|
|
var i MediaRatings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Rating,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdatePassword = `-- name: UpdatePassword :exec
|
|
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdatePasswordParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
|
}
|
|
|
|
func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error {
|
|
_, err := q.db.Exec(ctx, UpdatePassword, arg.ID, arg.PasswordHash)
|
|
return err
|
|
}
|
|
|
|
const UpdatePodcastFeedArtwork = `-- name: UpdatePodcastFeedArtwork :exec
|
|
UPDATE podcast_feeds SET artwork_path = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdatePodcastFeedArtworkParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
|
|
}
|
|
|
|
func (q *Queries) UpdatePodcastFeedArtwork(ctx context.Context, arg UpdatePodcastFeedArtworkParams) error {
|
|
_, err := q.db.Exec(ctx, UpdatePodcastFeedArtwork, arg.ID, arg.ArtworkPath)
|
|
return err
|
|
}
|
|
|
|
const UpdatePodcastFeedFetch = `-- name: UpdatePodcastFeedFetch :one
|
|
UPDATE podcast_feeds
|
|
SET etag = $2,
|
|
last_modified = $3,
|
|
last_fetched_at = NOW(),
|
|
last_build_date = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at
|
|
`
|
|
|
|
type UpdatePodcastFeedFetchParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Etag pgtype.Text `db:"etag" json:"etag"`
|
|
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
|
|
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
|
|
}
|
|
|
|
// Post-fetch bookkeeping: validators for conditional GETs, the fetch
|
|
// timestamp, and the feed's own last-build marker.
|
|
func (q *Queries) UpdatePodcastFeedFetch(ctx context.Context, arg UpdatePodcastFeedFetchParams) (PodcastFeeds, error) {
|
|
row := q.db.QueryRow(ctx, UpdatePodcastFeedFetch,
|
|
arg.ID,
|
|
arg.Etag,
|
|
arg.LastModified,
|
|
arg.LastBuildDate,
|
|
)
|
|
var i PodcastFeeds
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdatePodcastFeedMetadata = `-- name: UpdatePodcastFeedMetadata :one
|
|
UPDATE podcast_feeds
|
|
SET title = $2,
|
|
description = $3,
|
|
host = $4,
|
|
publisher = $5,
|
|
owner_name = $6,
|
|
owner_email = $7,
|
|
language = $8,
|
|
tags = $9,
|
|
explicit = $10,
|
|
feed_type = $11,
|
|
medium = $12,
|
|
is_complete = $13,
|
|
podcast_guid = COALESCE($14, podcast_guid),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at
|
|
`
|
|
|
|
type UpdatePodcastFeedMetadataParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Title string `db:"title" json:"title"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Host pgtype.Text `db:"host" json:"host"`
|
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
|
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
|
|
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
|
|
Language pgtype.Text `db:"language" json:"language"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
|
|
Medium pgtype.Text `db:"medium" json:"medium"`
|
|
IsComplete bool `db:"is_complete" json:"is_complete"`
|
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
|
}
|
|
|
|
// Fetcher-only full metadata refresh (parse result). podcast_guid is
|
|
// set once a feed declares it; new_feed_url handling is a dedicated
|
|
// query below.
|
|
func (q *Queries) UpdatePodcastFeedMetadata(ctx context.Context, arg UpdatePodcastFeedMetadataParams) (PodcastFeeds, error) {
|
|
row := q.db.QueryRow(ctx, UpdatePodcastFeedMetadata,
|
|
arg.ID,
|
|
arg.Title,
|
|
arg.Description,
|
|
arg.Host,
|
|
arg.Publisher,
|
|
arg.OwnerName,
|
|
arg.OwnerEmail,
|
|
arg.Language,
|
|
arg.Tags,
|
|
arg.Explicit,
|
|
arg.FeedType,
|
|
arg.Medium,
|
|
arg.IsComplete,
|
|
arg.PodcastGuid,
|
|
)
|
|
var i PodcastFeeds
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.FeedUrl,
|
|
&i.PodcastGuid,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Host,
|
|
&i.Publisher,
|
|
&i.OwnerName,
|
|
&i.OwnerEmail,
|
|
&i.Language,
|
|
&i.Tags,
|
|
&i.Explicit,
|
|
&i.ArtworkPath,
|
|
&i.FeedType,
|
|
&i.Medium,
|
|
&i.IsComplete,
|
|
&i.Etag,
|
|
&i.LastModified,
|
|
&i.LastFetchedAt,
|
|
&i.LastBuildDate,
|
|
&i.CreatedByAdminID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdatePodcastFeedRedirect = `-- name: UpdatePodcastFeedRedirect :exec
|
|
UPDATE podcast_feeds
|
|
SET feed_url = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type UpdatePodcastFeedRedirectParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
|
}
|
|
|
|
// itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
|
|
// MOVED. Rewrite feed_url so future polls hit the new location; the row
|
|
// (and every subscription/episode) stays put.
|
|
func (q *Queries) UpdatePodcastFeedRedirect(ctx context.Context, arg UpdatePodcastFeedRedirectParams) error {
|
|
_, err := q.db.Exec(ctx, UpdatePodcastFeedRedirect, arg.ID, arg.FeedUrl)
|
|
return err
|
|
}
|
|
|
|
const UpdatePodcastSubscription = `-- name: UpdatePodcastSubscription :one
|
|
UPDATE podcast_subscriptions
|
|
SET display_name = $3,
|
|
auto_download = $4,
|
|
auto_download_count = $5
|
|
WHERE user_id = $1 AND feed_id = $2
|
|
RETURNING id, user_id, feed_id, display_name, auto_download, auto_download_count, created_at
|
|
`
|
|
|
|
type UpdatePodcastSubscriptionParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
|
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
|
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
|
}
|
|
|
|
// User-owned fields only (D9): display name + auto-download policy.
|
|
func (q *Queries) UpdatePodcastSubscription(ctx context.Context, arg UpdatePodcastSubscriptionParams) (PodcastSubscriptions, error) {
|
|
row := q.db.QueryRow(ctx, UpdatePodcastSubscription,
|
|
arg.UserID,
|
|
arg.FeedID,
|
|
arg.DisplayName,
|
|
arg.AutoDownload,
|
|
arg.AutoDownloadCount,
|
|
)
|
|
var i PodcastSubscriptions
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.FeedID,
|
|
&i.DisplayName,
|
|
&i.AutoDownload,
|
|
&i.AutoDownloadCount,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
|
|
INSERT INTO reading_progress (media_item_id, user_id, current_page, total_pages, last_read_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
last_read_at = NOW()
|
|
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
|
|
`
|
|
|
|
type UpdateReadingProgressParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
|
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
|
}
|
|
|
|
func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) {
|
|
row := q.db.QueryRow(ctx, UpdateReadingProgress,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.CurrentPage,
|
|
arg.TotalPages,
|
|
)
|
|
var i ReadingProgress
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.CurrentPage,
|
|
&i.TotalPages,
|
|
&i.LastReadAt,
|
|
&i.Percentage,
|
|
&i.CharacterOffset,
|
|
&i.Epubcfi,
|
|
&i.ContextText,
|
|
&i.Chapter,
|
|
&i.ChapterProgress,
|
|
&i.AudioOffsetSeconds,
|
|
&i.ViewportX,
|
|
&i.ViewportY,
|
|
&i.ZoomLevel,
|
|
&i.ScrollPositionX,
|
|
&i.ScrollPositionY,
|
|
&i.PanelNumber,
|
|
&i.ReadingMode,
|
|
&i.LastSyncDevice,
|
|
&i.LastSyncSource,
|
|
&i.LastSyncTimestamp,
|
|
&i.ConflictDetected,
|
|
&i.ConflictResolved,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateReadingSpeed = `-- name: UpdateReadingSpeed :one
|
|
UPDATE reading_speed
|
|
SET
|
|
pages_per_minute = $3,
|
|
pages_read = pages_read + $4,
|
|
total_reading_minutes = total_reading_minutes + $5,
|
|
last_read_at = $6,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
RETURNING id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at, updated_at
|
|
`
|
|
|
|
type UpdateReadingSpeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
PagesPerMinute pgtype.Float4 `db:"pages_per_minute" json:"pages_per_minute"`
|
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
|
TotalReadingMinutes pgtype.Float4 `db:"total_reading_minutes" json:"total_reading_minutes"`
|
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
|
}
|
|
|
|
func (q *Queries) UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error) {
|
|
row := q.db.QueryRow(ctx, UpdateReadingSpeed,
|
|
arg.UserID,
|
|
arg.MediaItemID,
|
|
arg.PagesPerMinute,
|
|
arg.PagesRead,
|
|
arg.TotalReadingMinutes,
|
|
arg.LastReadAt,
|
|
)
|
|
var i ReadingSpeed
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.MediaItemID,
|
|
&i.WordsPerMinute,
|
|
&i.PagesPerMinute,
|
|
&i.PagesRead,
|
|
&i.TotalReadingMinutes,
|
|
&i.LastReadAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateSavedFilter = `-- name: UpdateSavedFilter :one
|
|
UPDATE saved_filters
|
|
SET name = $1,
|
|
filters = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $3 AND user_id = $4
|
|
RETURNING id, user_id, name, resource_type, filters, created_at, updated_at
|
|
`
|
|
|
|
type UpdateSavedFilterParams struct {
|
|
Name string `db:"name" json:"name"`
|
|
Filters []byte `db:"filters" json:"filters"`
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
func (q *Queries) UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error) {
|
|
row := q.db.QueryRow(ctx, UpdateSavedFilter,
|
|
arg.Name,
|
|
arg.Filters,
|
|
arg.ID,
|
|
arg.UserID,
|
|
)
|
|
var i SavedFilters
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.ResourceType,
|
|
&i.Filters,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateSyncQueueItemStatus = `-- name: UpdateSyncQueueItemStatus :one
|
|
UPDATE sync_queue
|
|
SET
|
|
status = $2,
|
|
attempts = attempts + 1,
|
|
error_message = $3,
|
|
processed_at = CASE WHEN $2::varchar = 'completed' THEN NOW() ELSE NULL END
|
|
WHERE id = $1
|
|
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
|
|
`
|
|
|
|
type UpdateSyncQueueItemStatusParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Status pgtype.Text `db:"status" json:"status"`
|
|
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
|
|
}
|
|
|
|
func (q *Queries) UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error) {
|
|
row := q.db.QueryRow(ctx, UpdateSyncQueueItemStatus, arg.ID, arg.Status, arg.ErrorMessage)
|
|
var i SyncQueue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.MediaItemID,
|
|
&i.SyncType,
|
|
&i.SyncData,
|
|
&i.Priority,
|
|
&i.Attempts,
|
|
&i.MaxAttempts,
|
|
&i.Status,
|
|
&i.ErrorMessage,
|
|
&i.CreatedAt,
|
|
&i.ProcessedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateSystemSetting = `-- name: UpdateSystemSetting :exec
|
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1
|
|
`
|
|
|
|
type UpdateSystemSettingParams struct {
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
|
}
|
|
|
|
func (q *Queries) UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateSystemSetting, arg.SettingKey, arg.SettingValue)
|
|
return err
|
|
}
|
|
|
|
const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
|
|
INSERT INTO reading_progress (
|
|
media_item_id,
|
|
user_id,
|
|
percentage,
|
|
character_offset,
|
|
epubcfi,
|
|
context_text,
|
|
chapter,
|
|
chapter_progress,
|
|
viewport_x,
|
|
viewport_y,
|
|
zoom_level,
|
|
scroll_position_x,
|
|
scroll_position_y,
|
|
panel_number,
|
|
reading_mode,
|
|
last_sync_device,
|
|
last_sync_source,
|
|
last_sync_timestamp,
|
|
current_page,
|
|
total_pages,
|
|
audio_offset_seconds,
|
|
last_read_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, NOW()
|
|
)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
percentage = EXCLUDED.percentage,
|
|
character_offset = EXCLUDED.character_offset,
|
|
epubcfi = EXCLUDED.epubcfi,
|
|
context_text = EXCLUDED.context_text,
|
|
chapter = EXCLUDED.chapter,
|
|
chapter_progress = EXCLUDED.chapter_progress,
|
|
viewport_x = EXCLUDED.viewport_x,
|
|
viewport_y = EXCLUDED.viewport_y,
|
|
zoom_level = EXCLUDED.zoom_level,
|
|
scroll_position_x = EXCLUDED.scroll_position_x,
|
|
scroll_position_y = EXCLUDED.scroll_position_y,
|
|
panel_number = EXCLUDED.panel_number,
|
|
reading_mode = EXCLUDED.reading_mode,
|
|
last_sync_device = EXCLUDED.last_sync_device,
|
|
last_sync_source = EXCLUDED.last_sync_source,
|
|
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
audio_offset_seconds = EXCLUDED.audio_offset_seconds,
|
|
last_read_at = NOW()
|
|
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, audio_offset_seconds, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
|
|
`
|
|
|
|
type UpdateUniversalProgressParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
|
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
|
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
|
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
|
|
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
|
|
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
|
|
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
|
|
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
|
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
|
|
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
|
|
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
|
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
|
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
|
AudioOffsetSeconds pgtype.Int4 `db:"audio_offset_seconds" json:"audio_offset_seconds"`
|
|
}
|
|
|
|
// Update universal progress
|
|
func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) {
|
|
row := q.db.QueryRow(ctx, UpdateUniversalProgress,
|
|
arg.MediaItemID,
|
|
arg.UserID,
|
|
arg.Percentage,
|
|
arg.CharacterOffset,
|
|
arg.Epubcfi,
|
|
arg.ContextText,
|
|
arg.Chapter,
|
|
arg.ChapterProgress,
|
|
arg.ViewportX,
|
|
arg.ViewportY,
|
|
arg.ZoomLevel,
|
|
arg.ScrollPositionX,
|
|
arg.ScrollPositionY,
|
|
arg.PanelNumber,
|
|
arg.ReadingMode,
|
|
arg.LastSyncDevice,
|
|
arg.LastSyncSource,
|
|
arg.CurrentPage,
|
|
arg.TotalPages,
|
|
arg.AudioOffsetSeconds,
|
|
)
|
|
var i ReadingProgress
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.CurrentPage,
|
|
&i.TotalPages,
|
|
&i.LastReadAt,
|
|
&i.Percentage,
|
|
&i.CharacterOffset,
|
|
&i.Epubcfi,
|
|
&i.ContextText,
|
|
&i.Chapter,
|
|
&i.ChapterProgress,
|
|
&i.AudioOffsetSeconds,
|
|
&i.ViewportX,
|
|
&i.ViewportY,
|
|
&i.ZoomLevel,
|
|
&i.ScrollPositionX,
|
|
&i.ScrollPositionY,
|
|
&i.PanelNumber,
|
|
&i.ReadingMode,
|
|
&i.LastSyncDevice,
|
|
&i.LastSyncSource,
|
|
&i.LastSyncTimestamp,
|
|
&i.ConflictDetected,
|
|
&i.ConflictResolved,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
|
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUserDefaultAudioSpeedParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
|
|
return err
|
|
}
|
|
|
|
const UpdateUserKeepScreenAwake = `-- name: UpdateUserKeepScreenAwake :exec
|
|
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUserKeepScreenAwakeParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUserKeepScreenAwake, arg.ID, arg.KeepScreenAwake)
|
|
return err
|
|
}
|
|
|
|
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
|
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake
|
|
`
|
|
|
|
type UpdateUserMaxDevicesParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error) {
|
|
row := q.db.QueryRow(ctx, UpdateUserMaxDevices, arg.ID, arg.MaxDevices)
|
|
var i Users
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.PasswordHash,
|
|
&i.FirstName,
|
|
&i.LastName,
|
|
&i.Role,
|
|
&i.Theme,
|
|
&i.MaxDevices,
|
|
&i.CreatedAt,
|
|
&i.Timezone,
|
|
&i.UpdatedAt,
|
|
&i.DefaultAudioSpeed,
|
|
&i.KeepScreenAwake,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateUserProfile = `-- name: UpdateUserProfile :exec
|
|
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUserProfileParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUserProfile, arg.ID, arg.FirstName, arg.LastName)
|
|
return err
|
|
}
|
|
|
|
const UpdateUserRole = `-- name: UpdateUserRole :one
|
|
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING id, email, username, role
|
|
`
|
|
|
|
type UpdateUserRoleParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Role string `db:"role" json:"role"`
|
|
}
|
|
|
|
type UpdateUserRoleRow struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Email string `db:"email" json:"email"`
|
|
Username string `db:"username" json:"username"`
|
|
Role string `db:"role" json:"role"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) {
|
|
row := q.db.QueryRow(ctx, UpdateUserRole, arg.ID, arg.Role)
|
|
var i UpdateUserRoleRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.Role,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpdateUserTheme = `-- name: UpdateUserTheme :exec
|
|
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUserThemeParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUserTheme, arg.ID, arg.Theme)
|
|
return err
|
|
}
|
|
|
|
const UpdateUserTimezone = `-- name: UpdateUserTimezone :exec
|
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUserTimezoneParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUserTimezone, arg.ID, arg.Timezone)
|
|
return err
|
|
}
|
|
|
|
const UpdateUsername = `-- name: UpdateUsername :exec
|
|
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1
|
|
`
|
|
|
|
type UpdateUsernameParams struct {
|
|
ID pgtype.UUID `db:"id" json:"id"`
|
|
Username string `db:"username" json:"username"`
|
|
}
|
|
|
|
func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error {
|
|
_, err := q.db.Exec(ctx, UpdateUsername, arg.ID, arg.Username)
|
|
return err
|
|
}
|
|
|
|
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
|
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, media_item_id)
|
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
|
RETURNING speed
|
|
`
|
|
|
|
type UpsertAudioPlaybackSpeedParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Speed float32 `db:"speed" json:"speed"`
|
|
}
|
|
|
|
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
|
|
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
|
|
var speed float32
|
|
err := row.Scan(&speed)
|
|
return speed, err
|
|
}
|
|
|
|
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
hidden_collections = EXCLUDED.hidden_collections,
|
|
collection_order = EXCLUDED.collection_order,
|
|
items_per_section = EXCLUDED.items_per_section,
|
|
updated_at = NOW()
|
|
RETURNING id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at
|
|
`
|
|
|
|
type UpsertDashboardPreferencesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
|
|
CollectionOrder []string `db:"collection_order" json:"collection_order"`
|
|
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
|
|
}
|
|
|
|
func (q *Queries) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) {
|
|
row := q.db.QueryRow(ctx, UpsertDashboardPreferences,
|
|
arg.UserID,
|
|
arg.LibraryID,
|
|
arg.HiddenCollections,
|
|
arg.CollectionOrder,
|
|
arg.ItemsPerSection,
|
|
)
|
|
var i UserDashboardPreferences
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.LibraryID,
|
|
&i.HiddenCollections,
|
|
&i.CollectionOrder,
|
|
&i.ItemsPerSection,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertGlobalDashboardPreferences = `-- name: UpsertGlobalDashboardPreferences :one
|
|
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (user_id)
|
|
DO UPDATE SET
|
|
hidden_collections = EXCLUDED.hidden_collections,
|
|
collection_order = EXCLUDED.collection_order,
|
|
items_per_section = EXCLUDED.items_per_section,
|
|
updated_at = NOW()
|
|
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at
|
|
`
|
|
|
|
type UpsertGlobalDashboardPreferencesParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
|
|
CollectionOrder []string `db:"collection_order" json:"collection_order"`
|
|
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
|
|
}
|
|
|
|
func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) {
|
|
row := q.db.QueryRow(ctx, UpsertGlobalDashboardPreferences,
|
|
arg.UserID,
|
|
arg.HiddenCollections,
|
|
arg.CollectionOrder,
|
|
arg.ItemsPerSection,
|
|
)
|
|
var i UserDashboardPreferencesGlobal
|
|
err := row.Scan(
|
|
&i.UserID,
|
|
&i.HiddenCollections,
|
|
&i.CollectionOrder,
|
|
&i.ItemsPerSection,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertMediaReview = `-- name: UpsertMediaReview :one
|
|
INSERT INTO media_reviews (media_item_id, user_id, review)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
review = EXCLUDED.review,
|
|
updated_at = NOW()
|
|
RETURNING id, media_item_id, user_id, review, created_at, updated_at
|
|
`
|
|
|
|
type UpsertMediaReviewParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
Review string `db:"review" json:"review"`
|
|
}
|
|
|
|
func (q *Queries) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) {
|
|
row := q.db.QueryRow(ctx, UpsertMediaReview, arg.MediaItemID, arg.UserID, arg.Review)
|
|
var i MediaReviews
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.UserID,
|
|
&i.Review,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertMediaScanState = `-- name: UpsertMediaScanState :exec
|
|
INSERT INTO media_scan_state (media_item_id, manifest)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (media_item_id) DO UPDATE SET manifest = EXCLUDED.manifest
|
|
`
|
|
|
|
type UpsertMediaScanStateParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
Manifest []byte `db:"manifest" json:"manifest"`
|
|
}
|
|
|
|
// UpsertMediaScanState stores a book's sidecar manifest fingerprint
|
|
// (faceted plan Part 2 §3). Written on backfill (first sighting —
|
|
// no re-extraction), on repoint reconciliation (stat pairs refreshed,
|
|
// content identical), and after a manifest-gated re-extract.
|
|
func (q *Queries) UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error {
|
|
_, err := q.db.Exec(ctx, UpsertMediaScanState, arg.MediaItemID, arg.Manifest)
|
|
return err
|
|
}
|
|
|
|
const UpsertPanelData = `-- name: UpsertPanelData :one
|
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (media_item_id, page_number)
|
|
DO UPDATE SET
|
|
detection_method = EXCLUDED.detection_method,
|
|
panels = EXCLUDED.panels,
|
|
updated_at = NOW()
|
|
RETURNING id, media_item_id, page_number, detection_method, panels, created_at, updated_at
|
|
`
|
|
|
|
type UpsertPanelDataParams struct {
|
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
|
PageNumber int32 `db:"page_number" json:"page_number"`
|
|
DetectionMethod string `db:"detection_method" json:"detection_method"`
|
|
Panels []byte `db:"panels" json:"panels"`
|
|
}
|
|
|
|
func (q *Queries) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) {
|
|
row := q.db.QueryRow(ctx, UpsertPanelData,
|
|
arg.MediaItemID,
|
|
arg.PageNumber,
|
|
arg.DetectionMethod,
|
|
arg.Panels,
|
|
)
|
|
var i PanelData
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.MediaItemID,
|
|
&i.PageNumber,
|
|
&i.DetectionMethod,
|
|
&i.Panels,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertPodcastEpisode = `-- name: UpsertPodcastEpisode :one
|
|
|
|
INSERT INTO media_items (
|
|
library_id, title, subtitle, description, authors, authors_search,
|
|
file_path, file_size, mime_type, cover_image_path, explicit,
|
|
date_published, language, tags, genre,
|
|
format_group, format_mimetype, is_reflowable, library_type_name,
|
|
source, source_feed_id, episode_guid, feed_position,
|
|
season_number, episode_number, enclosure_url,
|
|
declared_duration_seconds, transcript_url, chapters_url,
|
|
hosts, guests
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6,
|
|
$7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15,
|
|
'audiobook', $9, FALSE, 'podcasts',
|
|
'podcast_episode', $16, $17, $18,
|
|
$19, $20, $21,
|
|
$22, $23, $24,
|
|
$25, $26
|
|
)
|
|
ON CONFLICT (source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
subtitle = EXCLUDED.subtitle,
|
|
description = EXCLUDED.description,
|
|
authors = EXCLUDED.authors,
|
|
authors_search = EXCLUDED.authors_search,
|
|
explicit = EXCLUDED.explicit,
|
|
date_published = EXCLUDED.date_published,
|
|
language = EXCLUDED.language,
|
|
tags = EXCLUDED.tags,
|
|
genre = EXCLUDED.genre,
|
|
season_number = EXCLUDED.season_number,
|
|
episode_number = EXCLUDED.episode_number,
|
|
enclosure_url = EXCLUDED.enclosure_url,
|
|
declared_duration_seconds = EXCLUDED.declared_duration_seconds,
|
|
transcript_url = EXCLUDED.transcript_url,
|
|
chapters_url = EXCLUDED.chapters_url,
|
|
hosts = EXCLUDED.hosts,
|
|
guests = EXCLUDED.guests,
|
|
feed_position = EXCLUDED.feed_position,
|
|
cover_image_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.cover_image_path ELSE EXCLUDED.cover_image_path END,
|
|
file_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_path ELSE EXCLUDED.file_path END,
|
|
file_size = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_size ELSE EXCLUDED.file_size END,
|
|
mime_type = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.mime_type ELSE EXCLUDED.mime_type END,
|
|
updated_at = NOW()
|
|
RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos
|
|
`
|
|
|
|
type UpsertPodcastEpisodeParams struct {
|
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
Authors []string `db:"authors" json:"authors"`
|
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
|
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"`
|
|
Explicit bool `db:"explicit" json:"explicit"`
|
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
|
Language pgtype.Text `db:"language" json:"language"`
|
|
Tags []string `db:"tags" json:"tags"`
|
|
Genre pgtype.Text `db:"genre" json:"genre"`
|
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
|
Hosts []string `db:"hosts" json:"hosts"`
|
|
Guests []string `db:"guests" json:"guests"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// Podcast episodes — media_items rows with source='podcast_episode'
|
|
// ============================================================================
|
|
// Fetcher-only upsert keyed on the partial unique index
|
|
// (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
|
|
// metadata only. Write-after-success guard (AGENTS.md gotcha #7):
|
|
// download state/error/attempts are NEVER touched here, and the
|
|
// downloaded file's facts (path/size/mime/cover) survive renumbering —
|
|
// only un-downloaded rows follow the re-derived file_path.
|
|
// authors_search mirrors authors (trigram search column).
|
|
func (q *Queries) UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpisodeParams) (MediaItems, error) {
|
|
row := q.db.QueryRow(ctx, UpsertPodcastEpisode,
|
|
arg.LibraryID,
|
|
arg.Title,
|
|
arg.Subtitle,
|
|
arg.Description,
|
|
arg.Authors,
|
|
arg.AuthorsSearch,
|
|
arg.FilePath,
|
|
arg.FileSize,
|
|
arg.MimeType,
|
|
arg.CoverImagePath,
|
|
arg.Explicit,
|
|
arg.DatePublished,
|
|
arg.Language,
|
|
arg.Tags,
|
|
arg.Genre,
|
|
arg.SourceFeedID,
|
|
arg.EpisodeGuid,
|
|
arg.FeedPosition,
|
|
arg.SeasonNumber,
|
|
arg.EpisodeNumber,
|
|
arg.EnclosureUrl,
|
|
arg.DeclaredDurationSeconds,
|
|
arg.TranscriptUrl,
|
|
arg.ChaptersUrl,
|
|
arg.Hosts,
|
|
arg.Guests,
|
|
)
|
|
var i MediaItems
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.LibraryID,
|
|
&i.Title,
|
|
&i.Subtitle,
|
|
&i.Author,
|
|
&i.Isbn,
|
|
&i.Description,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.MimeType,
|
|
&i.CoverImagePath,
|
|
&i.Series,
|
|
&i.SeriesNumber,
|
|
&i.Tags,
|
|
&i.Narrators,
|
|
&i.Authors,
|
|
&i.AuthorsSearch,
|
|
&i.Publishers,
|
|
&i.PublishersSearch,
|
|
&i.Abridged,
|
|
&i.Explicit,
|
|
&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.LibraryTypeName,
|
|
&i.AudioMetadata,
|
|
&i.TagsSearch,
|
|
&i.ContributorsSearch,
|
|
&i.NarratorsSearch,
|
|
&i.FileSha256,
|
|
&i.OpfIdentifier,
|
|
&i.OpfUuid,
|
|
&i.HashConfidence,
|
|
&i.Source,
|
|
&i.SourceFeedID,
|
|
&i.EpisodeGuid,
|
|
&i.FeedPosition,
|
|
&i.SeasonNumber,
|
|
&i.EpisodeNumber,
|
|
&i.EnclosureUrl,
|
|
&i.DeclaredDurationSeconds,
|
|
&i.TranscriptUrl,
|
|
&i.ChaptersUrl,
|
|
&i.Hosts,
|
|
&i.Guests,
|
|
&i.DownloadState,
|
|
&i.DownloadError,
|
|
&i.DownloadAttempts,
|
|
&i.DownloadQueuePos,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertReaderSettings = `-- name: UpsertReaderSettings :one
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, 'reader_settings', $2)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET
|
|
setting_value = EXCLUDED.setting_value,
|
|
updated_at = NOW()
|
|
RETURNING id, user_id, setting_key, setting_value, updated_at
|
|
`
|
|
|
|
type UpsertReaderSettingsParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SettingValue []byte `db:"setting_value" json:"setting_value"`
|
|
}
|
|
|
|
func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) {
|
|
row := q.db.QueryRow(ctx, UpsertReaderSettings, arg.UserID, arg.SettingValue)
|
|
var i ReaderSettings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.SettingKey,
|
|
&i.SettingValue,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertSystemSetting = `-- name: UpsertSystemSetting :one
|
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (setting_key) DO UPDATE
|
|
SET setting_value = EXCLUDED.setting_value,
|
|
description = EXCLUDED.description,
|
|
setting_type = EXCLUDED.setting_type,
|
|
min_value = EXCLUDED.min_value,
|
|
max_value = EXCLUDED.max_value,
|
|
requires_restart = EXCLUDED.requires_restart,
|
|
category = EXCLUDED.category,
|
|
updated_at = NOW()
|
|
RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category
|
|
`
|
|
|
|
type UpsertSystemSettingParams struct {
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
|
Description pgtype.Text `db:"description" json:"description"`
|
|
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
|
|
MinValue pgtype.Text `db:"min_value" json:"min_value"`
|
|
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
|
|
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
|
|
Category pgtype.Text `db:"category" json:"category"`
|
|
}
|
|
|
|
func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) {
|
|
row := q.db.QueryRow(ctx, UpsertSystemSetting,
|
|
arg.SettingKey,
|
|
arg.SettingValue,
|
|
arg.Description,
|
|
arg.SettingType,
|
|
arg.MinValue,
|
|
arg.MaxValue,
|
|
arg.RequiresRestart,
|
|
arg.Category,
|
|
)
|
|
var i SystemSettings
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.SettingKey,
|
|
&i.SettingValue,
|
|
&i.Description,
|
|
&i.UpdatedAt,
|
|
&i.SettingType,
|
|
&i.MinValue,
|
|
&i.MaxValue,
|
|
&i.RequiresRestart,
|
|
&i.Category,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one
|
|
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (device_id, content_id) DO UPDATE
|
|
SET file_path = EXCLUDED.file_path,
|
|
title = EXCLUDED.title,
|
|
author = EXCLUDED.author,
|
|
confidence_score = EXCLUDED.confidence_score,
|
|
last_seen_at = NOW()
|
|
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
|
`
|
|
|
|
type UpsertUnlinkedBookParams struct {
|
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
|
ContentID string `db:"content_id" json:"content_id"`
|
|
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
|
Title pgtype.Text `db:"title" json:"title"`
|
|
Author pgtype.Text `db:"author" json:"author"`
|
|
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
|
}
|
|
|
|
// Ingest: insert or refresh the queue row for a device file that could not
|
|
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
|
func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) {
|
|
row := q.db.QueryRow(ctx, UpsertUnlinkedBook,
|
|
arg.DeviceID,
|
|
arg.ContentID,
|
|
arg.FilePath,
|
|
arg.Title,
|
|
arg.Author,
|
|
arg.ConfidenceScore,
|
|
)
|
|
var i UnlinkedBooks
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.DeviceID,
|
|
&i.ContentID,
|
|
&i.FilePath,
|
|
&i.Title,
|
|
&i.Author,
|
|
&i.ConfidenceScore,
|
|
&i.Resolved,
|
|
&i.MediaItemID,
|
|
&i.ResolvedAt,
|
|
&i.ResolutionMethod,
|
|
&i.LastSeenAt,
|
|
&i.CreatedAt,
|
|
&i.IgnoredAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const UpsertUserSettingByKey = `-- name: UpsertUserSettingByKey :exec
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()
|
|
`
|
|
|
|
type UpsertUserSettingByKeyParams struct {
|
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
|
SettingValue []byte `db:"setting_value" json:"setting_value"`
|
|
}
|
|
|
|
func (q *Queries) UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error {
|
|
_, err := q.db.Exec(ctx, UpsertUserSettingByKey, arg.UserID, arg.SettingKey, arg.SettingValue)
|
|
return err
|
|
}
|