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:
John O'Keefe committed 2026-10-19 15:25:40 -04:00
1 parent 7e4c97f639
commit 1dbb0ce406
15 files changed
+992 -18

No files matched your search

+5 -1
View File
@@ -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")
}
+1
View File
@@ -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'),
+16
View File
@@ -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.
+35
View File
@@ -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)
+325 -11
View File
@@ -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,
+80 -6
View File
@@ -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
+6
View File
@@ -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
+98
View File
@@ -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"})
}
+2
View File
@@ -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,
}) })
} }
+1
View File
@@ -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)
+32
View File
@@ -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
}
+64
View File
@@ -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 {
+134
View File
@@ -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: