feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7). Auto-playlist collections (§2.4): the scanner turns obviously-playlist sidecar series (explicit '(Reading Order)'/chronological/publication/ in-order/timeline markers only — precision over recall) into curated static collections: upsert by view_settings.imported_from_series then name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a stay unpositioned rather than guessing), membership add-if-missing, never removed or re-ordered by rescans, provenance in description + view_settings, show_on_dashboard true (dashboard-eligible by design), 'scanner.auto_playlist_collections' setting (default on, Settings Registry + schema seed). Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST then added_at ASC (was added_at DESC — walked playlists backwards); PUT /api/collections/:id/items/order applies full-order rewrites (owner-only, service-gated). hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard gains viewer_user_id + hide_finished (LEFT JOIN reading_progress, COALESCE percentage < 0.999) and viewer_percentage rides the row for card graying; GetCollectionUpNext serves the collection's reading-queue head with the family-lens visible-library array enforced. Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT — the show_on_dashboard gate (never writable for user rows since the column shipped) is gone from GetUserCollectionsForDashboard; hiding stays dashboard-prefs (hidden_collections) territory; hide_finished filtered-everything now emits a Complete section instead of silently vanishing; sections JSON carries is_playlist/complete. Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by title ASC — NULL/tied numbers previously returned heap (scan) order, which is what the user saw. Frontend custom-section-builder note: the builder has sent show_on_dashboard: true since its creation; the API never bound it. User-row visibility is now intentionally prefs-governed, so the field stays unbound by design (documented in code). Pins widened (docs/contract_pins.md): collection detail + up_next; dashboard sections + is_playlist/complete; new reorder endpoint recorded. Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar series split, collection auto-creation with position 7, rescan idempotency, owner-only reorder, finished-filter + completion marker + Up Next drain) plus the full affected integration sweep green.
This commit is contained in:
1 parent
7e4c97f639
commit
1dbb0ce406
15 files changed
+992
-18
No files matched your search
@@ -490,10 +490,12 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
fx := createContractFixture(t, setup)
|
fx := createContractFixture(t, setup)
|
||||||
|
|
||||||
// The envelope every detail branch shares.
|
// The envelope every detail branch shares.
|
||||||
|
// A2c (2026-10-19): + up_next (the collection's reading-queue strip
|
||||||
|
// payload; docs/contract_pins.md changelog).
|
||||||
envelopeKeys := []string{
|
envelopeKeys := []string{
|
||||||
"id", "user_id", "name", "description", "color", "icon",
|
"id", "user_id", "name", "description", "color", "icon",
|
||||||
"auto_assign_rules", "view_settings", "books", "book_count",
|
"auto_assign_rules", "view_settings", "books", "book_count",
|
||||||
"is_smart", "created_at",
|
"is_smart", "created_at", "up_next",
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
||||||
@@ -693,6 +695,8 @@ func TestContractPin_DashboardSections(t *testing.T) {
|
|||||||
sectionKeys := []string{
|
sectionKeys := []string{
|
||||||
"id", "collection_id", "is_system", "title", "description",
|
"id", "collection_id", "is_system", "title", "description",
|
||||||
"icon", "items", "view_all_url", "priority",
|
"icon", "items", "view_all_url", "priority",
|
||||||
|
// A2c (2026-10-19): + is_playlist, complete (docs/contract_pins.md).
|
||||||
|
"is_playlist", "complete",
|
||||||
}
|
}
|
||||||
sections := getArray(t, body, "sections")
|
sections := getArray(t, body, "sections")
|
||||||
require.NotEmpty(t, sections)
|
require.NotEmpty(t, sections)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
||||||
|
// an ordering construct must (a) land the real series on the item, (b)
|
||||||
|
// auto-create a curated playlist collection for the ordering construct with
|
||||||
|
// provenance + position, (c) survive a rescan without duplicates, (d)
|
||||||
|
// support owner-only reorder, and (e) filter finished books for Up Next /
|
||||||
|
// hide_finished with a completion marker when everything is read.
|
||||||
|
func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
adminID, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Playlist %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
// The user's exact Witcher shape: real series + reading-order construct.
|
||||||
|
itemDir := filepath.Join(folder, "Tag Author", "The Tagged Album")
|
||||||
|
fixtureAudio(t, "tagged.mp3", filepath.Join(itemDir, "tagged.mp3"))
|
||||||
|
sidecar := `{
|
||||||
|
"title": "The Tagged Album",
|
||||||
|
"authors": ["Tag Author"],
|
||||||
|
"series": ["The Tagged Saga #2", "The Tagged Saga (Reading Order) #7"]
|
||||||
|
}`
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(itemDir, "metadata.json"), []byte(sidecar), 0644))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folder)
|
||||||
|
|
||||||
|
// (a) The item carries the REAL series only; the reading-order entry
|
||||||
|
// never touches the series columns.
|
||||||
|
var series string
|
||||||
|
var seriesNum float64
|
||||||
|
var foundPath string
|
||||||
|
err := setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT series, series_number, file_path FROM media_items WHERE title = 'The Tagged Album'`,
|
||||||
|
).Scan(&series, &seriesNum, &foundPath)
|
||||||
|
if err != nil {
|
||||||
|
var paths []string
|
||||||
|
rows, qerr := setup.DBPool.Query(context.Background(),
|
||||||
|
`SELECT file_path FROM media_items WHERE library_id = $1`, libID)
|
||||||
|
if qerr == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
_ = rows.Scan(&p)
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
t.Fatalf("item not found by title (paths in library: %v): %v", paths, err)
|
||||||
|
}
|
||||||
|
require.Equal(t, "The Tagged Saga", series)
|
||||||
|
require.Equal(t, 2.0, seriesNum)
|
||||||
|
require.NotEmpty(t, foundPath)
|
||||||
|
|
||||||
|
// (b) The playlist collection exists with provenance, dashboard pin,
|
||||||
|
// and the item at reading position 7.
|
||||||
|
adminUUIDStr := adminID.String()
|
||||||
|
var collID uuid.UUID
|
||||||
|
var collName, collDesc string
|
||||||
|
var showOnDashboard bool
|
||||||
|
var viewSettingsRaw []byte
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT id, name, description, show_on_dashboard, view_settings
|
||||||
|
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
|
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
||||||
|
require.True(t, showOnDashboard, "playlists are dashboard-eligible by design")
|
||||||
|
require.Contains(t, string(viewSettingsRaw), "imported_from_series")
|
||||||
|
require.Contains(t, string(viewSettingsRaw), "show_up_next")
|
||||||
|
require.Contains(t, collDesc, "safe to edit or delete")
|
||||||
|
_ = adminUUIDStr
|
||||||
|
|
||||||
|
var position *int32
|
||||||
|
var itemCount int
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
||||||
|
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
||||||
|
require.Equal(t, 1, itemCount)
|
||||||
|
require.NotNil(t, position)
|
||||||
|
require.Equal(t, int32(7), *position)
|
||||||
|
_ = adminUUIDStr
|
||||||
|
|
||||||
|
// (c) Rescan: no duplicate collection, no duplicate membership.
|
||||||
|
scanTestFolder(t, setup, adminID, folder)
|
||||||
|
var collectionCount, membershipCount int
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
|
adminID).Scan(&collectionCount))
|
||||||
|
require.Equal(t, 1, collectionCount)
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
||||||
|
require.Equal(t, 1, membershipCount)
|
||||||
|
|
||||||
|
// (d) Owner-only reorder: positions become 1..n; a non-owner gets 403.
|
||||||
|
var itemID uuid.UUID
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM media_items WHERE title = 'The Tagged Album'`).Scan(&itemID))
|
||||||
|
|
||||||
|
reorderURL := fmt.Sprintf("%s/api/collections/%s/items/order", setup.Server.URL, collID.String())
|
||||||
|
body, _ := json.Marshal(map[string][]string{"item_ids": {itemID.String()}})
|
||||||
|
req, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
regularToken := setup.RegularToken
|
||||||
|
req2, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+regularToken)
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp2.StatusCode)
|
||||||
|
_ = resp2.Body.Close()
|
||||||
|
|
||||||
|
// (e) hide_finished + Up Next + completion marker.
|
||||||
|
// Flip the collection's flag on, mark the single item finished for the
|
||||||
|
// admin, and the dashboard section becomes Complete with no items.
|
||||||
|
_, err = setup.DBPool.Exec(context.Background(),
|
||||||
|
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(context.Background(),
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||||
|
VALUES ($1, $2, 1.0, NOW()) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 1.0`,
|
||||||
|
itemID, adminID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sectionsURL := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", setup.Server.URL, libID.String())
|
||||||
|
req3, _ := http.NewRequest("GET", sectionsURL, nil)
|
||||||
|
req3.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp3, err := http.DefaultClient.Do(req3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp3.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Sections []struct {
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
Complete bool `json:"complete"`
|
||||||
|
IsPlaylist bool `json:"is_playlist"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"sections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&payload))
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, s := range payload.Sections {
|
||||||
|
if s.CollectionID == collID.String() {
|
||||||
|
found = true
|
||||||
|
if !s.Complete {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
t.Fatalf("section found but not complete: %s", b)
|
||||||
|
}
|
||||||
|
require.True(t, s.IsPlaylist)
|
||||||
|
require.Empty(t, s.Items, "finished items must not show under hide_finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
b, _ := json.Marshal(payload.Sections)
|
||||||
|
t.Fatalf("playlist section missing; sections = %s", b)
|
||||||
|
}
|
||||||
|
require.True(t, found, "the playlist section must appear on the owner's dashboard")
|
||||||
|
|
||||||
|
// Up Next on the collection detail: the finished queue is empty.
|
||||||
|
detailURL := fmt.Sprintf("%s/api/collections/%s", setup.Server.URL, collID.String())
|
||||||
|
req4, _ := http.NewRequest("GET", detailURL, nil)
|
||||||
|
req4.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp4, err := http.DefaultClient.Do(req4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp4.Body.Close() }()
|
||||||
|
var detail struct {
|
||||||
|
UpNext []map[string]any `json:"up_next"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp4.Body).Decode(&detail))
|
||||||
|
require.Empty(t, detail.UpNext, "nothing is up next once everything is finished")
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
|||||||
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
|
('auto_playlist_collections', 'true', 'Auto-create curated playlist collections from sidecar series marked as reading orders', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||||
-- security / auth (live)
|
-- security / auth (live)
|
||||||
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||||
|
|||||||
@@ -136,3 +136,19 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
still accepted (arrays win when both present).
|
still accepted (arrays win when both present).
|
||||||
- Override keys unchanged: `author`/`publisher` override names are preserved
|
- Override keys unchanged: `author`/`publisher` override names are preserved
|
||||||
for existing rows and now protect the arrays.
|
for existing rows and now protect the arrays.
|
||||||
|
|
||||||
|
## 2026-10-19 — A2c: collections/playlists phase
|
||||||
|
|
||||||
|
- `GET /api/collections/:id` gained ADDITIVE `up_next` (array of
|
||||||
|
`{id, title, author, cover, format, progress}` — the static collection's
|
||||||
|
reading queue head in position order, viewer-progress-aware; empty array
|
||||||
|
for smart collections).
|
||||||
|
- `GET /api/dashboard/sections` rows gained ADDITIVE `is_playlist` (bool —
|
||||||
|
collection was imported from a sidecar ordering construct) and
|
||||||
|
`complete` (bool — hide_finished filtered every item out of a non-empty
|
||||||
|
collection; render "playlist complete").
|
||||||
|
- `PUT /api/collections/:id/items/order` is a NEW endpoint (owner-only,
|
||||||
|
full ordered id list → positions 1..n).
|
||||||
|
- User collections are dashboard-eligible by default (the
|
||||||
|
show_on_dashboard gate is gone from GetUserCollectionsForDashboard);
|
||||||
|
hiding stays dashboard-prefs (`hidden_collections`) territory.
|
||||||
@@ -14,6 +14,9 @@ type Querier interface {
|
|||||||
// COLLECTION ITEMS QUERIES
|
// COLLECTION ITEMS QUERIES
|
||||||
// Add book to collection
|
// Add book to collection
|
||||||
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
|
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
|
||||||
|
// A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||||
|
// re-adds never clobber the user's manual drag ordering.
|
||||||
|
AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
// KOBO SHELF MANAGEMENT QUERIES
|
// KOBO SHELF MANAGEMENT QUERIES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -42,6 +45,10 @@ type Querier interface {
|
|||||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||||
CountAdmins(ctx context.Context) (int64, error)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||||
|
// 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.
|
||||||
|
CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error)
|
||||||
// Live item count for a STATIC collection (rule-free cards on the
|
// Live item count for a STATIC collection (rule-free cards on the
|
||||||
// collections page); same live filter as GetCollectionItems, plus the
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
// future-hybrid excluded exclusion and an optional library scope.
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
@@ -174,12 +181,30 @@ type Querier interface {
|
|||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
// Get collection
|
// Get collection
|
||||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||||
|
// A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||||
|
// rename can't spawn duplicates on the next scan.
|
||||||
|
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||||
|
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||||
// Get collection items
|
// 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.
|
||||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||||
// returns the real row so neither exists).
|
// 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).
|
||||||
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
|
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
|
||||||
|
// 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).
|
||||||
|
GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error)
|
||||||
// Get collection with book count
|
// Get collection with book count
|
||||||
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
|
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
|
||||||
// Get collections by user
|
// Get collections by user
|
||||||
@@ -393,6 +418,10 @@ type Querier interface {
|
|||||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||||
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||||
|
// 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.
|
||||||
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||||
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
||||||
// carry no device (web reader until it identifies) still appear as one
|
// carry no device (web reader until it identifies) still appear as one
|
||||||
@@ -510,6 +539,9 @@ type Querier interface {
|
|||||||
// Remove book from collection
|
// Remove book from collection
|
||||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||||
|
// 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.
|
||||||
|
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||||
@@ -556,6 +588,9 @@ type Querier interface {
|
|||||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||||
// of the JSONB without touching metadata stale clients did not send).
|
// of the JSONB without touching metadata stale clients did not send).
|
||||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||||
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
|
// touching the rest of the row.
|
||||||
|
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||||
|
|||||||
@@ -42,6 +42,42 @@ func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollecti
|
|||||||
return i, err
|
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
|
const AddBookToKoboShelf = `-- name: AddBookToKoboShelf :one
|
||||||
|
|
||||||
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
||||||
@@ -294,6 +330,20 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) {
|
|||||||
return count, err
|
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
|
const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one
|
||||||
SELECT COUNT(*)::int FROM collection_items ci
|
SELECT COUNT(*)::int FROM collection_items ci
|
||||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
@@ -399,8 +449,8 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create
|
|||||||
|
|
||||||
const CreateCollection = `-- name: CreateCollection :one
|
const CreateCollection = `-- name: CreateCollection :one
|
||||||
|
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -412,6 +462,7 @@ type CreateCollectionParams struct {
|
|||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// COLLECTIONS QUERIES
|
// COLLECTIONS QUERIES
|
||||||
@@ -425,6 +476,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
|||||||
arg.Icon,
|
arg.Icon,
|
||||||
arg.AutoAssignRules,
|
arg.AutoAssignRules,
|
||||||
arg.ViewSettings,
|
arg.ViewSettings,
|
||||||
|
arg.ShowOnDashboard,
|
||||||
)
|
)
|
||||||
var i Collections
|
var i Collections
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -2824,6 +2876,77 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
||||||
|
SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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.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, 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.ViewSettings,
|
||||||
|
&i.ShowOnDashboard,
|
||||||
|
&i.QueryType,
|
||||||
|
&i.Priority,
|
||||||
|
&i.IsSystemCollection,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetCollectionItems = `-- name: GetCollectionItems :many
|
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
|
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
|
FROM collection_items ci
|
||||||
@@ -2831,7 +2954,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id
|
|||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = $1
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY ci.added_at DESC
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetCollectionItemsRow struct {
|
type GetCollectionItemsRow struct {
|
||||||
@@ -2850,6 +2973,9 @@ type GetCollectionItemsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get collection items
|
// 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) {
|
func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, GetCollectionItems, collectionID)
|
rows, err := q.db.Query(ctx, GetCollectionItems, collectionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2884,32 +3010,49 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.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 FROM media_items mi
|
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, rp.percentage AS viewer_percentage FROM media_items mi
|
||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = $1
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
||||||
AND ($2::uuid IS NULL OR mi.library_id = $2::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.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (ci.excluded IS NOT TRUE)
|
AND (ci.excluded IS NOT TRUE)
|
||||||
ORDER BY ci.added_at DESC
|
AND ($4::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
||||||
LIMIT $3
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||||
|
LIMIT $5
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetCollectionItemsForDashboardParams struct {
|
type GetCollectionItemsForDashboardParams struct {
|
||||||
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_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"`
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetCollectionItemsForDashboardRow struct {
|
type GetCollectionItemsForDashboardRow struct {
|
||||||
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
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
|
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||||
// returns the real row so neither exists).
|
// 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) {
|
func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) {
|
||||||
rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard, arg.CollectionID, arg.LibraryID, arg.Limit)
|
rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard,
|
||||||
|
arg.ViewerUserID,
|
||||||
|
arg.CollectionID,
|
||||||
|
arg.LibraryID,
|
||||||
|
arg.HideFinished,
|
||||||
|
arg.Limit,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2992,6 +3135,138 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
|
|||||||
&i.MediaItems.OpfIdentifier,
|
&i.MediaItems.OpfIdentifier,
|
||||||
&i.MediaItems.OpfUuid,
|
&i.MediaItems.OpfUuid,
|
||||||
&i.MediaItems.HashConfidence,
|
&i.MediaItems.HashConfidence,
|
||||||
|
&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, 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.ViewerPercentage,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -8614,12 +8889,15 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
|||||||
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
||||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, 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
|
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, 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
|
WHERE c.user_id = $1
|
||||||
AND c.show_on_dashboard = true
|
|
||||||
AND c.is_system_collection = false
|
AND c.is_system_collection = false
|
||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
ORDER BY priority ASC
|
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) {
|
func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) {
|
||||||
rows, err := q.db.Query(ctx, GetUserCollectionsForDashboard, userID)
|
rows, err := q.db.Query(ctx, GetUserCollectionsForDashboard, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -12121,6 +12399,26 @@ func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFro
|
|||||||
return err
|
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
|
const ReparentMediaItemChildren = `-- name: ReparentMediaItemChildren :exec
|
||||||
SELECT reparent_media_item_children($1::uuid, $2::uuid)
|
SELECT reparent_media_item_children($1::uuid, $2::uuid)
|
||||||
`
|
`
|
||||||
@@ -13711,6 +14009,22 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateCollectionViewSettings = `-- name: UpdateCollectionViewSettings :exec
|
||||||
|
UPDATE collections SET view_settings = $2, updated_at = NOW() 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.
|
||||||
|
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
|
const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one
|
||||||
UPDATE user_dashboard_preferences
|
UPDATE user_dashboard_preferences
|
||||||
SET hidden_collections = $2,
|
SET hidden_collections = $2,
|
||||||
|
|||||||
@@ -2328,10 +2328,15 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
|||||||
|
|
||||||
-- Create collection
|
-- Create collection
|
||||||
-- name: CreateCollection :one
|
-- name: CreateCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
|
-- touching the rest of the row.
|
||||||
|
-- name: UpdateCollectionViewSettings :exec
|
||||||
|
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
-- Get collection
|
-- Get collection
|
||||||
-- name: GetCollection :one
|
-- name: GetCollection :one
|
||||||
SELECT * FROM collections WHERE id = $1;
|
SELECT * FROM collections WHERE id = $1;
|
||||||
@@ -2381,11 +2386,46 @@ VALUES ($1, $2, $3)
|
|||||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||||
|
-- re-adds never clobber the user's manual drag ordering.
|
||||||
|
-- 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 *;
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
-- name: ReorderCollectionItems :exec
|
||||||
|
UPDATE collection_items AS ci
|
||||||
|
SET position = d.ord
|
||||||
|
FROM unnest(sqlc.arg('media_item_ids')::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
||||||
|
WHERE ci.collection_id = sqlc.arg('collection_id')::uuid
|
||||||
|
AND ci.media_item_id = d.media_item_id;
|
||||||
|
|
||||||
|
-- A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||||
|
-- rename can't spawn duplicates on the next scan.
|
||||||
|
-- name: GetCollectionByImportedSeries :one
|
||||||
|
SELECT * FROM collections
|
||||||
|
WHERE user_id = sqlc.arg('user_id')
|
||||||
|
AND view_settings->>'imported_from_series' = sqlc.arg('imported_from_series')::text
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetCollectionByUserAndName :one
|
||||||
|
SELECT * FROM collections
|
||||||
|
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- Remove book from collection
|
-- Remove book from collection
|
||||||
-- name: RemoveBookFromCollection :exec
|
-- name: RemoveBookFromCollection :exec
|
||||||
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
||||||
|
|
||||||
-- Get collection items
|
-- 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.
|
||||||
-- name: GetCollectionItems :many
|
-- name: GetCollectionItems :many
|
||||||
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
||||||
FROM collection_items ci
|
FROM collection_items ci
|
||||||
@@ -2393,7 +2433,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id
|
|||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = $1
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY ci.added_at DESC;
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
-- Get collections for book
|
-- Get collections for book
|
||||||
-- name: GetCollectionsForBook :many
|
-- name: GetCollectionsForBook :many
|
||||||
@@ -2710,9 +2750,12 @@ WHERE user_id = $1
|
|||||||
ORDER BY priority ASC;
|
ORDER BY priority ASC;
|
||||||
|
|
||||||
-- name: GetUserCollectionsForDashboard :many
|
-- name: GetUserCollectionsForDashboard :many
|
||||||
|
-- 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.
|
||||||
SELECT c.* FROM collections c
|
SELECT c.* FROM collections c
|
||||||
WHERE c.user_id = $1
|
WHERE c.user_id = $1
|
||||||
AND c.show_on_dashboard = true
|
|
||||||
AND c.is_system_collection = false
|
AND c.is_system_collection = false
|
||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
ORDER BY priority ASC;
|
ORDER BY priority ASC;
|
||||||
@@ -2828,16 +2871,47 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- The excluded filter lives here (was: callers filtered ci.excluded in
|
-- The excluded filter lives here (was: callers filtered ci.excluded in
|
||||||
-- Go over a 69-field row + a 45-field converter to MediaItems; embed
|
-- Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||||
-- returns the real row so neither exists).
|
-- returns the real row so neither exists).
|
||||||
SELECT sqlc.embed(mi) FROM media_items mi
|
-- 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).
|
||||||
|
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (ci.excluded IS NOT TRUE)
|
AND (ci.excluded IS NOT TRUE)
|
||||||
ORDER BY ci.added_at DESC
|
AND (sqlc.narg('hide_finished')::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
|
-- 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).
|
||||||
|
-- name: GetCollectionUpNext :many
|
||||||
|
SELECT sqlc.embed(mi), 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 = sqlc.narg('viewer_user_id')::uuid
|
||||||
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||||
|
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 (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||||
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
-- name: CountCollectionItems :one
|
||||||
|
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
-- membership comes from the candidate book set instead of collection_items.
|
-- membership comes from the candidate book set instead of collection_items.
|
||||||
-- The set is scoped to the requesting user's visible libraries (family
|
-- The set is scoped to the requesting user's visible libraries (family
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type SettingDefault struct {
|
|||||||
var SettingDefaults = []SettingDefault{
|
var SettingDefaults = []SettingDefault{
|
||||||
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
||||||
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
||||||
|
{Key: "auto_playlist_collections", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Auto-create curated playlist collections from sidecar series marked as reading orders"},
|
||||||
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
||||||
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
||||||
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
||||||
@@ -189,6 +190,11 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
|||||||
// AutoScanEnabled reports whether auto-scanning is on.
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
// AutoPlaylistCollections reports whether the scanner should turn
|
||||||
|
// obviously-playlist sidecar series ("The Witcher (Reading Order)") into
|
||||||
|
// curated static collections (A2c §2.4).
|
||||||
|
func (r *SettingsRegistry) AutoPlaylistCollections() bool { return r.getBool("auto_playlist_collections") }
|
||||||
|
|
||||||
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
||||||
// creation is never affected — only self-service registration rides this
|
// creation is never affected — only self-service registration rides this
|
||||||
// flag; handlers additionally grant the first-user exception (see
|
// flag; handlers additionally grant the first-user exception (see
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ type SectionData struct {
|
|||||||
Items []BookInfo `json:"items"`
|
Items []BookInfo `json:"items"`
|
||||||
ViewAllURL string `json:"view_all_url"`
|
ViewAllURL string `json:"view_all_url"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
// A2c: curated playlist marker (imported reading-order collection) and
|
||||||
|
// the hide_finished completion state — dashboard.ts renders both.
|
||||||
|
IsPlaylist bool `json:"is_playlist"`
|
||||||
|
Complete bool `json:"complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||||
@@ -284,10 +288,28 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if libUUID.Valid {
|
} else if libUUID.Valid {
|
||||||
|
// A2c §3.3: the detail page honors the same per-collection
|
||||||
|
// hide_finished as the dashboard section, against the viewer's
|
||||||
|
// progress. The viewer_percentage rides along for card graying.
|
||||||
|
hideFinished := false
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||||
|
if b, ok := vs["hide_finished"].(bool); ok {
|
||||||
|
hideFinished = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var viewerID pgtype.UUID
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
viewerID = u.ID
|
||||||
|
}
|
||||||
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||||
database.GetCollectionItemsForDashboardParams{
|
database.GetCollectionItemsForDashboardParams{
|
||||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
|
ViewerUserID: viewerID,
|
||||||
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
|
||||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||||
})
|
})
|
||||||
if collErr != nil {
|
if collErr != nil {
|
||||||
@@ -317,6 +339,40 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A2c §3.4: the collection's reading queue for the Up Next strip.
|
||||||
|
// Library visibility enforced — a hidden library's books never surface
|
||||||
|
// here even if they sit in a visible collection.
|
||||||
|
upNext := []map[string]interface{}{}
|
||||||
|
if !isSmart {
|
||||||
|
hideFinished := false
|
||||||
|
if b, ok := viewSettings["hide_finished"].(bool); ok {
|
||||||
|
hideFinished = b
|
||||||
|
}
|
||||||
|
_ = hideFinished
|
||||||
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||||
|
if visErr == nil {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
|
VisibleLibraryIds: visibleIDs,
|
||||||
|
Limit: pgtype.Int4{Int32: 10, Valid: true},
|
||||||
|
})
|
||||||
|
if upErr == nil {
|
||||||
|
for _, row := range rows {
|
||||||
|
upNext = append(upNext, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
|
||||||
|
"title": row.MediaItems.Title,
|
||||||
|
"author": utils.PgTextString(row.MediaItems.Author),
|
||||||
|
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
|
||||||
|
"format": row.MediaItems.FormatGroup,
|
||||||
|
"progress": row.ViewerPercentage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
@@ -329,6 +385,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
"books": bookList,
|
"books": bookList,
|
||||||
"book_count": len(bookList),
|
"book_count": len(bookList),
|
||||||
"is_smart": isSmart,
|
"is_smart": isSmart,
|
||||||
|
"up_next": upNext,
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1238,3 +1295,44 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderItems applies a static collection's manual playlist order
|
||||||
|
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
||||||
|
// ids omitted keep their relative order appended after (the service never
|
||||||
|
// deletes membership).
|
||||||
|
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
user, ok := c.Get("user").(database.Users)
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ItemIDs []string `json:"item_ids"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
||||||
|
}
|
||||||
|
|
||||||
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
||||||
|
if err != nil || !owns {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
||||||
|
for _, raw := range req.ItemIDs {
|
||||||
|
id, parseErr := uuid.Parse(raw)
|
||||||
|
if parseErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
||||||
|
}
|
||||||
|
ordered = append(ordered, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
||||||
|
}
|
||||||
@@ -211,6 +211,8 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra
|
|||||||
Items: bookCards,
|
Items: bookCards,
|
||||||
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
||||||
Priority: ds.Priority,
|
Priority: ds.Priority,
|
||||||
|
IsPlaylist: ds.IsPlaylist,
|
||||||
|
Complete: ds.Complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.DELETE("/:id", cfg.CollectionHandler.DeleteCollection)
|
collections.DELETE("/:id", cfg.CollectionHandler.DeleteCollection)
|
||||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||||
|
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||||
|
|||||||
@@ -785,3 +785,35 @@ func parseFloat(s string) (float64, error) {
|
|||||||
|
|
||||||
return sign * result / divisor, nil
|
return sign * result / divisor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
|
||||||
|
// collection row's owner is the only user whose drag actions rewrite
|
||||||
|
// positions.
|
||||||
|
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
|
||||||
|
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return uuid.UUID(collection.UserID.Bytes) == userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
|
||||||
|
// complete ordered id list; positions become 1..n in payload order. Ids
|
||||||
|
// not in the payload keep their relative order after — the SQL only
|
||||||
|
// touches the given ids, and NULL positions sort last. Never deletes.
|
||||||
|
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
|
||||||
|
if len(orderedIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]pgtype.UUID, 0, len(orderedIDs))
|
||||||
|
for _, id := range orderedIDs {
|
||||||
|
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
|
||||||
|
}
|
||||||
|
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
MediaItemIds: ids,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to reorder collection items: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -24,6 +24,34 @@ func NewDashboardService(db *database.Queries) *DashboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewSettingsFlag reads a boolean key from a collection's view_settings
|
||||||
|
// JSONB. Malformed or absent → false.
|
||||||
|
func viewSettingsFlag(raw []byte, key string) bool {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b, ok := settings[key].(bool)
|
||||||
|
return ok && b
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewSettingsHasKey reports whether the key exists at all (any value) —
|
||||||
|
// used for provenance markers like imported_from_series.
|
||||||
|
func viewSettingsHasKey(raw []byte, key string) bool {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := settings[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardSection struct {
|
type DashboardSection struct {
|
||||||
CollectionID uuid.UUID
|
CollectionID uuid.UUID
|
||||||
CollectionName string
|
CollectionName string
|
||||||
@@ -34,6 +62,13 @@ type DashboardSection struct {
|
|||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Icon string
|
Icon string
|
||||||
|
// A2c: the collection's own ordering-construct marker — the section is
|
||||||
|
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
|
||||||
|
// badge by the frontend.
|
||||||
|
IsPlaylist bool
|
||||||
|
// A2c §3.5: hide_finished filtered every item out of a NON-empty
|
||||||
|
// collection — render "playlist complete" instead of dropping silently.
|
||||||
|
Complete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetDashboardSections(
|
func (s *DashboardService) GetDashboardSections(
|
||||||
@@ -81,7 +116,29 @@ func (s *DashboardService) GetDashboardSections(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A2c §3.5: hide_finished filtered everything out — but the
|
||||||
|
// collection is NOT empty. That's a finished playlist: render a
|
||||||
|
// completion marker instead of silently vanishing.
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||||
|
if hideFinished {
|
||||||
|
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||||
|
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
|
||||||
|
results = append(results, DashboardSection{
|
||||||
|
CollectionID: coll.ID.Bytes,
|
||||||
|
CollectionName: coll.Name,
|
||||||
|
Items: []database.MediaItems{},
|
||||||
|
QueryType: coll.QueryType.String,
|
||||||
|
Priority: int(coll.Priority.Int32),
|
||||||
|
IsSystem: false,
|
||||||
|
Title: coll.Name,
|
||||||
|
Description: coll.Description.String,
|
||||||
|
Icon: coll.Icon.String,
|
||||||
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||||
|
Complete: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +152,7 @@ func (s *DashboardService) GetDashboardSections(
|
|||||||
Title: coll.Name,
|
Title: coll.Name,
|
||||||
Description: coll.Description.String,
|
Description: coll.Description.String,
|
||||||
Icon: coll.Icon.String,
|
Icon: coll.Icon.String,
|
||||||
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +276,15 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
|
|||||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||||
|
|
||||||
|
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
|
||||||
|
// against the VIEWER's reading progress. The section then shows the
|
||||||
|
// head of the reading queue in playlist order.
|
||||||
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
|
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
|
||||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -268,6 +269,136 @@ func (s *MediaScanner) GetAutoScanEnabled() bool {
|
|||||||
return strings.ToLower(setting) == "true"
|
return strings.ToLower(setting) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAutoPlaylistCollections mirrors GetAutoScanEnabled for the A2c
|
||||||
|
// playlist-collection toggle. Defaults ON: the feature only creates
|
||||||
|
// deletable curated collections from explicit "(Reading Order)" markers.
|
||||||
|
func (s *MediaScanner) GetAutoPlaylistCollections() bool {
|
||||||
|
if cached, ok := s.settingsCache.Get("auto_playlist_collections"); ok {
|
||||||
|
return strings.ToLower(cached) == "true"
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
setting, err := s.db.GetSystemSetting(ctx, "auto_playlist_collections")
|
||||||
|
if err != nil || setting == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.settingsCache.Set("auto_playlist_collections", setting)
|
||||||
|
return strings.ToLower(setting) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPlaylistCollectionsAfterCreate turns a created item's playlist-marked
|
||||||
|
// series entries (A2c §2.2) into curated static collections (§2.4):
|
||||||
|
// upsert by imported_from_series provenance then name, add-if-missing with
|
||||||
|
// position ONLY on insert, provenance in description + view_settings,
|
||||||
|
// dashboard-eligible by default. Deleting a sidecar later deletes nothing —
|
||||||
|
// the collection is user data from the moment it exists.
|
||||||
|
func (s *MediaScanner) syncPlaylistCollectionsAfterCreate(ctx context.Context, itemID pgtype.UUID, entries []SeriesCandidate) {
|
||||||
|
if len(entries) == 0 || s.db == nil || !s.GetAutoPlaylistCollections() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
collection, created, err := s.upsertPlaylistCollection(ctx, entry)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: playlist collection sync failed for %q: %v\n", entry.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
position := cleanIntegerPosition(entry.Sequence)
|
||||||
|
if _, err := s.db.AddBookToCollectionOrdered(ctx, database.AddBookToCollectionOrderedParams{
|
||||||
|
CollectionID: collection.ID,
|
||||||
|
MediaItemID: itemID,
|
||||||
|
AddedByUserID: s.adminID,
|
||||||
|
Position: pgtype.Int4{Int32: position, Valid: position > 0},
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Printf("Warning: playlist collection add failed for %q: %v\n", entry.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
fmt.Printf("Created playlist collection %q from sidecar series (safe to edit or delete)\n", entry.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertPlaylistCollection finds the playlist collection for a sidecar
|
||||||
|
// series: provenance key first (survives renames), name second.
|
||||||
|
func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry SeriesCandidate) (database.Collections, bool, error) {
|
||||||
|
owner := s.adminID
|
||||||
|
if collection, err := s.db.GetCollectionByImportedSeries(ctx, database.GetCollectionByImportedSeriesParams{
|
||||||
|
UserID: owner,
|
||||||
|
ImportedFromSeries: entry.Name,
|
||||||
|
}); err == nil {
|
||||||
|
return collection, false, nil
|
||||||
|
}
|
||||||
|
if collection, err := s.db.GetCollectionByUserAndName(ctx, database.GetCollectionByUserAndNameParams{
|
||||||
|
UserID: owner,
|
||||||
|
Name: entry.Name,
|
||||||
|
}); err == nil {
|
||||||
|
// Name match, no provenance: mark it so future scans find it by
|
||||||
|
// provenance and don't spawn a duplicate after a rename.
|
||||||
|
s.markPlaylistProvenance(ctx, collection.ID, entry.Name)
|
||||||
|
return collection, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
description := fmt.Sprintf("Imported from metadata.json sidecar (series %q) — safe to edit or delete.", entry.Name)
|
||||||
|
viewSettings, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"show_up_next": true,
|
||||||
|
"imported_from_series": entry.Name,
|
||||||
|
})
|
||||||
|
created, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
||||||
|
UserID: owner,
|
||||||
|
Name: entry.Name,
|
||||||
|
Description: pgtype.Text{String: description, Valid: true},
|
||||||
|
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
||||||
|
ViewSettings: viewSettings,
|
||||||
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, false, err
|
||||||
|
}
|
||||||
|
return created, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaScanner) markPlaylistProvenance(ctx context.Context, collectionID pgtype.UUID, seriesName string) {
|
||||||
|
collection, err := s.db.GetCollection(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
_ = json.Unmarshal(collection.ViewSettings, &settings)
|
||||||
|
}
|
||||||
|
if settings == nil {
|
||||||
|
settings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
settings["imported_from_series"] = seriesName
|
||||||
|
settings["show_up_next"] = true
|
||||||
|
b, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.db.UpdateCollectionViewSettings(ctx, database.UpdateCollectionViewSettingsParams{
|
||||||
|
ID: collectionID,
|
||||||
|
ViewSettings: b,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanIntegerPosition accepts only CLEAN integer sequences ("3") as a
|
||||||
|
// playlist position; decimals ("1.5") and lettered ("1a") stay un-positioned
|
||||||
|
// (ordering falls back to added_at) rather than guessing.
|
||||||
|
func cleanIntegerPosition(sequence string) int32 {
|
||||||
|
sequence = strings.TrimSpace(sequence)
|
||||||
|
if sequence == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(sequence, 64)
|
||||||
|
if err != nil || f != math.Trunc(f) || f < 1 || f > 2147483647 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int32(f)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) SetAdminID(adminID pgtype.UUID) {
|
func (s *MediaScanner) SetAdminID(adminID pgtype.UUID) {
|
||||||
s.adminID = adminID
|
s.adminID = adminID
|
||||||
}
|
}
|
||||||
@@ -1139,6 +1270,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.newItems++
|
s.newItems++
|
||||||
|
|
||||||
|
// A2c §2.4: obvious playlist sidecar series become curated collections.
|
||||||
|
s.syncPlaylistCollectionsAfterCreate(ctx, createdItem.ID, metadata.PlaylistSeries)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -935,6 +935,7 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
|||||||
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
||||||
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
|
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
|
||||||
}
|
}
|
||||||
|
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||||
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
|
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
|
||||||
s.newItems++
|
s.newItems++
|
||||||
default:
|
default:
|
||||||
@@ -1057,6 +1058,7 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
|||||||
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
||||||
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
|
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
|
||||||
}
|
}
|
||||||
|
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||||
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
|
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
|
||||||
s.newItems++
|
s.newItems++
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in new issue
Block a user