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

+32
View File
@@ -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
}
+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 {
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 {
+134
View File
@@ -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
}
@@ -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: