diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go index b9fc536b..35beaee4 100644 --- a/cmd/server/tests/contract_pins_test.go +++ b/cmd/server/tests/contract_pins_test.go @@ -490,10 +490,12 @@ func TestContractPin_CollectionsJSON(t *testing.T) { fx := createContractFixture(t, setup) // 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{ "id", "user_id", "name", "description", "color", "icon", "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) { @@ -693,6 +695,8 @@ func TestContractPin_DashboardSections(t *testing.T) { sectionKeys := []string{ "id", "collection_id", "is_system", "title", "description", "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") require.NotEmpty(t, sections) diff --git a/cmd/server/tests/playlist_collections_test.go b/cmd/server/tests/playlist_collections_test.go new file mode 100644 index 00000000..62352db8 --- /dev/null +++ b/cmd/server/tests/playlist_collections_test.go @@ -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") +} diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 18f685d1..b89586e0 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -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 ('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_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'), -- security / auth (live) ('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'), diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 9f011b96..0cebd47c 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -136,3 +136,19 @@ cover_image_path, has_conflict, format_group, badge`. still accepted (arrays win when both present). - Override keys unchanged: `author`/`publisher` override names are preserved 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. diff --git a/internal/database/querier.go b/internal/database/querier.go index bf4d9104..8a68c140 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -14,6 +14,9 @@ type Querier interface { // COLLECTION ITEMS QUERIES // Add book to collection 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 // ============================================ @@ -42,6 +45,10 @@ type Querier interface { ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error CountAdmins(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 // collections page); same live filter as GetCollectionItems, plus the // future-hybrid excluded exclusion and an optional library scope. @@ -174,12 +181,30 @@ type Querier interface { GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) // Get collection 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 + // 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) // The excluded filter lives here (was: callers filtered ci.excluded in // Go over a 69-field row + a 45-field converter to MediaItems; embed // returns the real row so neither exists). + // A2c: position-first ordering; optional hide_finished — when the flag is + // set AND a viewer is supplied, items the viewer finished (percentage + // >= 0.999) drop out, turning the section into the reading queue head. + // viewer_user_id NULL = no progress filter (never-finished semantics for + // anonymous/legacy callers is NOT implied: hide_finished without a viewer + // is a caller bug and shows everything). 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 GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error) // Get collections by user @@ -393,6 +418,10 @@ type Querier interface { GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, 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) // Per-device reading breakdown for a range. LEFT JOIN so pushes that // carry no device (web reader until it identifies) still appear as one @@ -510,6 +539,9 @@ type Querier interface { // Remove book from collection RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) 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) ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) 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 // of the JSONB without touching metadata stale clients did not send). 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) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 32fa577e..74eb1632 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -42,6 +42,42 @@ func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollecti return i, err } +const AddBookToCollectionOrdered = `-- name: AddBookToCollectionOrdered :one +INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position) +VALUES ($1, $2, $3, $4) +ON CONFLICT (collection_id, media_item_id) DO NOTHING +RETURNING id, collection_id, media_item_id, added_at, added_by_user_id, excluded, position +` + +type AddBookToCollectionOrderedParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` + Position pgtype.Int4 `db:"position" json:"position"` +} + +// A2c: playlist upsert — position set ONLY on insert, so rescans and +// re-adds never clobber the user's manual drag ordering. +func (q *Queries) AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error) { + row := q.db.QueryRow(ctx, AddBookToCollectionOrdered, + arg.CollectionID, + arg.MediaItemID, + arg.AddedByUserID, + arg.Position, + ) + var i CollectionItems + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.MediaItemID, + &i.AddedAt, + &i.AddedByUserID, + &i.Excluded, + &i.Position, + ) + return i, err +} + const AddBookToKoboShelf = `-- name: AddBookToKoboShelf :one INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position) @@ -294,6 +330,20 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) { return count, err } +const CountCollectionItems = `-- name: CountCollectionItems :one +SELECT COUNT(*) FROM collection_items WHERE collection_id = $1 +` + +// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the +// dashboard renders a completion marker when hide_finished filtered +// everything out of a non-empty collection. +func (q *Queries) CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, CountCollectionItems, collectionID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one SELECT COUNT(*)::int FROM collection_items ci JOIN media_items mi ON ci.media_item_id = mi.id @@ -399,8 +449,8 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create const CreateCollection = `-- name: CreateCollection :one -INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) -VALUES ($1, $2, $3, $4, $5, $6, $7) +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, $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 ` @@ -412,6 +462,7 @@ type CreateCollectionParams struct { Icon pgtype.Text `db:"icon" json:"icon"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` ViewSettings []byte `db:"view_settings" json:"view_settings"` + ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` } // COLLECTIONS QUERIES @@ -425,6 +476,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara arg.Icon, arg.AutoAssignRules, arg.ViewSettings, + arg.ShowOnDashboard, ) var i Collections err := row.Scan( @@ -2824,6 +2876,77 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection 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 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 @@ -2831,7 +2954,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 -ORDER BY ci.added_at DESC +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC ` type GetCollectionItemsRow struct { @@ -2850,6 +2973,9 @@ type GetCollectionItemsRow struct { } // Get collection items +// A2c: position-first ordering (playlist reading order); un-positioned +// legacy items keep added_at ASC after the positioned tail — NOT DESC, +// which walked a playlist backwards. func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) { rows, err := q.db.Query(ctx, GetCollectionItems, collectionID) if err != nil { @@ -2884,32 +3010,49 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU } 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 -WHERE ci.collection_id = $1 - AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) +LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid +WHERE ci.collection_id = $2 + AND ($3::uuid IS NULL OR mi.library_id = $3::uuid) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 AND (ci.excluded IS NOT TRUE) -ORDER BY ci.added_at DESC -LIMIT $3 + AND ($4::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999) +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC +LIMIT $5 ` type GetCollectionItemsForDashboardParams struct { + ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"` CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + HideFinished pgtype.Bool `db:"hide_finished" json:"hide_finished"` Limit pgtype.Int4 `db:"limit" json:"limit"` } type GetCollectionItemsForDashboardRow struct { - MediaItems MediaItems `db:"media_items" json:"media_items"` + MediaItems MediaItems `db:"media_items" json:"media_items"` + ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"` } // The excluded filter lives here (was: callers filtered ci.excluded in // Go over a 69-field row + a 45-field converter to MediaItems; embed // returns the real row so neither exists). +// A2c: position-first ordering; optional hide_finished — when the flag is +// set AND a viewer is supplied, items the viewer finished (percentage +// >= 0.999) drop out, turning the section into the reading queue head. +// viewer_user_id NULL = no progress filter (never-finished semantics for +// anonymous/legacy callers is NOT implied: hide_finished without a viewer +// is a caller bug and shows everything). func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) { - rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard, arg.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 { return nil, err } @@ -2992,6 +3135,138 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol &i.MediaItems.OpfIdentifier, &i.MediaItems.OpfUuid, &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 { return nil, err } @@ -8614,12 +8889,15 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs 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 WHERE c.user_id = $1 - AND c.show_on_dashboard = true AND c.is_system_collection = false AND c.deleted_at IS NULL ORDER BY priority ASC ` +// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT — +// show_on_dashboard gated them forever-false (no writer ever set it on +// user rows), and hiding is the dashboard-prefs job (hidden_collections). +// The flag remains system-section provisioning state only. func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) { rows, err := q.db.Query(ctx, GetUserCollectionsForDashboard, userID) if err != nil { @@ -12121,6 +12399,26 @@ func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFro return err } +const ReorderCollectionItems = `-- name: ReorderCollectionItems :exec +UPDATE collection_items AS ci +SET position = d.ord +FROM unnest($2::uuid[]) WITH ORDINALITY AS d(media_item_id, ord) +WHERE ci.collection_id = $1::uuid + AND ci.media_item_id = d.media_item_id +` + +type ReorderCollectionItemsParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemIds []pgtype.UUID `db:"media_item_ids" json:"media_item_ids"` +} + +// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is +// the complete ordered id list; positions become 1..n in payload order. +func (q *Queries) ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error { + _, err := q.db.Exec(ctx, ReorderCollectionItems, arg.CollectionID, arg.MediaItemIds) + return err +} + const ReparentMediaItemChildren = `-- name: ReparentMediaItemChildren :exec SELECT reparent_media_item_children($1::uuid, $2::uuid) ` @@ -13711,6 +14009,22 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle 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 UPDATE user_dashboard_preferences SET hidden_collections = $2, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 626df7ac..edb7aca6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2328,10 +2328,15 @@ DELETE FROM device_file_aliases WHERE id = $1; -- Create collection -- name: CreateCollection :one -INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) -VALUES ($1, $2, $3, $4, $5, $6, $7) +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, $8) 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 -- name: GetCollection :one SELECT * FROM collections WHERE id = $1; @@ -2381,11 +2386,46 @@ VALUES ($1, $2, $3) ON CONFLICT (collection_id, media_item_id) DO NOTHING 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 -- name: RemoveBookFromCollection :exec DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2; -- 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 SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group FROM collection_items ci @@ -2393,7 +2433,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 -ORDER BY ci.added_at DESC; +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC; -- Get collections for book -- name: GetCollectionsForBook :many @@ -2710,9 +2750,12 @@ WHERE user_id = $1 ORDER BY priority ASC; -- 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 WHERE c.user_id = $1 - AND c.show_on_dashboard = true AND c.is_system_collection = false AND c.deleted_at IS NULL ORDER BY priority ASC; @@ -2828,16 +2871,47 @@ LIMIT sqlc.narg('limit'); -- The excluded filter lives here (was: callers filtered ci.excluded in -- Go over a 69-field row + a 45-field converter to MediaItems; embed -- returns the real row so neither exists). -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 +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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 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'); +-- 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 -- membership comes from the candidate book set instead of collection_items. -- The set is scoped to the requesting user's visible libraries (family diff --git a/internal/database/settings_registry.go b/internal/database/settings_registry.go index 941586e3..0137090a 100644 --- a/internal/database/settings_registry.go +++ b/internal/database/settings_registry.go @@ -58,6 +58,7 @@ type SettingDefault struct { 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: "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: "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"}, @@ -189,6 +190,11 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration { // AutoScanEnabled reports whether auto-scanning is on. 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 // creation is never affected — only self-service registration rides this // flag; handlers additionally grant the first-user exception (see diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 0093b0e5..3bd8245a 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -79,6 +79,10 @@ type SectionData struct { Items []BookInfo `json:"items"` ViewAllURL string `json:"view_all_url"` 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 { @@ -284,10 +288,28 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { } } } 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(), database.GetCollectionItemsForDashboardParams{ CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, LibraryID: libUUID, + ViewerUserID: viewerID, + HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished}, Limit: pgtype.Int4{Int32: 10000, Valid: true}, }) 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{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), @@ -329,6 +385,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { "books": bookList, "book_count": len(bookList), "is_smart": isSmart, + "up_next": upNext, "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}) } + +// 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"}) +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6a6e578d..19f09f58 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -211,6 +211,8 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra Items: bookCards, ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID), Priority: ds.Priority, + IsPlaylist: ds.IsPlaylist, + Complete: ds.Complete, }) } diff --git a/internal/router/collections.go b/internal/router/collections.go index ab15a948..ec228df2 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -16,6 +16,7 @@ func registerCollectionsRoutes(cfg *Config) { collections.DELETE("/:id", cfg.CollectionHandler.DeleteCollection) collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections) collections.POST("/:id/books", cfg.CollectionHandler.AddBooks) + collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems) collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook) collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks) collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks) diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index fe239c3d..012c1421 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -785,3 +785,35 @@ func parseFloat(s string) (float64, error) { 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 +} diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index d3ae4f84..e5490efc 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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 { CollectionID uuid.UUID CollectionName string @@ -34,6 +62,13 @@ type DashboardSection struct { Title string Description 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( @@ -81,7 +116,29 @@ func (s *DashboardService) GetDashboardSections( 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 { + 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 } @@ -95,6 +152,7 @@ func (s *DashboardService) GetDashboardSections( Title: coll.Name, Description: coll.Description.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) { 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{ CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, LibraryID: libraryID, + ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true}, + HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) if err != nil { diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 53f0d69b..3ea540f2 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -23,6 +23,7 @@ import ( _ "image/png" "io" "io/fs" + "math" "net/url" "os" "os/exec" @@ -268,6 +269,136 @@ func (s *MediaScanner) GetAutoScanEnabled() bool { 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) { s.adminID = adminID } @@ -1139,6 +1270,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, } s.newItems++ + + // A2c §2.4: obvious playlist sidecar series become curated collections. + s.syncPlaylistCollectionsAfterCreate(ctx, createdItem.ID, metadata.PlaylistSeries) return true, nil } diff --git a/internal/services/media_scanner_audio_multifile.go b/internal/services/media_scanner_audio_multifile.go index 44aa9b17..3de9f067 100644 --- a/internal/services/media_scanner_audio_multifile.go +++ b/internal/services/media_scanner_audio_multifile.go @@ -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 { 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) s.newItems++ 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 { 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) s.newItems++ default: