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)
// 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)
@@ -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")
}