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.
192 lines
7.5 KiB
Go
192 lines
7.5 KiB
Go
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")
|
|
}
|