feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7). Auto-playlist collections (§2.4): the scanner turns obviously-playlist sidecar series (explicit '(Reading Order)'/chronological/publication/ in-order/timeline markers only — precision over recall) into curated static collections: upsert by view_settings.imported_from_series then name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a stay unpositioned rather than guessing), membership add-if-missing, never removed or re-ordered by rescans, provenance in description + view_settings, show_on_dashboard true (dashboard-eligible by design), 'scanner.auto_playlist_collections' setting (default on, Settings Registry + schema seed). Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST then added_at ASC (was added_at DESC — walked playlists backwards); PUT /api/collections/:id/items/order applies full-order rewrites (owner-only, service-gated). hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard gains viewer_user_id + hide_finished (LEFT JOIN reading_progress, COALESCE percentage < 0.999) and viewer_percentage rides the row for card graying; GetCollectionUpNext serves the collection's reading-queue head with the family-lens visible-library array enforced. Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT — the show_on_dashboard gate (never writable for user rows since the column shipped) is gone from GetUserCollectionsForDashboard; hiding stays dashboard-prefs (hidden_collections) territory; hide_finished filtered-everything now emits a Complete section instead of silently vanishing; sections JSON carries is_playlist/complete. Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by title ASC — NULL/tied numbers previously returned heap (scan) order, which is what the user saw. Frontend custom-section-builder note: the builder has sent show_on_dashboard: true since its creation; the API never bound it. User-row visibility is now intentionally prefs-governed, so the field stays unbound by design (documented in code). Pins widened (docs/contract_pins.md): collection detail + up_next; dashboard sections + is_playlist/complete; new reorder endpoint recorded. Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar series split, collection auto-creation with position 7, rescan idempotency, owner-only reorder, finished-filter + completion marker + Up Next drain) plus the full affected integration sweep green.
This commit is contained in:
1 parent
7e4c97f639
commit
1dbb0ce406
15 files changed
+992
-18
No files matched your search
@@ -490,10 +490,12 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
||||
fx := createContractFixture(t, setup)
|
||||
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user