Compare commits

..
Author SHA1 Message Date
Cursor AgentandQuick d05ae85077 fix(playback): restore SDR alternate fallback for burn-in subtitles
#617 renamed HDR/4K burn-in refusals to subtitle_conversion_unsupported
when the subtitle alone forced adaptation, but terminalAllowsAlternateFileV3
still only recognized the old HDR/4K reasons. Selecting a PGS track on an
HDR source therefore skipped the SDR alternate and returned
adaptation_unavailable.

Co-authored-by: Quick <Quick104@users.noreply.github.com>
2026-08-13 11:10:13 +00:00
126 changed files with 437 additions and 7257 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+1 -1
View File
@@ -730,7 +730,7 @@ func main() {
handler = srv.Handler()
} else {
srv := transcodenode.NewServer(watcher, tracker)
srv.SetInputPathAuthorizer(transcodenode.NewCatalogPathAuthorizer(scanner.NewFileRepository(pool)))
srv.SetInputPathAuthorizer(transcodenode.NewMediaRootAuthorizer(catalog.NewFolderRepository(pool)))
srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID))
// Read jellycompat reconstruction recipes central wrote at transcode
// start, so this node can rebuild a Jellyfin transcode after its own
-2
View File
@@ -25,8 +25,6 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientBuild: s.ClientBuild,
ClientChannel: s.ClientChannel,
ClientUserAgent: s.ClientUserAgent,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
@@ -39,22 +39,6 @@
"audio_underruns": {
"type": "integer",
"description": "Audio underrun count."
},
"session_id": {
"type": "string",
"description": "Playback session identifier for correlation with operational logs."
},
"play_method": {
"type": "string",
"description": "Selected playback delivery method such as direct, remux, or transcode."
},
"reason": {
"type": "string",
"description": "Playback route, interruption, or lifecycle reason."
},
"position_ms": {
"type": "integer",
"description": "Current playback position in milliseconds."
}
},
"focus": {
@@ -83,44 +67,12 @@
"duration_ms": {
"type": "integer",
"description": "Request duration in milliseconds."
},
"outcome": {
"type": "string",
"description": "Request outcome such as success, http_error, transport_error, or cancelled."
},
"error_code": {
"type": "string",
"description": "Stable error classification for a failed request."
},
"attempt": {
"type": "integer",
"description": "Attempt ordinal for a retried request."
}
},
"lifecycle": {
"state": {
"type": "string",
"description": "App lifecycle state."
},
"phase": {
"type": "string",
"description": "Startup or lifecycle phase identifier."
},
"duration_ms": {
"type": "integer",
"description": "Elapsed duration for the phase in milliseconds."
},
"outcome": {
"type": "string",
"description": "Result of the phase, such as success, failure, or skipped."
},
"reason": {
"type": "string",
"description": "Classification for the phase outcome."
},
"launch_type": {
"type": "string",
"description": "Launch classification, such as cold or warm."
}
},
"crash": {
@@ -10,7 +10,6 @@
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
@@ -7,7 +7,6 @@
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
@@ -580,14 +580,6 @@
"type": "string",
"maxLength": 64
},
"app_build": {
"type": "string",
"maxLength": 64
},
"app_channel": {
"type": "string",
"maxLength": 32
},
"device": {
"type": "object",
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
@@ -531,14 +531,6 @@
"type": "string",
"maxLength": 64
},
"app_build": {
"type": "string",
"maxLength": 64
},
"app_channel": {
"type": "string",
"maxLength": 32
},
"device": {
"type": "object",
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
-32
View File
@@ -48,38 +48,6 @@ the other. Send the exact lower-case family:
| macOS | `desktop` |
| Browser | `web` |
### App identity headers
Separately from the family, every first-party client should send its own app
identity on playback requests. These are server-wide contextual headers, not
settings-specific: the playback session stores them, the admin Activity page
renders them ("Silo Android TV 1.0.0 (build 5)"), and playback decision logs
carry them so a report can be tied to an exact build.
| Header | Clamp | Meaning |
| ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------- |
| `X-Silo-Client` | 128 | Product name, e.g. `Silo Android TV`, `Silo iOS`. |
| `X-Silo-Client-Version` | 64 | Marketing version, e.g. `1.0.0`. Sent verbatim and displayed verbatim — do not pre-shorten it. |
| `X-Silo-Client-Build` | 64 | Opaque per-platform build identifier (Android `versionCode`, Apple `CFBundleVersion`). Never parsed or compared. |
| `X-Silo-Client-Channel` | 32 | Opaque distribution channel: `release`, `beta`, `sideload`, `dev`. Stored verbatim; `release` is not displayed. |
Values are trimmed and truncated to the clamp above — never rejected, on either
route, because an identity label must not be able to fail a playback start. The
clamp counts characters, not bytes, matching `maxLength` in the v3 request
schemas, and is applied where the request is read rather than where the session
is created so the decision logs and `playback_route_events` observe it too.
Nothing is validated against an enum either, so a client may introduce a new
channel without a server change.
Protocol-v3 `POST /playback/start` accepts `client_playback_context.app_version`,
`.app_build`, and `.app_channel` as a body-level fallback for clients that cannot
set the headers on every request. The headers win field by field when both are
present, and the fallback applies **only to a client that sent `X-Silo-Client`**:
`client_playback_context` carries no app name, so nothing in the body can
identify a client that did not name itself — such a session is labeled from its
user agent, and its `app_version` is a free-form platform string rather than the
marketing version `client_version` promises.
## Remote scopes
Every stored value has exactly one identity. Context fields not named by the
-9
View File
@@ -278,15 +278,6 @@ func (s stubStore) GetSeriesPlaybackPreference(context.Context, string, string)
func (s stubStore) DeleteSeriesPlaybackPreference(context.Context, string, string) error {
panic("unused")
}
func (s stubStore) SetCollectionSortPreference(context.Context, userstore.CollectionSortPreference) error {
panic("unused")
}
func (s stubStore) GetCollectionSortPreference(context.Context, string, string, string) (*userstore.CollectionSortPreference, error) {
panic("unused")
}
func (s stubStore) ClearCollectionSortPreference(context.Context, string, string, string) error {
panic("unused")
}
func (s stubStore) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
panic("unused")
}
-16
View File
@@ -59,16 +59,6 @@ type catalogResponse struct {
Items []itemListResponse `json:"items"`
Snapshot string `json:"snapshot,omitempty"`
SearchDiagnostics *searchDiagnostics `json:"search_diagnostics,omitempty"`
// EffectiveSort reports the order a collection source actually resolved in
// once the viewer's saved override and the collection's configured default
// were applied. Omitted for non-collection sources and when the collection
// resolved in its own source order.
EffectiveSort *effectiveSortResponse `json:"effective_sort,omitempty"`
}
type effectiveSortResponse struct {
Field string `json:"field"`
Order string `json:"order"`
}
// searchDiagnostics is an additive, per-query observability object emitted on
@@ -254,11 +244,6 @@ func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *cat
}
}
var effectiveSort *effectiveSortResponse
if field := strings.TrimSpace(result.EffectiveSort.Field); field != "" {
effectiveSort = &effectiveSortResponse{Field: field, Order: result.EffectiveSort.Order}
}
writeJSON(w, http.StatusOK, catalogResponse{
Total: result.Total,
TotalExact: result.TotalExact && !groupedByWork,
@@ -266,7 +251,6 @@ func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *cat
Items: items,
Snapshot: snapshot,
SearchDiagnostics: diag,
EffectiveSort: effectiveSort,
})
}
@@ -1,195 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type collectionPreferenceLibraryReader interface {
GetByID(ctx context.Context, id string) (*models.LibraryCollection, error)
}
// collectionSortPreferenceRequest saves the sort a viewer chose while browsing a
// collection. An empty Field is meaningful — it pins the viewer to the
// collection's own source order even if its creator later configures a default.
// To go back to following the creator's default, DELETE the preference instead.
type collectionSortPreferenceRequest struct {
CollectionKind string `json:"collection_kind"`
CollectionID string `json:"collection_id"`
Field string `json:"field"`
Order string `json:"order"`
}
type collectionSortPreferenceResponse struct {
CollectionKind string `json:"collection_kind"`
CollectionID string `json:"collection_id"`
Field string `json:"field"`
Order string `json:"order"`
}
// NormalizeCollectionSortConfig validates the default sort a collection's
// creator configured and returns its canonical sort_config JSON. Shared by the
// personal and library collection handlers so both id spaces accept exactly the
// same vocabulary.
//
// allowPersonalized must be false for library collection defaults because
// those defaults are shared across profiles. Viewer overrides are validated
// separately and may use profile-scoped fields for either collection kind.
func NormalizeCollectionSortConfig(raw json.RawMessage, allowPersonalized bool) (string, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == jsonNullLiteral || trimmed == "{}" {
return "{}", nil
}
var cfg struct {
Field string `json:"field"`
Order string `json:"order"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", errInvalidSortConfig
}
if strings.TrimSpace(cfg.Field) == "" {
// sort_config predates default sorting and may contain independent
// collection modes such as {"mode":"manual_pins"}. Preserve those
// configurations byte-for-byte instead of erasing them merely because
// they do not contain a sort field.
return string(raw), nil
}
qs, ok := catalog.NormalizeCollectionSort(cfg.Field, cfg.Order, allowPersonalized)
if !ok {
return "", errInvalidSortConfig
}
return catalog.EncodeCollectionDefaultSort(qs, true)
}
var errInvalidSortConfig = &sortConfigError{}
type sortConfigError struct{}
func (*sortConfigError) Error() string {
return "sort_config must be {} or {\"field\":\"<supported sort>\",\"order\":\"asc|desc\"}"
}
// HandleSetCollectionSortPreference handles PUT /collections/sort-preference.
func (h *CollectionHandler) HandleSetCollectionSortPreference(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req collectionSortPreferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
kind, collectionID, ok := normalizeCollectionRef(req.CollectionKind, req.CollectionID)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "collection_kind must be 'library' or 'user' and collection_id is required")
return
}
field := strings.ToLower(strings.TrimSpace(req.Field))
order := ""
if field != "" {
qs, valid := catalog.NormalizeCollectionSort(field, req.Order, true)
if !valid {
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported sort field or order for this collection")
return
}
field, order = qs.Field, qs.Order
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if !h.canSaveCollectionSortPreference(r, store, kind, collectionID) {
// Use one not-found response for absent and inaccessible collections so
// the preference endpoint cannot be used to enumerate hidden IDs.
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
if err := store.SetCollectionSortPreference(r.Context(), userstore.CollectionSortPreference{
ProfileID: profileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: field,
SortOrder: order,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save sort preference")
return
}
writeJSON(w, http.StatusOK, collectionSortPreferenceResponse{
CollectionKind: kind,
CollectionID: collectionID,
Field: field,
Order: order,
})
}
func (h *CollectionHandler) canSaveCollectionSortPreference(
r *http.Request,
store userstore.UserStore,
kind, collectionID string,
) bool {
switch kind {
case userstore.CollectionKindLibrary:
if h.LibraryCollections == nil {
return false
}
collection, err := h.LibraryCollections.GetByID(r.Context(), collectionID)
return err == nil && catalog.CanAccessLibraryCollection(collection, requestAccessFilter(r))
case userstore.CollectionKindUser:
collection, err := store.GetCollection(r.Context(), collectionID)
return err == nil && catalog.ProfileCanAccessCollection(collection, apimw.GetProfileID(r.Context()))
default:
return false
}
}
// HandleClearCollectionSortPreference handles DELETE /collections/sort-preference,
// returning the collection to whatever default its creator configured.
func (h *CollectionHandler) HandleClearCollectionSortPreference(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
values := r.URL.Query()
kind, collectionID, ok := normalizeCollectionRef(values.Get("collection_kind"), values.Get("collection_id"))
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "collection_kind must be 'library' or 'user' and collection_id is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.ClearCollectionSortPreference(r.Context(), profileID, kind, collectionID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear sort preference")
return
}
w.WriteHeader(http.StatusNoContent)
}
func normalizeCollectionRef(rawKind, rawID string) (string, string, bool) {
kind := strings.ToLower(strings.TrimSpace(rawKind))
if kind != userstore.CollectionKindLibrary && kind != userstore.CollectionKindUser {
return "", "", false
}
collectionID := strings.TrimSpace(rawID)
if collectionID == "" {
return "", "", false
}
return kind, collectionID, true
}
@@ -1,302 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/userstore/pgstore"
)
type sortPrefLibraryReader struct {
collection *models.LibraryCollection
}
func (r sortPrefLibraryReader) GetByID(_ context.Context, id string) (*models.LibraryCollection, error) {
if r.collection == nil || r.collection.ID != id {
return nil, catalog.ErrLibraryCollectionNotFound
}
return r.collection, nil
}
// These exercise the sort-preference endpoints through real HTTP handlers and a
// real store, using middleware.SetClaims / SetProfileID to supply the auth
// context the middleware would normally attach. Set SILO_TEST_DATABASE_URL to a
// migrated database to run them.
func sortPrefHandlerPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(context.Background(),
`SELECT to_regclass('public.user_collection_sort_preferences')::text`).Scan(&tableName); err != nil {
t.Fatalf("check preference table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the collection sort preference migration")
}
return pool
}
func sortPrefFixtureUser(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var userID int
if err := pool.QueryRow(context.Background(), `
INSERT INTO users (username, email, password_hash, role)
VALUES ('sort-pref-handler-fixture', 'sort-pref-handler@invalid.test', '', 'user')
RETURNING id
`).Scan(&userID); err != nil {
t.Fatalf("seed fixture user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
return userID
}
// authed attaches the user + profile scope the middleware chain would normally
// have put on the request.
func authed(r *http.Request, userID int, profileID string) *http.Request {
ctx := middleware.SetClaims(r.Context(), &auth.Claims{UserID: userID, Role: "user"})
ctx = middleware.SetProfileID(ctx, profileID)
return r.WithContext(ctx)
}
func TestCollectionSortPreferenceEndpoints(t *testing.T) {
pool := sortPrefHandlerPool(t)
userID := sortPrefFixtureUser(t, pool)
const profileID = "profile-http"
const collectionID = "http-collection-1"
provider := pgstore.NewPostgresProvider(pool)
handler := NewCollectionHandler(provider)
handler.LibraryCollections = sortPrefLibraryReader{collection: &models.LibraryCollection{
ID: collectionID, Visibility: "visible", LibraryIDs: []int{7},
}}
store, err := provider.ForUser(context.Background(), userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: profileID, Name: "HTTP sort preference"}); err != nil {
t.Fatalf("seed profile: %v", err)
}
personalCollection, err := store.CreateCollection(context.Background(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: "HTTP personal sort preference",
CollectionType: "manual",
})
if err != nil {
t.Fatalf("seed personal collection: %v", err)
}
t.Cleanup(func() {
_ = store.DeleteProfile(context.Background(), profileID)
})
put := func(body string) *httptest.ResponseRecorder {
req := authed(httptest.NewRequest(http.MethodPut, "/collections/sort-preference", strings.NewReader(body)), userID, profileID)
rec := httptest.NewRecorder()
handler.HandleSetCollectionSortPreference(rec, req)
return rec
}
t.Run("saves a sort the viewer picked", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"title","order":"asc"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp collectionSortPreferenceResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Field != "title" || resp.Order != "asc" {
t.Fatalf("response = %+v", resp)
}
// Read it back through the store, not the response, so the assertion
// covers persistence rather than echo.
pref, err := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if err != nil || pref == nil {
t.Fatalf("stored preference = %v, err = %v", pref, err)
}
if pref.SortField != "title" || pref.SortOrder != "asc" {
t.Fatalf("stored preference = %+v", pref)
}
})
t.Run("fills in the field's default order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"year"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil || pref.SortOrder != "desc" {
t.Fatalf("stored preference = %+v, want year/desc", pref)
}
})
t.Run("an empty field pins to source order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":""}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil {
t.Fatal("pinning to source order removed the row; it must persist as an explicit choice")
}
if pref.SortField != "" {
t.Fatalf("stored field = %q, want empty", pref.SortField)
}
})
t.Run("accepts a personalized viewer sort on a library collection", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"progress"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil || pref.SortField != "progress" || pref.SortOrder != "desc" {
t.Fatalf("stored preference = %+v, want progress/desc", pref)
}
})
t.Run("rejects an invalid sort order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"title","order":"sideways"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("accepts a personalized sort on a personal collection", func(t *testing.T) {
rec := put(`{"collection_kind":"user","collection_id":"` + personalCollection.ID + `","field":"progress"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindUser, personalCollection.ID)
})
})
t.Run("rejects a nonexistent collection", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"missing","field":"title"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("rejects a library collection outside the viewer scope", func(t *testing.T) {
req := authed(httptest.NewRequest(http.MethodPut, "/collections/sort-preference",
strings.NewReader(`{"collection_kind":"library","collection_id":"`+collectionID+`","field":"title"}`)), userID, profileID)
req = req.WithContext(access.SetScope(req.Context(), access.Scope{AllowedLibraryIDs: []int{99}}))
rec := httptest.NewRecorder()
handler.HandleSetCollectionSortPreference(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("rejects an unknown collection kind", func(t *testing.T) {
rec := put(`{"collection_kind":"nonsense","collection_id":"x","field":"title"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
})
t.Run("clearing removes the override", func(t *testing.T) {
req := authed(httptest.NewRequest(http.MethodDelete,
"/collections/sort-preference?collection_kind=library&collection_id="+collectionID, nil), userID, profileID)
rec := httptest.NewRecorder()
handler.HandleClearCollectionSortPreference(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, err := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if err != nil {
t.Fatalf("reading cleared preference: %v", err)
}
if pref != nil {
t.Fatalf("preference still present after clear: %+v", pref)
}
})
}
// TestNormalizeCollectionSortConfigForCreators covers the validation the
// collection create/update handlers apply to a creator-configured default.
func TestNormalizeCollectionSortConfigForCreators(t *testing.T) {
cases := []struct {
name string
raw string
allowPersonalized bool
want string
wantErr bool
}{
{name: "absent", raw: "", want: "{}"},
{name: "explicit null", raw: `null`, want: "{}"},
{name: "legacy empty object", raw: `{}`, want: "{}"},
{name: "empty field remains source order", raw: `{"field":""}`, want: `{"field":""}`},
{name: "preserves manual pins mode", raw: `{"mode":"manual_pins"}`, want: `{"mode":"manual_pins"}`},
{name: "canonicalizes", raw: `{"field":" Title ","order":"ASC"}`, want: `{"field":"title","order":"asc"}`},
{name: "fills the default order", raw: `{"field":"year"}`, want: `{"field":"year","order":"desc"}`},
{name: "rejects unknown fields", raw: `{"field":"not_a_sort"}`, wantErr: true},
{name: "rejects invalid orders", raw: `{"field":"year","order":"sideways"}`, wantErr: true},
{name: "rejects personalized for library", raw: `{"field":"progress"}`, wantErr: true},
{
name: "allows personalized for personal",
raw: `{"field":"progress"}`,
allowPersonalized: true,
want: `{"field":"progress","order":"desc"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := NormalizeCollectionSortConfig(json.RawMessage(tc.raw), tc.allowPersonalized)
if tc.wantErr {
if err == nil {
t.Fatalf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
})
}
}
func TestNormalizeCollectionSortConfigPreservesManualPinsAcrossUpdates(t *testing.T) {
const manualPins = `{"mode":"manual_pins"}`
created, err := NormalizeCollectionSortConfig(json.RawMessage(manualPins), false)
if err != nil {
t.Fatalf("normalize on create: %v", err)
}
updated, err := NormalizeCollectionSortConfig(json.RawMessage(created), false)
if err != nil {
t.Fatalf("normalize on update: %v", err)
}
if updated != manualPins {
t.Fatalf("manual pins config changed across create/read/update: got %s, want %s", updated, manualPins)
}
}
+9 -24
View File
@@ -22,12 +22,11 @@ import (
// CollectionHandler handles personal collection CRUD endpoints.
type CollectionHandler struct {
storeProvider userstore.UserStoreProvider
LibraryCollections collectionPreferenceLibraryReader
Executor *catalog.QueryExecutor
S3GP *s3client.Client
HTTPClient *http.Client
PresignTTL time.Duration
storeProvider userstore.UserStoreProvider
Executor *catalog.QueryExecutor
S3GP *s3client.Client
HTTPClient *http.Client
PresignTTL time.Duration
}
// NewCollectionHandler creates a new CollectionHandler.
@@ -107,11 +106,8 @@ type collectionCapabilitiesResponse struct {
// DisplayFilterFields are the catalog query fields a personal-collection
// display filter may use. Clients build a display_query_definition fragment
// from these rather than a bespoke enum.
DisplayFilterFields []string `json:"display_filter_fields"`
DisplayFilterPresets collectionDisplayFilterPresets `json:"display_filter_presets"`
CollectionDefaultSort bool `json:"collection_default_sort"`
CollectionSortPreferences bool `json:"collection_sort_preferences"`
EffectiveCollectionSort bool `json:"effective_collection_sort"`
DisplayFilterFields []string `json:"display_filter_fields"`
DisplayFilterPresets collectionDisplayFilterPresets `json:"display_filter_presets"`
}
type collectionDisplayFilterPresets struct {
@@ -237,9 +233,6 @@ func (h *CollectionHandler) HandleCapabilities(w http.ResponseWriter, r *http.Re
Watched: []string{"all", "watched", "unwatched"},
Media: []string{"all", "movie", "series"},
},
CollectionDefaultSort: true,
CollectionSortPreferences: true,
EffectiveCollectionSort: true,
})
}
@@ -281,11 +274,7 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt
}
}
queryDefinition := string(queryDefinitionJSON)
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sortConfig := string(defaultJSON(req.SortConfig))
displayQueryDefinition, err := catalog.NormalizeDisplayQueryFragment(req.DisplayQueryDefinition)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
@@ -369,11 +358,7 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt
input.QueryDefinition = &value
}
if len(req.SortConfig) > 0 {
value, err := NormalizeCollectionSortConfig(req.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
value := string(req.SortConfig)
input.SortConfig = &value
}
input.IsShared = req.IsShared
@@ -1,25 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCollectionCapabilitiesAdvertiseSortSupport(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/collections/capabilities", nil)
NewCollectionHandler(nil).HandleCapabilities(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got collectionCapabilitiesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if !got.CollectionDefaultSort || !got.CollectionSortPreferences || !got.EffectiveCollectionSort {
t.Fatalf("sort capabilities not fully advertised: %+v", got)
}
}
+12 -29
View File
@@ -2264,42 +2264,17 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
}}, nil
}
// episodeTargets resolves the watched leaf targets for a season or series.
// Durations come from one batched file lookup rather than a query per episode:
// marking a long series used to issue one GetByEpisodeID per episode before it
// wrote anything.
func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget {
episodeIDs := make([]string, 0, len(episodes))
for _, episode := range episodes {
episodeIDs = append(episodeIDs, episode.ContentID)
}
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
targets := make([]watchedLeafTarget, 0, len(episodes))
for _, episode := range episodes {
targets = append(targets, watchedLeafTarget{
ContentID: episode.ContentID,
DurationSeconds: mediaFileDurationSeconds(filesByEpisode[episode.ContentID], episode.Runtime),
DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime),
})
}
return targets
}
// mediaFileDurationSeconds picks a playable duration from an item's files,
// falling back to the catalog runtime. Mirrors contentDurationSeconds's
// preference order so the batched and single-item paths agree.
func mediaFileDurationSeconds(files []*models.MediaFile, fallbackRuntimeMinutes int) float64 {
for _, file := range files {
if file != nil && file.Duration > 0 {
return float64(file.Duration)
}
}
if fallbackRuntimeMinutes > 0 {
return float64(fallbackRuntimeMinutes * 60)
}
return 0
}
func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 {
if h.fileRepo == nil {
if fallbackRuntimeMinutes > 0 {
@@ -2319,10 +2294,18 @@ func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, ep
case contentID != "":
files, err = h.fileRepo.GetByContentID(ctx, contentID)
}
if err != nil {
files = nil
if err == nil {
for _, file := range files {
if file != nil && file.Duration > 0 {
return float64(file.Duration)
}
}
}
return mediaFileDurationSeconds(files, fallbackRuntimeMinutes)
if fallbackRuntimeMinutes > 0 {
return float64(fallbackRuntimeMinutes * 60)
}
return 0
}
// isNotFound checks if an error is a "not found" sentinel.
+60 -106
View File
@@ -33,28 +33,26 @@ import (
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type LibraryCollectionHandler struct {
repo *catalog.LibraryCollectionRepository
service *catalog.LibraryCollectionService
itemRepo *catalog.ItemRepository
Executor *catalog.QueryExecutor
detailSvc *catalog.DetailService
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
FolderRepo *catalog.FolderRepository
TemplateRegistry *templates.Registry
SmartCountRefresher *catalog.SmartCountRefresher
JobRepo *adminjob.Repository
EventsHub *evt.Hub
SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner
repo *catalog.LibraryCollectionRepository
service *catalog.LibraryCollectionService
itemRepo *catalog.ItemRepository
Executor *catalog.QueryExecutor
detailSvc *catalog.DetailService
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
FolderRepo *catalog.FolderRepository
TemplateRegistry *templates.Registry
SmartCountRefresher *catalog.SmartCountRefresher
JobRepo *adminjob.Repository
EventsHub *evt.Hub
}
var errLibraryCollectionInUse = errors.New("collection is used by one or more sections")
@@ -379,22 +377,21 @@ type updateLibraryCollectionRequest struct {
}
type importMDBListRequest struct {
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
// PosterFromTemplate marks the poster as supplied by a template bundle so
// future template applies can refresh it. Set programmatically by
// createCollectionFromTemplate; not user-settable via JSON.
@@ -402,25 +399,24 @@ type importMDBListRequest struct {
}
type importTMDBRequest struct {
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
TimeWindow string `json:"time_window"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
PosterFromTemplate bool `json:"-"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
TimeWindow string `json:"time_window"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
PosterFromTemplate bool `json:"-"`
}
// importTMDBFranchiseRequest is the request body for creating a collection
@@ -491,14 +487,13 @@ type importTMDBDiscoverSpecBody struct {
}
type importTraktRequest struct {
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
ProfileID string `json:"profile_id,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
ProfileID string `json:"profile_id,omitempty"`
// ListURL selects a user-authored Trakt list
// (https://trakt.tv/users/{user}/lists/{slug}) instead of a preset.
ListURL string `json:"list_url,omitempty"`
@@ -1142,14 +1137,6 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr
}
}
// Library collections resolve with user scope stripped, so per-profile sort
// fields are rejected here rather than failing at browse time.
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
var syncSchedule *string
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
if err := catalog.ParseCronExpression(s); err != nil {
@@ -1179,7 +1166,7 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: json.RawMessage(sortConfig),
SortConfig: defaultJSON(req.SortConfig),
SourceConfig: defaultCollectionSourceConfig(req.SourceConfig),
ManagementMode: managementMode,
ManagementSource: managementSource,
@@ -1238,16 +1225,6 @@ func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWr
}
}
sortConfig := req.SortConfig
if len(req.SortConfig) > 0 {
normalized, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sortConfig = json.RawMessage(normalized)
}
// Validate sync_schedule if provided.
if req.SyncSchedule != nil && *req.SyncSchedule != "" {
if err := catalog.ParseCronExpression(*req.SyncSchedule); err != nil {
@@ -1280,7 +1257,7 @@ func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWr
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: sortConfig,
SortConfig: req.SortConfig,
SourceConfig: req.SourceConfig,
ManagementMode: normalizeOptionalCollectionManagementMode(req.ManagementMode),
ManagementSource: req.ManagementSource,
@@ -1454,13 +1431,7 @@ func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, c
}
}
if err := h.repo.Delete(ctx, collectionID); err != nil {
return err
}
if h.SortPreferenceCleaner != nil {
h.SortPreferenceCleaner.DeleteForCollection(ctx, userstore.CollectionKindLibrary, collectionID)
}
return nil
return h.repo.Delete(ctx, collectionID)
}
type adminReorderCollectionsRequest struct {
@@ -2665,10 +2636,6 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
if err != nil {
return nil, requestValidationError{err: err}
}
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
return nil, requestValidationError{err: err}
}
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -2676,7 +2643,6 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
Title: req.Title,
Description: req.Description,
CollectionType: "mdblist",
SortConfig: json.RawMessage(sortConfig),
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
@@ -2730,10 +2696,6 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
if err != nil {
return nil, requestValidationError{err: err}
}
tmdbSortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
return nil, requestValidationError{err: err}
}
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -2741,7 +2703,6 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
Title: req.Title,
Description: req.Description,
CollectionType: "tmdb",
SortConfig: json.RawMessage(tmdbSortConfig),
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
@@ -3087,12 +3048,6 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
return
}
traktSortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -3100,7 +3055,6 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
Title: req.Title,
Description: req.Description,
CollectionType: "trakt",
SortConfig: json.RawMessage(traktSortConfig),
Visibility: "visible",
Featured: req.Featured,
PosterURL: req.PosterURL,
+10 -29
View File
@@ -43,18 +43,12 @@ import (
type SessionManagerInterface interface {
StartSession(userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
StartSessionWithFiles(userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
// StartSessionWithFilesContext is required rather than probed for at run
// time: the context is how the reporting client's identity reaches the new
// session, so an implementation without it would start sessions that
// silently carry no client name, version, build or channel.
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
UpdateProgress(sessionID string, position float64, isPaused bool) error
UpdateAudioTrack(sessionID string, audioTrackIndex int, method playback.PlayMethod) error
UpdateStreamState(sessionID string, state playback.SessionStreamState) error
TouchActivity(sessionID string) error
BeginTransport(sessionID string) error
EndTransport(sessionID string) error
SetRemoteTransport(sessionID string, remote bool) error
SetEffectiveMediaFileID(sessionID string, fileID int) error
SetTranscodeNodeURL(sessionID, url string) error
SetTranscodeRoute(sessionID string, route playback.TranscodeRoute) error
@@ -68,6 +62,10 @@ type SessionManagerInterface interface {
GetSession(sessionID string) (*playback.Session, error)
}
type sessionStarterWithFilesContext interface {
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
}
type transcodePermissionChecker interface {
CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error
}
@@ -398,20 +396,12 @@ const streamTokenParam = "st"
// reconstruction recipe. Returns "" when no signing secret is configured
// (reconstruct effectively disabled, e.g. in tests).
func (h *PlaybackHandler) signSessionToken(card playback.RecipeCard) string {
return h.signStreamClaims(card.ToClaims())
}
// signStreamClaims mints a stream token from claims that are already assembled.
// Callers serving a session from another node use it to add the claims a
// RecipeCard does not model (the file's Dolby Vision profile, audio-only flag),
// which a remote executor cannot look up for itself.
func (h *PlaybackHandler) signStreamClaims(claims streamtoken.Claims) string {
if h.JWTSecret == "" {
return ""
}
token, err := streamtoken.Sign(claims, h.JWTSecret, playback.MaxTokenTTL)
token, err := streamtoken.Sign(card.ToClaims(), h.JWTSecret, playback.MaxTokenTTL)
if err != nil {
slog.Warn("sign stream token failed", "error", err, "session", claims.SessionID, "playback_session_id", claims.SessionID)
slog.Warn("sign stream token failed", "error", err, "session", card.SessionID, "playback_session_id", card.SessionID)
return ""
}
return token
@@ -953,9 +943,7 @@ func (h *PlaybackHandler) handleExpiredSession(session *playback.Session) {
}
sessionCopy := *session
go func() {
slog.Info("expired inactive playback session", append([]any{
"session", sessionCopy.ID, "playback_session_id", sessionCopy.ID,
}, sessionCopy.ClientInfo().LogAttrs()...)...)
slog.Info("expired inactive playback session", "session", sessionCopy.ID, "playback_session_id", sessionCopy.ID)
// Expiry is a liveness reap, not a user stop — keep the recipe card so a
// resume reconstructs under the same id (the card's own TTL reaps it if
// the session is truly abandoned).
@@ -1079,18 +1067,11 @@ func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo {
if r == nil {
return playback.ClientInfo{}
}
// Clamped here, at the boundary, rather than only where the session stamps
// them: the decision logs and playback_route_events are written from this
// value directly, so a client sending a header-sized build would otherwise
// reach both despite the published bound. Values stay opaque — trimmed and
// length-clamped, never parsed or validated against an enum.
return playback.ClientInfo{
Name: r.Header.Get("X-Silo-Client"),
Version: r.Header.Get("X-Silo-Client-Version"),
Build: r.Header.Get("X-Silo-Client-Build"),
Channel: r.Header.Get("X-Silo-Client-Channel"),
Name: strings.TrimSpace(r.Header.Get("X-Silo-Client")),
Version: strings.TrimSpace(r.Header.Get("X-Silo-Client-Version")),
UserAgent: r.UserAgent(),
}.Normalized()
}
}
// HandleUpdateProgress handles POST /playback/{session_id}/progress.
+2 -58
View File
@@ -48,10 +48,7 @@ type playbackSessionRow struct {
ClientIP string `json:"client_ip,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
ClientBuild string `json:"client_build,omitempty"`
ClientChannel string `json:"client_channel,omitempty"`
ClientLabel string `json:"client_label,omitempty"`
ClientLabelFull string `json:"client_label_full,omitempty"`
ClientUserAgent string `json:"client_user_agent,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TranscodeAudio bool `json:"transcode_audio"`
@@ -92,11 +89,6 @@ type playbackSessionsCapabilitiesResponse struct {
EffectivePlayMethodValues []string `json:"effective_play_method_values"`
// IsJellyfinClient reports that rows carry is_jellyfin_client.
IsJellyfinClient bool `json:"is_jellyfin_client"`
// ClientBuild reports that rows carry client_build (and the exact-version
// client_label_full derived from it).
ClientBuild bool `json:"client_build"`
// ClientChannel reports that rows carry client_channel.
ClientChannel bool `json:"client_channel"`
}
// HandleGetSessionsCapabilities exposes additive feature support for the live
@@ -106,8 +98,6 @@ func (h *AdminHandler) HandleGetSessionsCapabilities(w http.ResponseWriter, _ *h
EffectivePlayMethod: true,
EffectivePlayMethodValues: []string{"direct", "remux", "transcode", "audio"},
IsJellyfinClient: true,
ClientBuild: true,
ClientChannel: true,
})
}
@@ -192,8 +182,6 @@ func (l *PlaybackSessionsLoader) Load(
COALESCE(HOST(s.client_ip), ''),
COALESCE(s.client_name, ''),
COALESCE(s.client_version, ''),
COALESCE(s.client_build, ''),
COALESCE(s.client_channel, ''),
COALESCE(s.client_user_agent, ''),
COALESCE(s.audio_track_index, 0),
COALESCE(s.transcode_audio, FALSE),
@@ -251,7 +239,6 @@ func (l *PlaybackSessionsLoader) Load(
&posterPath,
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.ClientName, &s.ClientVersion,
&s.ClientBuild, &s.ClientChannel,
&s.ClientUserAgent, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
@@ -266,13 +253,6 @@ func (l *PlaybackSessionsLoader) Load(
s.SourceBitrateKbps = sourceBitrateKbps
s.SourceAudioChannels = sourceAudioChannels
s.ClientLabel = playbackClientDisplayName(s.ClientName, s.ClientVersion, s.ClientUserAgent)
// Only worth the bytes when it says more than the compact label — which
// it does not for any client without a build or a non-release channel,
// i.e. most of a 200-row page. Clients read client_label when
// client_label_full is absent, so omitting the duplicate costs nothing.
if full := playbackClientFullDisplayName(s.ClientName, s.ClientVersion, s.ClientBuild, s.ClientChannel, s.ClientUserAgent); full != s.ClientLabel {
s.ClientLabelFull = full
}
enrichPlaybackSessionRow(&s, audioTracksJSON)
sessions = append(sessions, s)
}
@@ -467,47 +447,11 @@ func firstNonEmptyValue(values ...string) string {
return ""
}
// playbackClientFullDisplayName renders the client's exact identity — version,
// opaque build, and non-default channel — for surfaces that can afford the
// width (expanded session details, tooltips). The name-and-version half is
// whatever the compact label resolved, so a client identified only by its user
// agent still gets its build named: a client that bothered to report a build
// has earned having it displayed, whether or not it also named itself.
func playbackClientFullDisplayName(name, version, build, channel, userAgent string) string {
label := playbackClientDisplayName(name, version, userAgent)
if label == "" {
return ""
}
build = strings.TrimSpace(build)
channel = strings.TrimSpace(channel)
// "release" is the assumed channel, so naming it adds width without adding
// information; every other channel is worth calling out.
if strings.EqualFold(channel, "release") {
channel = ""
}
var qualifiers []string
if build != "" {
qualifiers = append(qualifiers, "build "+build)
}
if channel != "" {
qualifiers = append(qualifiers, channel)
}
if len(qualifiers) > 0 {
label += " (" + strings.Join(qualifiers, ", ") + ")"
}
return label
}
// playbackClientDisplayName renders the compact label the session lists show.
// A client that reports its own name keeps its version verbatim — truncating
// "1.0.0" to "1" hid the only field that identifies the running build. Labels
// derived from a user agent stay shortened, where a full browser version
// ("120.0.6099.109") is noise.
func playbackClientDisplayName(name, version, userAgent string) string {
name = strings.TrimSpace(name)
version = shortPlaybackClientVersion(version)
if name != "" {
if version = strings.TrimSpace(version); version != "" {
if version != "" {
return name + " " + version
}
return name
+2 -274
View File
@@ -4,12 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
@@ -70,8 +65,8 @@ func TestSessionsCapabilitiesAdvertisesActivityFields(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient || !resp.ClientBuild || !resp.ClientChannel {
t.Fatalf("capabilities must advertise every additive field: %+v", resp)
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient {
t.Fatalf("capabilities must advertise both fields: %+v", resp)
}
want := []string{"direct", "remux", "transcode", "audio"}
if len(resp.EffectivePlayMethodValues) != len(want) {
@@ -218,273 +213,6 @@ func TestPlaybackClientDisplayNameAndroidDevices(t *testing.T) {
}
}
func TestPlaybackClientDisplayNameKeepsNamedClientVersionExact(t *testing.T) {
cases := []struct {
name string
clientName string
clientVersion string
want string
}{
{name: "patch version survives", clientName: "Silo Android TV", clientVersion: "1.0.0", want: "Silo Android TV 1.0.0"},
{name: "prerelease suffix survives", clientName: "Silo iOS", clientVersion: "2.1.0-rc.3", want: "Silo iOS 2.1.0-rc.3"},
{name: "no version", clientName: "Silo Android TV", clientVersion: "", want: "Silo Android TV"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientDisplayName(tc.clientName, tc.clientVersion, "")
if got != tc.want {
t.Fatalf("playbackClientDisplayName(%q, %q, \"\") = %q, want %q", tc.clientName, tc.clientVersion, got, tc.want)
}
})
}
}
func TestPlaybackClientFullDisplayName(t *testing.T) {
cases := []struct {
name string
client string
version string
build string
channel string
userAgent string
want string
}{
{
name: "version build and channel",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "dev",
want: "Silo Android TV 1.0.0 (build 5, dev)",
},
{
name: "release channel omitted",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "release",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "release channel omitted case insensitively",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "Release",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "empty channel omitted",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "channel without build",
client: "Silo Android TV",
version: "1.0.0",
channel: "dev",
want: "Silo Android TV 1.0.0 (dev)",
},
{
name: "build and channel empty",
client: "Silo Android TV",
version: "1.0.0",
want: "Silo Android TV 1.0.0",
},
{
name: "version empty",
client: "Silo Android TV",
want: "Silo Android TV",
},
{
name: "opaque build is not parsed",
client: "Silo tvOS",
version: "1.0.0",
build: "2026.08.13-abcdef",
want: "Silo tvOS 1.0.0 (build 2026.08.13-abcdef)",
},
{
// A client that reports a build but not a name is still named by its
// build: the user-agent label supplies the product half, and dropping
// the qualifiers would hide the exact field this surface exists for.
name: "no client name keeps build and channel on the user agent label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
build: "5",
channel: "dev",
want: "Chrome 120 (build 5, dev)",
},
{
name: "no client name and no qualifiers keeps the plain user agent label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
want: "Chrome 120",
},
{
name: "no client name and no user agent",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientFullDisplayName(tc.client, tc.version, tc.build, tc.channel, tc.userAgent)
if got != tc.want {
t.Fatalf("playbackClientFullDisplayName() = %q, want %q", got, tc.want)
}
})
}
}
func TestPlaybackClientInfoForStartV3(t *testing.T) {
cases := []struct {
name string
headers map[string]string
context playback.ClientPlaybackContextV3
want playback.ClientInfo
}{
{
name: "headers win over the body",
headers: map[string]string{"X-Silo-Client": "Silo iOS", "X-Silo-Client-Version": "2.1.0", "X-Silo-Client-Build": "9", "X-Silo-Client-Channel": "beta"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "1", AppChannel: "release"},
want: playback.ClientInfo{Name: "Silo iOS", Version: "2.1.0", Build: "9", Channel: "beta"},
},
{
// The fallback is per field, not per struct: a client may set the
// name and version headers on every request and still report the
// build it only knows at start time in the body.
name: "body fills only the fields the headers omit",
headers: map[string]string{"X-Silo-Client": "Silo tvOS", "X-Silo-Client-Version": "2.1.0"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "77", AppChannel: "sideload"},
want: playback.ClientInfo{Name: "Silo tvOS", Version: "2.1.0", Build: "77", Channel: "sideload"},
},
{
name: "body supplies everything but the name",
headers: map[string]string{"X-Silo-Client": "Silo Android TV"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "5", AppChannel: "dev"},
want: playback.ClientInfo{Name: "Silo Android TV", Version: "1.0.0", Build: "5", Channel: "dev"},
},
{
// The regression this guards: the web player reports the literal
// "web" as its app_version and sends no X-Silo-Client. Taking the
// body anyway would stamp "web" onto client_version — the one field
// the contract promises is a marketing version — for every browser
// session, and onto every route event and decision log with it.
name: "nameless client keeps the body out of client_version",
context: playback.ClientPlaybackContextV3{AppVersion: "web"},
want: playback.ClientInfo{},
},
{
name: "nameless client takes no build or channel either",
context: playback.ClientPlaybackContextV3{AppVersion: "web", AppBuild: "5", AppChannel: "dev"},
want: playback.ClientInfo{},
},
{
name: "whitespace-only header falls through to the body",
headers: map[string]string{"X-Silo-Client": "Silo iOS", "X-Silo-Client-Build": " "},
context: playback.ClientPlaybackContextV3{AppBuild: " 42 "},
want: playback.ClientInfo{Name: "Silo iOS", Build: "42"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
for name, value := range tc.headers {
req.Header.Set(name, value)
}
got := playbackClientInfoForStartV3(req, tc.context)
got.UserAgent = ""
if got != tc.want {
t.Fatalf("playbackClientInfoForStartV3() = %+v, want %+v", got, tc.want)
}
})
}
}
// TestPlaybackClientInfoFromRequestClampsHeaders pins the bound at the request
// boundary rather than at session creation. The resolved identity is written
// straight to the plan-decision log and to playback_route_events, so a client
// sending a header-sized build would reach both if the clamp only happened
// where the session stamps its fields.
func TestPlaybackClientInfoFromRequestClampsHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
req.Header.Set("X-Silo-Client", " "+strings.Repeat("N", 200)+" ")
req.Header.Set("X-Silo-Client-Version", strings.Repeat("v", 100))
req.Header.Set("X-Silo-Client-Build", strings.Repeat("b", 100))
req.Header.Set("X-Silo-Client-Channel", strings.Repeat("c", 100))
got := playbackClientInfoFromRequest(req)
for _, tc := range []struct {
field string
value string
want int
}{
{"Name", got.Name, 128},
{"Version", got.Version, 64},
{"Build", got.Build, 64},
{"Channel", got.Channel, 32},
} {
if len(tc.value) != tc.want {
t.Errorf("%s length = %d, want %d", tc.field, len(tc.value), tc.want)
}
}
}
// TestNormalizeClientMetadataCountsRunes guards the two ways a clamp can go
// wrong on a client-supplied header: the published bound is JSON Schema
// maxLength, which counts characters, and a byte-wise cut through a multi-byte
// rune yields invalid UTF-8 that Postgres refuses — failing the whole per-node
// session-sync transaction, not just the offending row.
func TestNormalizeClientMetadataCountsRunes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
// 40 three-byte runes: within the 64-character channel bound as the schema
// counts it, well past it as bytes.
req.Header.Set("X-Silo-Client-Channel", strings.Repeat("δ", 40))
got := playbackClientInfoFromRequest(req)
if runes := utf8.RuneCountInString(got.Channel); runes != 32 {
t.Errorf("Channel runes = %d, want the 32-character bound", runes)
}
if !utf8.ValidString(got.Channel) {
t.Errorf("Channel = %q is not valid UTF-8; a text column would reject it", got.Channel)
}
}
// TestPlaybackClientInfoStripsControlCharacters guards the one malformed value
// that reaches a text column looking perfectly well-formed. A JSON NUL escape in a
// v3 start body decodes to a real NUL, which is valid UTF-8 — so the UTF-8
// repair above leaves it and TrimSpace does not consider it whitespace — but
// Postgres refuses NUL in text. The per-node session upserts share one
// transaction, so one such start would stop every live session on that node
// from reconciling. Headers cannot carry it (net/http rejects bytes below
// 0x20), which is exactly why the body path needs its own guard.
func TestPlaybackClientInfoStripsControlCharacters(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
req.Header.Set("X-Silo-Client", "Silo Android TV")
got := playbackClientInfoForStartV3(req, playback.ClientPlaybackContextV3{
AppVersion: "1.0\x00.0",
AppBuild: "5\x00",
AppChannel: "de\nv",
})
if want := "1.0.0"; got.Version != want {
t.Errorf("Version = %q, want %q", got.Version, want)
}
if want := "5"; got.Build != want {
t.Errorf("Build = %q, want %q", got.Build, want)
}
if want := "dev"; got.Channel != want {
t.Errorf("Channel = %q, want %q", got.Channel, want)
}
for _, field := range []string{got.Name, got.Version, got.Build, got.Channel} {
if strings.ContainsFunc(field, unicode.IsControl) {
t.Errorf("%q still carries a control character; a text column would reject it", field)
}
}
}
func TestEnrichPlaybackSessionRowUsesCompatOrigin(t *testing.T) {
row := playbackSessionRow{
ClientName: "Unrecognized Client",
-6
View File
@@ -167,10 +167,6 @@ func (failingSessionManager) StartSessionWithFiles(int, string, int, int, playba
return nil, errors.New("boom")
}
func (failingSessionManager) StartSessionWithFilesContext(context.Context, int, string, int, int, playback.PlayMethod, bool) (*playback.Session, error) {
return nil, errors.New("boom")
}
func (failingSessionManager) UpdateProgress(string, float64, bool) error { return nil }
func (failingSessionManager) UpdateAudioTrack(string, int, playback.PlayMethod) error { return nil }
@@ -185,8 +181,6 @@ func (failingSessionManager) BeginTransport(string) error { return nil }
func (failingSessionManager) EndTransport(string) error { return nil }
func (failingSessionManager) SetRemoteTransport(string, bool) error { return nil }
func (failingSessionManager) SetEffectiveMediaFileID(string, int) error { return nil }
func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return nil }
+29 -277
View File
@@ -198,12 +198,6 @@ type transcodeNodeEnumeratorV3 interface {
TranscodeNodeURLs() []string
}
// proxyNodeEnumeratorV3 is the proxy-pool counterpart, letting identity
// planning narrow selection to proxies that can execute the plan's recipe.
type proxyNodeEnumeratorV3 interface {
ProxyNodeURLs() []string
}
// hlsPlanningRegistryV3 returns the registry HLS deliveries plan against: the
// local probe plus every pooled transcode node's advertised transformations.
// Only availability of locally-defined specs widens (name and recipe version
@@ -457,25 +451,20 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
}
}
h.clarifyOriginalQuality4KTerminalV3(r.Context(), result.Terminal, requestedFile, !shouldTryAlternateFileV3(req.QualityPreference))
// The exact app identity is logged with every decision so a route or
// terminal reported against one build is attributable without asking the
// user which version they are running.
clientInfo := playbackClientInfoForStartV3(r, req.ClientPlaybackContext)
if result.Terminal != nil {
slog.InfoContext(r.Context(), "playback plan decided", append([]any{
logComponentKey, "playback",
slog.InfoContext(r.Context(), "playback plan decided", "component", "playback",
"outcome", "terminal",
"reason", result.Terminal.Reason,
"file_id", effectiveFile.ID,
"quality_preference", req.QualityPreference,
}, clientInfo.LogAttrs()...)...)
)
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
}
if response.Terminal != nil {
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, Event: playback.RouteEventTerminalV3, FallbackReason: response.Terminal.Reason, OutputContextID: req.ClientPlaybackContext.Output.OutputContextID}, UserID: userID, ProfileID: profileID, ClientName: clientInfo.Name, ClientVersion: clientInfo.Version, ClientBuild: clientInfo.Build, ClientChannel: clientInfo.Channel, ClientModel: req.ClientPlaybackContext.Device.Model})
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, Event: playback.RouteEventTerminalV3, FallbackReason: response.Terminal.Reason, OutputContextID: req.ClientPlaybackContext.Output.OutputContextID}, UserID: userID, ProfileID: profileID, ClientName: playbackClientInfoFromRequest(r).Name, ClientVersion: playbackClientInfoFromRequest(r).Version, ClientModel: req.ClientPlaybackContext.Device.Model})
}
writeJSON(w, http.StatusCreated, response)
return
@@ -483,8 +472,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
// One line per plan decision so route selection is reconstructible from
// server logs alone (finding a mis-planned route previously required
// correlating client logcat, ffmpeg commands, and session rows).
slog.InfoContext(r.Context(), "playback plan decided", append([]any{
logComponentKey, "playback",
slog.InfoContext(r.Context(), "playback plan decided", "component", "playback",
"outcome", "plan",
"decision_reason", result.Plan.DecisionReason,
"delivery", result.Plan.Delivery,
@@ -497,9 +485,9 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
"target_bitrate_kbps", result.TargetBitrateKbps,
"quality_preference", req.QualityPreference,
"bandwidth_estimate_kbps", intOrZeroHandlerV3(req.BandwidthEstimateKbps),
}, clientInfo.LogAttrs()...)...)
)
result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, warnings...)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigests, requestedFile, effectiveFile, audioIndex, result, clientInfo)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigests, requestedFile, effectiveFile, audioIndex, result)
if statusErr != nil {
if statusErr.reason == "playback_attempt_reused" {
writeError(w, http.StatusConflict, "playback_attempt_reused", statusErr.message)
@@ -524,70 +512,6 @@ type playbackStartRequestDigestsV3 struct {
legacy string
}
// playbackClientInfoForStartV3 resolves the client's app identity for a v3
// start request. The X-Silo-Client-* headers win because they are present on
// every request; the start body's client_playback_context is the fallback for
// clients that report their app identity only there. All values stay opaque —
// they are trimmed and length-clamped when the session stamps them.
//
// The fallback applies only to a client that named itself. client_playback_context
// carries no app name, so nothing in the body can identify a nameless client
// anyway — it is labeled from its user agent, and its app_version is a
// free-form platform string rather than the marketing version client_version
// promises. The web player, for one, reports the literal "web" there; taking it
// unconditionally would write "web" into the one field that is contractually
// semver, on every browser session.
func playbackClientInfoForStartV3(r *http.Request, clientContext playback.ClientPlaybackContextV3) playback.ClientInfo {
info := playbackClientInfoFromRequest(r)
if info.Name == "" {
return info
}
if info.Version == "" {
info.Version = strings.TrimSpace(clientContext.AppVersion)
}
if info.Build == "" {
info.Build = strings.TrimSpace(clientContext.AppBuild)
}
if info.Channel == "" {
info.Channel = strings.TrimSpace(clientContext.AppChannel)
}
// The header half is already normalized; body-sourced values have to be
// clamped too before they reach the decision log and the route event.
return info.Normalized()
}
// playbackClientInfoWithSessionFallbackV3 completes a header-derived identity
// from the session the event belongs to. Route events posted out of band carry
// no client_playback_context, so without this a client that reports its build
// only in the start body would attribute its plan_selected event to a build and
// every later event of the same attempt to none.
func (h *PlaybackHandler) playbackClientInfoWithSessionFallbackV3(sessionID string, info playback.ClientInfo) playback.ClientInfo {
if sessionID == "" || h.sessionMgr == nil {
return info
}
if info.Name != "" && info.Version != "" && info.Build != "" && info.Channel != "" {
return info
}
session, err := h.sessionMgr.GetSession(sessionID)
if err != nil || session == nil {
return info
}
stamped := session.ClientInfo()
if info.Name == "" {
info.Name = stamped.Name
}
if info.Version == "" {
info.Version = stamped.Version
}
if info.Build == "" {
info.Build = stamped.Build
}
if info.Channel == "" {
info.Channel = stamped.Channel
}
return info
}
// newPlaybackStartRequestDigestsV3 fingerprints both the body and normalized
// device identity because either can change the selected playback plan. It
// also retains the pre-device digest while attempts written by an older
@@ -608,7 +532,7 @@ func (d playbackStartRequestDigestsV3) matches(stored string) bool {
return stored == "" || stored == d.current || stored == d.legacy
}
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3, clientInfo playback.ClientInfo) (playback.DecisionResponseV3, *transportErrorV3) {
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
if result.Plan == nil {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."}
}
@@ -621,8 +545,15 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
return playback.DecisionResponseV3{}, &transportErrorV3{reason: reason, message: "The selected server adaptation is disabled for this user."}
}
}
clientInfo := playbackClientInfoFromRequest(r)
ctx := playback.WithClientInfo(r.Context(), clientInfo)
session, err := h.sessionMgr.StartSessionWithFilesContext(ctx, userID, profileID, effectiveFile.ID, requestedFile.ID, result.PlayMethod, result.TranscodeAudio)
var session *playback.Session
var err error
if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok {
session, err = starter.StartSessionWithFilesContext(ctx, userID, profileID, effectiveFile.ID, requestedFile.ID, result.PlayMethod, result.TranscodeAudio)
} else {
session, err = h.sessionMgr.StartSessionWithFiles(userID, profileID, effectiveFile.ID, requestedFile.ID, result.PlayMethod, result.TranscodeAudio)
}
if err != nil {
return playback.DecisionResponseV3{}, sessionStartErrorV3(err)
}
@@ -721,7 +652,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
h.maybeQueueLazyPlaybackMarkers(r.Context(), session, effectiveFile)
h.persistSeriesSelectionsV3(r.Context(), userID, profileID, effectiveFile, plannedAudioTrackIndexV3(result, audioIndex))
h.syncSessionsNow(r.Context(), "v3_start")
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, Event: playback.RouteEventPlanSelectedV3, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputContextID: req.ClientPlaybackContext.Output.OutputContextID}, UserID: userID, ProfileID: profileID, ClientName: clientInfo.Name, ClientVersion: clientInfo.Version, ClientBuild: clientInfo.Build, ClientChannel: clientInfo.Channel, ClientModel: req.ClientPlaybackContext.Device.Model})
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, Event: playback.RouteEventPlanSelectedV3, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputContextID: req.ClientPlaybackContext.Output.OutputContextID}, UserID: userID, ProfileID: profileID, ClientName: clientInfo.Name, ClientVersion: clientInfo.Version, ClientModel: req.ClientPlaybackContext.Device.Model})
return response, nil
}
@@ -741,7 +672,7 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
return preparedTransportV3{}, timelineErr
}
if result.Plan.Delivery != playback.DeliveryTranscodeHLSV3 && result.Plan.Delivery != playback.DeliveryRemuxHLSV3 {
return h.prepareIdentityTransportV3(r, session, file, result, timeline)
return h.prepareIdentityTransportV3(session, result, timeline), nil
}
if h.NodePlanner != nil {
plan := h.planNodeSessionV3(r.Context(), session, result)
@@ -846,7 +777,7 @@ func planRequiresServerTransformationsV3(plan *playback.PlanV3) bool {
return false
}
func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3, timeline preparedTimelineV3) preparedTransportV3 {
routeSession := *session
routeSession.PlayMethod = result.PlayMethod
routeSession.BasePlayMethod = result.PlayMethod
@@ -857,31 +788,11 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *p
routeSession.TargetAudioChannels = result.TargetAudioChannels
routeSession.TargetAudioBitrateKbps = result.TargetAudioBitrateKbps
routeSession.RemuxDVMode = remuxDVModeForPlanV3(result.Plan)
proxyNode, proxyErr := h.planIdentityProxyV3(r, session.ID, result)
if proxyErr != nil {
return preparedTransportV3{}, proxyErr
}
streamURL, servedByProxy := h.identityStreamURLV3(&routeSession, file, proxyNode)
releaseProxyReservation := func() {
if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok {
releaser.ReleaseSession(session.ID)
}
}
if proxyNode != nil && !servedByProxy {
// A planned proxy that could not be addressed (no signable token, no
// file record) falls back to the local path, so its reservation must be
// dropped now rather than pinning that node's budget until it ages out.
releaseProxyReservation()
if err := h.refuseLocalIdentityWorkV3(r, result); err != nil {
return preparedTransportV3{}, err
}
}
previousNodeURL := session.TranscodeNodeURL
previousTransportID := remoteTransportID(session)
unlock := h.tm.LockSessionLifecycle(session.ID)
committed := false
streamURL := h.playbackStreamURL(&routeSession)
if result.Plan != nil && result.Plan.Delivery == playback.DeliveryRemuxProgressiveV3 {
if seek := timeline.seekSeconds; seek > 0 {
streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64))
@@ -898,7 +809,6 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *p
if previousNodeURL != "" {
h.tm.StopRemoteTranscode(previousTransportID, previousNodeURL)
}
h.applyRemoteTransportMarkV3(r.Context(), session.ID, servedByProxy)
unlock()
},
rollback: func() {
@@ -906,158 +816,9 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *p
return
}
committed = true
// The session never reached the client, so a proxy admitted for it
// must not keep consuming that node's job/bandwidth budget until the
// reservation ages out.
if servedByProxy {
releaseProxyReservation()
}
unlock()
},
}, nil
}
// planIdentityProxyV3 selects the proxy node that will serve a direct-play or
// progressive-remux session. These deliveries need no transcode node — the
// bytes are either the source file or a single remux pipe — so the planner is
// asked for a proxy alone, exactly as the Jellyfin-compat transport does.
//
// A nil node (no planner, no signing secret, or no eligible proxy) means the
// API server serves the stream itself, which is both the single-node case and
// the correct degradation when every proxy is unhealthy or at capacity. The one
// exception is a remux that must run ffmpeg: that is transcode work, so it
// honors the same local-fallback gate as the HLS routes rather than quietly
// spawning an encoder on an API-only node.
func (h *PlaybackHandler) planIdentityProxyV3(r *http.Request, sessionID string, result playback.PlannerResultV3) (*nodepool.Node, *transportErrorV3) {
if h.NodePlanner == nil || h.JWTSecret == "" {
return nil, h.refuseLocalIdentityWorkV3(r, result)
}
// Reserve against the session id the rest of the transport uses, so a
// re-plan replaces its own reservation instead of double-counting, and the
// rollback path can release it.
plan := h.planIdentityProxySessionV3(r.Context(), sessionID, result)
if plan.ProxyNode == nil {
return nil, h.refuseLocalIdentityWorkV3(r, result)
}
return plan.ProxyNode, nil
}
// applyRemoteTransportMarkV3 records whether the committed route's media bytes
// leave this server or another node.
//
// Every committed transition calls it, including the ones that serve locally:
// a session replanned from a proxy onto the integrated transcoder would
// otherwise keep a stale mark, and the widened idle grace it grants would hold
// that session's stream and transcode slots for five minutes after the local
// stream disconnected without an explicit stop.
func (h *PlaybackHandler) applyRemoteTransportMarkV3(ctx context.Context, sessionID string, remote bool) {
if err := h.sessionMgr.SetRemoteTransport(sessionID, remote); err != nil &&
!errors.Is(err, playback.ErrSessionNotFound) {
slog.WarnContext(ctx, "failed to record transport locality",
"component", "api", "playback_session_id", sessionID, "remote", remote, "error", err)
}
}
// planIdentityProxySessionV3 asks the planner for a proxy, narrowing selection
// to proxies that can execute the plan's frozen recipe when one is required.
//
// The narrowing happens before selection rather than after, mirroring how HLS
// filters transcode nodes: rejecting a single round-robin pick would abandon
// the whole pool, so a capable sibling with free capacity would sit unused
// while playback fell back to the API — or was refused outright when local
// fallback is disabled.
//
// Direct play copies bytes and needs no recipe, so it skips the probe entirely
// and any healthy proxy serves it.
func (h *PlaybackHandler) planIdentityProxySessionV3(ctx context.Context, sessionID string, result playback.PlannerResultV3) nodepool.Plan {
estBitrate := identityStreamBitrateKbpsV3(result)
selector, selectable := h.NodePlanner.(capabilitySessionPlannerV3)
enumerator, enumerable := h.NodePlanner.(proxyNodeEnumeratorV3)
if !selectable || !enumerable || !planRequiresServerTransformationsV3(result.Plan) {
return h.NodePlanner.PlanSession(sessionID, "", false, estBitrate)
}
capable := make(map[string]struct{})
for nodeURL, advertised := range h.pooledNodeTransformationsV3(ctx, enumerator.ProxyNodeURLs()) {
if validateAdvertisedTransformationsV3(result.Plan, advertised) == nil {
capable[nodeURL] = struct{}{}
}
}
if len(capable) == 0 {
slog.WarnContext(ctx, "protocol v3 no proxy advertises the planned recipe",
"component", "api", "delivery", result.Plan.Delivery)
}
// The predicate runs under the planner lock: a set lookup only.
return selector.PlanSessionWith(sessionID, "", false, estBitrate, func(node *nodepool.Node) bool {
if node == nil {
return false
}
_, ok := capable[node.URL]
return ok
})
}
// refuseLocalIdentityWorkV3 enforces playback.local_transcode_fallback for the
// identity deliveries. Direct play moves bytes and always falls back locally;
// a progressive remux that must convert audio is ffmpeg work, so on an API-only
// node it is refused for the same reason the HLS routes refuse it, instead of
// silently spawning an encoder the operator disabled.
func (h *PlaybackHandler) refuseLocalIdentityWorkV3(r *http.Request, result playback.PlannerResultV3) *transportErrorV3 {
if result.Plan == nil || result.Plan.Delivery != playback.DeliveryRemuxProgressiveV3 ||
!planRequiresServerTransformationsV3(result.Plan) ||
nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
return nil
}
return &transportErrorV3{reason: "capacity_unavailable", message: "No proxy node is available and local fallback is disabled.", retryable: true}
}
// identityStreamBitrateKbpsV3 estimates the bitrate a proxy will egress for an
// identity delivery, so bandwidth-capped proxies admit it accurately. The plan's
// effective recipe is authoritative (a remux that downmixes audio egresses less
// than the source); the source descriptor is the fallback.
func identityStreamBitrateKbpsV3(result playback.PlannerResultV3) int {
if result.Plan == nil {
return 0
}
if effective := result.Plan.EffectiveRecipe.BitrateKbps; effective != nil && *effective > 0 {
return *effective
}
if source := result.Plan.Source.BitrateKbps; source > 0 {
return source
}
return 0
}
// identityStreamURLV3 builds the stream URL for a direct-play or
// progressive-remux session: an absolute proxy URL when one was planned,
// otherwise the API-local path.
//
// The proxy serves from the token alone, so the token has to carry everything
// the API-local path would have read from the session and the file record: the
// media path it opens, and the Dolby Vision profile its remux needs to strip a
// dangling Profile 7 RPU. Omitting either would not fail loudly — the proxy
// would serve a subtly different stream than the plan promised.
//
// The bool reports whether the returned URL is actually a proxy URL, so the
// caller can release the planner reservation when it is not.
func (h *PlaybackHandler) identityStreamURLV3(s *playback.Session, file *models.MediaFile, proxyNode *nodepool.Node) (string, bool) {
if proxyNode == nil || file == nil {
return h.playbackStreamURL(s), false
}
card := identityRecipeCard(s)
card.InputPath = file.FilePath
claims := card.ToClaims()
claims.DVProfile = file.PrimaryDVProfile()
claims.AudioOnly = file.IsAudioOnly()
token := h.signStreamClaims(claims)
if token == "" {
return h.playbackStreamURL(s), false
}
base := strings.TrimRight(proxyNode.URL, "/")
if s.PlayMethod == playback.PlayRemux {
return base + "/stream/remux/" + token, true
}
return base + "/stream/direct/" + token, true
}
// sessionOwnsResumeTimelineV3 reports whether the session's own position is a
@@ -1268,7 +1029,6 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
}
committed = true
previous := h.tm.SwapTranscodeSession(session.ID, ts)
h.applyRemoteTransportMarkV3(r.Context(), session.ID, false)
unlock()
if previous != nil && previous != ts {
_ = previous.Close()
@@ -1327,10 +1087,6 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel))
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
url := h.buildProxyManifestURL(card, nodePlan.ProxyNode)
// buildProxyManifestURL only returns an absolute proxy URL when a proxy was
// planned and the token could be signed; otherwise the client fetches the
// manifest from this server and the local liveness path applies.
servedByProxy := nodePlan.ProxyNode != nil && strings.HasPrefix(url, "http")
committed := false
previousNodeURL := session.TranscodeNodeURL
previousTransportID := remoteTransportID(session)
@@ -1344,7 +1100,6 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
if previousNodeURL != "" {
h.tm.StopRemoteTranscode(previousTransportID, previousNodeURL)
}
h.applyRemoteTransportMarkV3(r.Context(), session.ID, servedByProxy)
unlock()
}, rollback: func() {
if committed {
@@ -2159,7 +1914,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
event = playback.RouteEventRuntimeCorrectionSucceededV3
clientModel = start.ClientPlaybackContext.Device.Model
}
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, PlanAttemptID: req.PlanAttemptID, PlanAttemptKey: playback.PlanAttemptKeyV3(*result.Plan, start.ClientPlaybackContext.Output.OutputContextID, nil), Event: event, FallbackReason: req.Failure.Classification, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputContextID: start.ClientPlaybackContext.Output.OutputContextID}, UserID: session.UserID, ProfileID: session.ProfileID, ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientBuild: session.ClientBuild, ClientChannel: session.ClientChannel, ClientModel: clientModel})
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, PlanAttemptID: req.PlanAttemptID, PlanAttemptKey: playback.PlanAttemptKeyV3(*result.Plan, start.ClientPlaybackContext.Output.OutputContextID, nil), Event: event, FallbackReason: req.Failure.Classification, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputContextID: start.ClientPlaybackContext.Output.OutputContextID}, UserID: session.UserID, ProfileID: session.ProfileID, ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientModel: clientModel})
}
transport.rollback = func() {
originalRollback()
@@ -2610,17 +2365,14 @@ const (
terminalSubtitleConversionUnsupportedV3 = "subtitle_conversion_unsupported"
)
// terminalAllowsAlternateFileV3 reports whether a refusal is the kind another
// version of the same item could satisfy.
// terminalAllowsAlternateFileV3 reports whether a planner terminal should
// trigger another media-file attempt before the response is finalized.
//
// subtitle_conversion_unsupported belongs here because it is not only a
// subtitle-format refusal: when a burn-in requirement is the sole trigger of an
// adaptation the source cannot take, the planner reports the blocker in terms
// of the subtitle rather than the HDR pipeline or the 4K policy. Those cases
// used to surface as hdr_transcode_unsupported / no_alternate_version and were
// the exact reason this gate exists — a bitmap subtitle needing burn-in that an
// HDR source cannot support while an SDR alternate can. Leaving the new reason
// out silently retired that fallback and refused playback outright.
// subtitle_conversion_unsupported is included because a burn-in-only
// adaptation can rename an HDR or 4K blocker to that reason (see
// planVideoTranscodeV3's subtitleForcedAdaptation path). The underlying
// incompatibility is still version-shaped: an SDR / lower-resolution
// alternate may burn the same subtitle successfully.
func terminalAllowsAlternateFileV3(terminal *playback.TerminalV3) bool {
if terminal == nil {
return false
@@ -2770,8 +2522,8 @@ func (h *PlaybackHandler) HandlePlaybackRouteEventV3(w http.ResponseWriter, r *h
return
}
event.Diagnostics = sanitizeDiagnosticsV3(event.Diagnostics)
client := h.playbackClientInfoWithSessionFallbackV3(firstNonEmptyValue(event.SessionID, identity.SessionID), playbackClientInfoFromRequest(r))
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: event, UserID: userID, ProfileID: profileID, ClientName: client.Name, ClientVersion: client.Version, ClientBuild: client.Build, ClientChannel: client.Channel, ClientModel: event.Diagnostics["device_model"]})
client := playbackClientInfoFromRequest(r)
h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: event, UserID: userID, ProfileID: profileID, ClientName: client.Name, ClientVersion: client.Version, ClientModel: event.Diagnostics["device_model"]})
w.WriteHeader(http.StatusAccepted)
}
+1 -517
View File
@@ -23,7 +23,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -118,7 +117,7 @@ func TestShouldTryAlternateFileV3PinsOriginalQuality(t *testing.T) {
}
func TestTerminalAllowsAlternateFileV3IncludesHDRIncompatibility(t *testing.T) {
for _, reason := range []string{"no_alternate_version", "hdr_transcode_unsupported"} {
for _, reason := range []string{"no_alternate_version", "hdr_transcode_unsupported", "subtitle_conversion_unsupported"} {
if !terminalAllowsAlternateFileV3(&playback.TerminalV3{Reason: reason}) {
t.Fatalf("terminal reason %q should permit alternate selection", reason)
}
@@ -3635,518 +3634,3 @@ func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID stri
}
return response
}
// recordingNodePlannerV3 is a planner double that records what the transport
// asked for and whether the reservation was released.
type recordingNodePlannerV3 struct {
plan nodepool.Plan
needsTranscode bool
estBitrateKbps int
plannedSessionID string
released []string
}
func (p *recordingNodePlannerV3) PlanSession(sessionID, _ string, needsTranscode bool, estBitrateKbps int) nodepool.Plan {
p.plannedSessionID = sessionID
p.needsTranscode = needsTranscode
p.estBitrateKbps = estBitrateKbps
return p.plan
}
func (p *recordingNodePlannerV3) ReleaseSession(sessionID string) {
p.released = append(p.released, sessionID)
}
// PlanSessionWith mirrors the real planner: the eligibility predicate narrows
// the pool before selection, so a proxy that cannot execute the recipe is
// skipped rather than picked and then rejected.
func (p *recordingNodePlannerV3) PlanSessionWith(sessionID, currentTranscodeURL string, needsTranscode bool, estBitrateKbps int, eligible func(*nodepool.Node) bool) nodepool.Plan {
plan := p.PlanSession(sessionID, currentTranscodeURL, needsTranscode, estBitrateKbps)
if eligible == nil {
return plan
}
if !needsTranscode && plan.ProxyNode != nil && !eligible(plan.ProxyNode) {
return nodepool.Plan{}
}
if needsTranscode && plan.TranscodeNode != nil && !eligible(plan.TranscodeNode) {
return nodepool.Plan{}
}
return plan
}
func (p *recordingNodePlannerV3) ProxyNodeURLs() []string {
if p.plan.ProxyNode == nil {
return nil
}
return []string{p.plan.ProxyNode.URL}
}
func (p *recordingNodePlannerV3) TranscodeNodeURLs() []string {
if p.plan.TranscodeNode == nil {
return nil
}
return []string{p.plan.TranscodeNode.URL}
}
func identityProxyPlanV3(delivery playback.DeliveryV3, transformations ...playback.TransformationV3) *playback.PlanV3 {
return &playback.PlanV3{
PlanID: "plan:identity-proxy",
Delivery: delivery,
Transformations: transformations,
Source: playback.SourceDescriptorV3{BitrateKbps: 5_673},
}
}
func TestPrepareTransportV3RoutesDirectPlayThroughProxyNode(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
planner := &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: "http://proxy-1"}}}
handler.NodePlanner = planner
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-direct-proxy", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
defer transport.rollback()
if !strings.HasPrefix(transport.url, "http://proxy-1/stream/direct/") {
t.Fatalf("direct-play stream url = %q, want a proxy /stream/direct URL", transport.url)
}
if planner.needsTranscode {
t.Fatal("direct play must not request a transcode node")
}
if planner.plannedSessionID != "session-direct-proxy" {
t.Fatalf("reservation session id = %q, want the playback session id", planner.plannedSessionID)
}
if planner.estBitrateKbps != 5_673 {
t.Fatalf("estimated bitrate = %d, want the source bitrate for cap admission", planner.estBitrateKbps)
}
token := strings.TrimPrefix(transport.url, "http://proxy-1/stream/direct/")
claims, err := streamtoken.Verify(token, handler.JWTSecret)
if err != nil {
t.Fatalf("verify proxy stream token: %v", err)
}
if claims.MediaPath == "" {
t.Fatal("proxy token carries no media path; the proxy cannot open the source")
}
if claims.PlayMethod != string(playback.PlayDirect) {
t.Fatalf("token play method = %q, want direct", claims.PlayMethod)
}
}
func TestPrepareTransportV3RoutesProgressiveRemuxThroughProxyNodeWithSeekAndDV(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
stubCopySeekAnchorV3(handler)
proxy := capableProxyStubV3(t)
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: proxy.URL + "/"}}}
file := v3HandlerFixtureFile(t)
file.VideoTracks[0].DVProfile = 7
plan := identityProxyPlanV3(playback.DeliveryRemuxProgressiveV3, playback.TransformationV3{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"})
plan.Timeline = playback.TimelineV3{SourceStartSeconds: 39.5}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-remux-proxy", UserID: 7, ProfileID: "profile-1"},
file,
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TranscodeAudio: true, TargetAudioCodec: "aac"},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
defer transport.rollback()
remuxPrefix := proxy.URL + "/stream/remux/"
if !strings.HasPrefix(transport.url, remuxPrefix) {
t.Fatalf("remux stream url = %q, want a proxy /stream/remux URL", transport.url)
}
if !strings.Contains(transport.url, "seek=39.5") {
t.Fatalf("remux stream url = %q, want the resolved seek preserved", transport.url)
}
token := strings.TrimSuffix(strings.TrimPrefix(transport.url, remuxPrefix), "?seek=39.5")
claims, err := streamtoken.Verify(token, handler.JWTSecret)
if err != nil {
t.Fatalf("verify proxy stream token: %v", err)
}
if claims.DVProfile != 7 {
t.Fatalf("token DV profile = %d, want 7 so the proxy strips the dangling RPU", claims.DVProfile)
}
if !claims.TranscodeAudio {
t.Fatal("token must tell the proxy to convert audio")
}
}
func TestPrepareTransportV3FallsBackLocallyWithoutEligibleProxy(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{}}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-direct-local", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
defer transport.rollback()
if !strings.HasPrefix(transport.url, "/stream/session-direct-local") {
t.Fatalf("stream url = %q, want the API-local path when no proxy is eligible", transport.url)
}
}
func TestPrepareTransportV3RefusesLocalRemuxWhenFallbackDisabled(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
stubCopySeekAnchorV3(handler)
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.local_transcode_fallback": "false"}}
_, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-remux-refused", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{
Plan: identityProxyPlanV3(playback.DeliveryRemuxProgressiveV3, playback.TransformationV3{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"}),
PlayMethod: playback.PlayRemux,
TranscodeAudio: true,
},
)
if transportErr == nil || transportErr.reason != "capacity_unavailable" {
t.Fatalf("transport error = %#v, want capacity_unavailable when local remux work is disabled", transportErr)
}
}
func TestPrepareTransportV3AllowsLocalDirectPlayWhenFallbackDisabled(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.local_transcode_fallback": "false"}}
// Direct play moves bytes rather than running ffmpeg, so the transcode
// fallback gate must not strand a single-node deployment.
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-direct-allowed", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("direct play refused with local fallback disabled: %#v", transportErr)
}
transport.rollback()
}
func TestPrepareTransportV3ReleasesProxyReservationOnRollback(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
planner := &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: "http://proxy-1"}}}
handler.NodePlanner = planner
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-rollback", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
transport.rollback()
if len(planner.released) != 1 || planner.released[0] != "session-rollback" {
t.Fatalf("released reservations = %v, want the session released once", planner.released)
}
}
// capableProxyStubV3 is a proxy that advertises the audio_to_aac and DV strip
// recipes, mirroring the /hw-capabilities endpoint a real proxy now serves.
func capableProxyStubV3(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hw-capabilities" {
w.WriteHeader(http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"},
{Name: playback.TransformationServerDV7HDR10V3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"},
}})
}))
t.Cleanup(server.Close)
return server
}
func TestPrepareTransportV3KeepsRemuxLocalWhenProxyLacksTheRecipe(t *testing.T) {
// A proxy on an older ffmpeg build advertises no aac encoder. Sending the
// remux there would 500 at stream time, so selection must reject it.
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: playback.TransformationVideoToH264V3, Executor: playback.ExecutorServerV3, RecipeVersion: "2"},
}})
}))
defer proxy.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
stubCopySeekAnchorV3(handler)
planner := &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: proxy.URL}}}
handler.NodePlanner = planner
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-incapable-proxy", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{
Plan: identityProxyPlanV3(playback.DeliveryRemuxProgressiveV3, playback.TransformationV3{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"}),
PlayMethod: playback.PlayRemux,
TranscodeAudio: true,
},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
defer transport.rollback()
if strings.HasPrefix(transport.url, proxy.URL) {
t.Fatalf("stream url = %q, want local fallback when the proxy lacks the recipe", transport.url)
}
// Narrowing happens before selection, so no reservation is made against an
// incapable proxy in the first place and none needs releasing.
if len(planner.released) != 0 {
t.Fatalf("released = %v, want no reservation taken on an incapable proxy", planner.released)
}
}
func TestPrepareTransportV3DirectPlaySkipsProxyCapabilityProbe(t *testing.T) {
// Direct play copies bytes and needs no recipe, so it must not pay a
// capability fetch nor be rejected by one.
probes := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
probes++
w.WriteHeader(http.StatusNotFound)
}))
defer proxy.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: proxy.URL}}}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-direct-noprobe", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
defer transport.rollback()
if !strings.HasPrefix(transport.url, proxy.URL+"/stream/direct/") {
t.Fatalf("stream url = %q, want the proxy direct URL", transport.url)
}
if probes != 0 {
t.Fatalf("capability probes = %d, want none for direct play", probes)
}
}
func TestIdentityStreamBitrateKbpsV3PrefersEffectiveRecipe(t *testing.T) {
// A downmixing remux egresses less than the source, so the effective
// recipe — not the source descriptor — is what a capped proxy admits on.
effective := 1_200
plan := identityProxyPlanV3(playback.DeliveryRemuxProgressiveV3)
plan.EffectiveRecipe.BitrateKbps = &effective
if got := identityStreamBitrateKbpsV3(playback.PlannerResultV3{Plan: plan}); got != effective {
t.Fatalf("bitrate estimate = %d, want the effective recipe value %d", got, effective)
}
plan.EffectiveRecipe.BitrateKbps = nil
if got := identityStreamBitrateKbpsV3(playback.PlannerResultV3{Plan: plan}); got != 5_673 {
t.Fatalf("bitrate estimate = %d, want the source fallback 5673", got)
}
}
func TestPrepareTransportV3MarksProxySessionsAsRemotelyTransported(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager)
handler.JWTSecret = "test-secret"
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: "http://proxy-1"}}}
session, err := manager.StartSession(7, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("start session: %v", err)
}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
session,
v3HandlerFixtureFile(t),
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %v", transportErr)
}
transport.commit()
// The client talks to the proxy, so this server sees no transport request.
// Without the remote-transport mark the idle sweep would reap a healthy
// stream as soon as heartbeats lapsed.
if expired := manager.CleanInactive(time.Nanosecond, time.Nanosecond); len(expired) != 0 {
t.Fatalf("expired %d proxy-served sessions, want them preserved", len(expired))
}
if _, err := manager.GetSession(session.ID); err != nil {
t.Fatalf("proxy-served session was reaped: %v", err)
}
}
// poolNodePlannerV3 models a multi-proxy pool so capability narrowing can be
// observed end to end rather than only at the planner unit level.
type poolNodePlannerV3 struct {
proxies []*nodepool.Node
released []string
}
func (p *poolNodePlannerV3) PlanSession(string, string, bool, int) nodepool.Plan {
if len(p.proxies) == 0 {
return nodepool.Plan{}
}
return nodepool.Plan{ProxyNode: p.proxies[0]}
}
func (p *poolNodePlannerV3) PlanSessionWith(sessionID, currentTranscodeURL string, needsTranscode bool, estBitrateKbps int, eligible func(*nodepool.Node) bool) nodepool.Plan {
for _, node := range p.proxies {
if eligible == nil || eligible(node) {
return nodepool.Plan{ProxyNode: node}
}
}
return nodepool.Plan{}
}
func (p *poolNodePlannerV3) ProxyNodeURLs() []string {
urls := make([]string, 0, len(p.proxies))
for _, node := range p.proxies {
urls = append(urls, node.URL)
}
return urls
}
func (p *poolNodePlannerV3) TranscodeNodeURLs() []string { return nil }
func (p *poolNodePlannerV3) ReleaseSession(sessionID string) {
p.released = append(p.released, sessionID)
}
func TestPrepareTransportV3PrefersACapableSiblingProxy(t *testing.T) {
// A rolling upgrade leaves one proxy on an ffmpeg without an aac encoder.
// The remux must land on its capable sibling rather than falling back to
// the API node, which would be refused outright with local fallback off.
stale := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: playback.TransformationVideoToH264V3, Executor: playback.ExecutorServerV3, RecipeVersion: "2"},
}})
}))
defer stale.Close()
upgraded := capableProxyStubV3(t)
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.local_transcode_fallback": "false"}}
handler.NodePlanner = &poolNodePlannerV3{proxies: []*nodepool.Node{
{ID: 1, URL: stale.URL},
{ID: 2, URL: upgraded.URL},
}}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-sibling", UserID: 7, ProfileID: "profile-1"},
v3HandlerFixtureFile(t),
playback.PlannerResultV3{
Plan: identityProxyPlanV3(playback.DeliveryRemuxProgressiveV3, playback.TransformationV3{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1"}),
PlayMethod: playback.PlayRemux,
TranscodeAudio: true,
},
)
if transportErr != nil {
t.Fatalf("prepare identity transport: %#v", transportErr)
}
defer transport.rollback()
if !strings.HasPrefix(transport.url, upgraded.URL+"/stream/remux/") {
t.Fatalf("stream url = %q, want the capable sibling proxy %q", transport.url, upgraded.URL)
}
}
func TestPrepareTransportV3ClearsRemoteTransportMarkWhenServingLocally(t *testing.T) {
// A session replanned from a proxy onto this server must lose the widened
// idle grace, or its stream and transcode slots stay held for minutes after
// a local stream disconnects without an explicit stop.
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager)
handler.JWTSecret = "test-secret"
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{ProxyNode: &nodepool.Node{URL: "http://proxy-1"}}}
session, err := manager.StartSession(7, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("start session: %v", err)
}
file := v3HandlerFixtureFile(t)
proxied, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil), session, file,
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare proxy transport: %v", transportErr)
}
proxied.commit()
// Re-plan with no eligible proxy: the same delivery now serves locally.
handler.NodePlanner = &recordingNodePlannerV3{plan: nodepool.Plan{}}
local, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil), session, file,
playback.PlannerResultV3{Plan: identityProxyPlanV3(playback.DeliveryOriginalHTTPV3), PlayMethod: playback.PlayDirect},
)
if transportErr != nil {
t.Fatalf("prepare local transport: %v", transportErr)
}
local.commit()
if expired := manager.CleanInactive(time.Nanosecond, time.Nanosecond); len(expired) != 1 {
t.Fatalf("expired %d sessions, want the locally-served session to lose its widened grace", len(expired))
}
}
// A burn-in requirement that only an SDR alternate can satisfy must still reach
// the alternate-version fallback. The planner reports that refusal in terms of
// the subtitle rather than the HDR pipeline, so a gate listing only the HDR and
// version reasons silently retires the fallback and refuses playback outright.
func TestTerminalAllowsAlternateFileV3CoversSubtitleForcedRefusals(t *testing.T) {
for _, reason := range []string{
terminalNoAlternateVersionV3,
terminalHDRTranscodeUnsupportedV3,
terminalSubtitleConversionUnsupportedV3,
} {
if !terminalAllowsAlternateFileV3(&playback.TerminalV3{Reason: reason}) {
t.Fatalf("terminal %q must allow an alternate-version retry", reason)
}
}
if terminalAllowsAlternateFileV3(&playback.TerminalV3{Reason: "client_hls_unsupported"}) {
t.Fatal("a client-route refusal must not trigger an alternate-version retry")
}
if terminalAllowsAlternateFileV3(nil) {
t.Fatal("a nil terminal must not trigger an alternate-version retry")
}
}
+12 -15
View File
@@ -23,19 +23,18 @@ import (
// SectionHandler handles section management and batch section endpoints.
type SectionHandler struct {
repo *sections.Repository
fetcher *sections.Fetcher
previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection
episodeFetcher sectionEpisodeFetcher
FolderRepo *catalog.FolderRepository
EpisodeRepo *catalog.EpisodeRepository
StoreProvider userstore.UserStoreProvider
UserRepo *auth.UserRepository
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner
EbookProgress EbookReaderProgressLister
repo *sections.Repository
fetcher *sections.Fetcher
previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection
episodeFetcher sectionEpisodeFetcher
FolderRepo *catalog.FolderRepository
EpisodeRepo *catalog.EpisodeRepository
StoreProvider userstore.UserStoreProvider
UserRepo *auth.UserRepository
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
EbookProgress EbookReaderProgressLister
}
// NewSectionHandler creates a new SectionHandler.
@@ -406,8 +405,6 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.
}
if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
slog.WarnContext(ctx, "failed to delete unreferenced section-managed collection", "component", "api", "collection_id", collectionID, "error", err)
} else if err == nil && h.SortPreferenceCleaner != nil {
h.SortPreferenceCleaner.DeleteForCollection(ctx, userstore.CollectionKindLibrary, collectionID)
}
}
@@ -75,7 +75,6 @@ type userImportSharedFields struct {
PosterURL string `json:"poster_url"`
LibraryIDs []int `json:"library_ids,omitempty"`
DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty"`
SortConfig json.RawMessage `json:"sort_config,omitempty"`
}
type userImportMDBListRequest struct {
@@ -218,11 +217,6 @@ func (h *UserCollectionImportHandler) createImportedCollection(
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sortConfig, err := NormalizeCollectionSortConfig(shared.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
@@ -231,7 +225,7 @@ func (h *UserCollectionImportHandler) createImportedCollection(
CollectionType: collectionType,
IsShared: shared.IsShared,
QueryDefinition: "{}",
SortConfig: sortConfig,
SortConfig: "{}",
SourceURL: cfg.DisplayURL(),
SourceConfig: sourceConfigJSON,
SyncSchedule: schedule,
-11
View File
@@ -1438,14 +1438,6 @@ func NewRouter(deps Dependencies) chi.Router {
}
libraryCollectionRepo := catalog.NewLibraryCollectionRepository(deps.DB)
var collectionSortCleaner *userstore.CollectionSortPreferenceCleaner
if userRepo != nil && deps.UserStoreProvider != nil {
collectionSortCleaner = userstore.NewCollectionSortPreferenceCleaner(userRepo, deps.UserStoreProvider)
}
if collectionHandler != nil {
collectionHandler.LibraryCollections = libraryCollectionRepo
}
sectionHandler.SortPreferenceCleaner = collectionSortCleaner
if libraryCollectionService == nil {
libraryCollectionService = catalog.NewLibraryCollectionService(
libraryCollectionRepo,
@@ -1545,7 +1537,6 @@ func NewRouter(deps Dependencies) chi.Router {
libraryCollectionHandler.SectionRepo = sectionRepo
libraryCollectionHandler.UserCollectionPool = deps.DB
libraryCollectionHandler.EventsHub = deps.EventsHub
libraryCollectionHandler.SortPreferenceCleaner = collectionSortCleaner
if deps.FolderRepo != nil {
libraryCollectionHandler.FolderRepo = deps.FolderRepo
} else {
@@ -2300,8 +2291,6 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(apimw.RequireProfile)
r.Get("/", collectionHandler.HandleListCollections)
r.Get("/capabilities", collectionHandler.HandleCapabilities)
r.Put("/sort-preference", collectionHandler.HandleSetCollectionSortPreference)
r.Delete("/sort-preference", collectionHandler.HandleClearCollectionSortPreference)
if libraryCollectionHandler != nil {
// Aggregated server (admin-curated) collections across
// every accessible library. Separate from "/" (personal,
-25
View File
@@ -8,8 +8,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
const LibraryCollectionVisibilityVisible = "visible"
// AccessFilter captures effective viewer access constraints for catalog reads.
type AccessFilter struct {
AllowedLibraryIDs []int
@@ -47,29 +45,6 @@ type AccessFilter struct {
ExcludedMediaTypes []string
}
// CanAccessLibraryCollection reports whether a visible server collection is
// reachable through at least one library in the viewer's effective scope.
// Collections without explicit library scope retain their legacy unrestricted
// visibility, but restricted viewers cannot address them by ID.
func CanAccessLibraryCollection(collection *models.LibraryCollection, filter AccessFilter) bool {
if collection == nil || collection.Visibility != LibraryCollectionVisibilityVisible {
return false
}
if len(collection.LibraryIDs) == 0 {
return filter.AllowedLibraryIDs == nil && len(filter.DisabledLibraryIDs) == 0
}
for _, libraryID := range collection.LibraryIDs {
if filter.AllowedLibraryIDs != nil && !intInSlice(libraryID, filter.AllowedLibraryIDs) {
continue
}
if intInSlice(libraryID, filter.DisabledLibraryIDs) {
continue
}
return true
}
return false
}
func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
if filter.MaxContentRating != "" {
allowedRatings := access.AllowedRatingsUpTo(filter.MaxContentRating)
+5 -74
View File
@@ -37,12 +37,6 @@ type CatalogResult struct {
SemanticUsed bool
FallbackReason string
IndexPendingEvents int
// EffectiveSort is the order collection sources actually resolved in, after
// applying the request's own sort, the viewer's saved override, and the
// collection's configured default in that order. An empty Field means the
// collection's source order. Only collection sources populate it; clients
// use it to show which sort is active when the request carried none.
EffectiveSort QuerySort
}
type CatalogFiltersResult struct {
@@ -493,25 +487,10 @@ func (r *CatalogResolver) resolveSectionBrowseSource(ctx context.Context, req Ca
func (r *CatalogResolver) resolveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || !CanAccessLibraryCollection(collection, access) {
if err != nil || collection.Visibility != "visible" {
return nil, ErrCatalogSourceNotFound
}
return r.resolveCollectionWithEffectiveSort(
ctx, req, access, userstore.CollectionKindLibrary, collection.ID, collection.SortConfig,
func(req CatalogRequest) (*CatalogResult, error) {
return r.resolveLibraryCollectionItems(ctx, req, access, collection, collectionRepo)
},
)
}
func (r *CatalogResolver) resolveLibraryCollectionItems(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
collection *models.LibraryCollection,
collectionRepo *LibraryCollectionRepository,
) (*CatalogResult, error) {
if IsLiveQueryType(collection.CollectionType) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
@@ -565,55 +544,10 @@ func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req C
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) {
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
return r.resolveCollectionWithEffectiveSort(
ctx, req, access, userstore.CollectionKindUser, collection.ID, []byte(collection.SortConfig),
func(req CatalogRequest) (*CatalogResult, error) {
return r.resolveUserCollectionItems(ctx, req, access, store, collection)
},
)
}
// resolveCollectionWithEffectiveSort applies the shared collection-sort
// precedence, delegates item resolution, and stamps the resolved order on the
// response. Keeping this sequence in one place prevents the library and
// personal collection paths from drifting.
func (r *CatalogResolver) resolveCollectionWithEffectiveSort(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
kind, collectionID string,
sortConfig []byte,
resolve func(CatalogRequest) (*CatalogResult, error),
) (*CatalogResult, error) {
// A sort in the request is the viewer's live choice and always wins; only
// when there is none do the saved override and the collection's configured
// default come into play.
if req.UseSourceOrder {
if qs, ok := r.EffectiveCollectionSort(ctx, access, kind, collectionID, sortConfig); ok {
req.Query.Sort = qs
req.UseSourceOrder = false
}
}
result, err := resolve(req)
if err != nil {
return nil, err
}
result.EffectiveSort = req.Query.Sort
return result, nil
}
func (r *CatalogResolver) resolveUserCollectionItems(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
store userstore.UserStore,
collection *userstore.Collection,
) (*CatalogResult, error) {
if IsLiveQueryType(collection.CollectionType) {
def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition))
if err != nil {
@@ -1752,10 +1686,7 @@ func (r *CatalogResolver) catalogStoreForAccess(ctx context.Context, access Acce
return store, nil
}
// ProfileCanAccessCollection reports whether profileID may view a personal
// collection. Creator access is represented in AllowedProfileIDs at write time,
// so this is the canonical check for both browse and preference endpoints.
func ProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
func catalogProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
if collection == nil || strings.TrimSpace(profileID) == "" {
return false
}
@@ -1875,7 +1806,7 @@ func (r *CatalogResolver) loadCollectionSourceBaseItems(ctx context.Context, req
case CatalogSourceLibraryCollection:
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != LibraryCollectionVisibilityVisible {
if err != nil || collection.Visibility != "visible" {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) || catalogCollectionUsesLiveQuery(collection.QueryDefinition) {
@@ -1906,7 +1837,7 @@ func (r *CatalogResolver) loadCollectionSourceBaseItems(ctx context.Context, req
return nil, err
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) {
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
var items []*models.MediaItem
-185
View File
@@ -1,185 +0,0 @@
package catalog
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// QueryCollectionItemsBySort loads collection members through the catalog
// query executor, applying an optional personal-collection display filter and
// the resolved sort before the database limit. This keeps rail queries bounded
// without changing the full-match total returned to callers.
func QueryCollectionItemsBySort(
ctx context.Context,
pool *pgxpool.Pool,
contentIDs []string,
qs QuerySort,
access AccessFilter,
limit int,
displayQueryDefinition string,
) ([]*models.MediaItem, int, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, 0, nil
}
def := QueryDefinition{Sort: qs}
if trimmed := strings.TrimSpace(displayQueryDefinition); trimmed != "" {
if err := json.Unmarshal([]byte(trimmed), &def); err != nil {
return nil, 0, fmt.Errorf("parsing collection display_query_definition: %w", err)
}
// Display fragments are filter-only. Never let stale or malformed stored
// execution fields expand scope, replace the selected sort, or set a
// different limit.
def.MediaScope = ""
def.LibraryIDs = nil
def.Sort = qs
def.Limit = nil
}
queryAccess := access
if access.AllowedContentIDs != nil {
queryAccess.AllowedContentIDs = intersectContentIDs(contentIDs, access.AllowedContentIDs)
} else {
queryAccess.AllowedContentIDs = contentIDs
}
if limit <= 0 || limit > len(contentIDs) {
limit = len(contentIDs)
}
executor := &QueryExecutor{Pool: pool}
items, total, err := executor.Preview(ctx, def.Normalize(), queryAccess, limit)
if err != nil {
return nil, 0, fmt.Errorf("querying collection items by %q: %w", qs.Field, err)
}
return items, total, nil
}
// EffectiveCollectionSort resolves the order a collection's items are shown in
// when the request itself carries no sort. Precedence, highest first:
//
// 1. the viewing profile's saved override — set by changing the sort while
// browsing the collection, and sticky from then on;
// 2. the default sort the collection's creator configured (sort_config);
// 3. the collection's own source order, signaled by ok == false.
//
// An override row with an empty sort field is a real choice ("keep source
// order") and short-circuits the creator's default, which is why it returns
// false rather than falling through.
//
// Creator defaults for library collections stay user-agnostic because they are
// shared. A viewer override is profile-scoped, however, so personalized fields
// (progress, date viewed, plays) are valid for either collection kind.
func (r *CatalogResolver) EffectiveCollectionSort(
ctx context.Context,
access AccessFilter,
kind string,
collectionID string,
sortConfig []byte,
) (QuerySort, bool) {
if pref := r.collectionSortOverride(ctx, access, kind, collectionID); pref != nil {
if strings.TrimSpace(pref.SortField) == "" {
return QuerySort{}, false
}
if qs, ok := NormalizeCollectionSort(pref.SortField, pref.SortOrder, true); ok {
return qs, true
}
// A stored override that no longer validates (a sort field retired from
// the vocabulary) falls back to the creator's default rather than
// failing the browse.
}
return ParseCollectionDefaultSort(sortConfig, kind == userstore.CollectionKindUser)
}
// OrderCollectionItemsBySort reorders already-loaded collection members by a
// resolved sort, returning them in that order.
//
// Ordering runs through the catalog query executor constrained to the member
// IDs, so a rail orders items exactly as the collection's browse page does —
// same NULLS LAST handling, same sort_title collation — rather than through a
// second, drifting in-memory comparator. An empty sort field returns items
// untouched (source order). Items the executor does not return (no longer
// visible to this access scope) are dropped, matching the browse path.
func OrderCollectionItemsBySort(
ctx context.Context,
pool *pgxpool.Pool,
items []*models.MediaItem,
qs QuerySort,
access AccessFilter,
) ([]*models.MediaItem, error) {
if strings.TrimSpace(qs.Field) == "" || len(items) == 0 {
return items, nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
if item != nil && strings.TrimSpace(item.ContentID) != "" {
ids = append(ids, item.ContentID)
}
}
if len(ids) == 0 {
return items, nil
}
ordered, _, err := QueryCollectionItemsBySort(ctx, pool, ids, qs, access, len(ids), "")
if err != nil {
return nil, fmt.Errorf("ordering collection items by %q: %w", qs.Field, err)
}
// Preview returns freshly loaded rows; re-project onto the caller's items so
// any enrichment already applied to them survives the reorder.
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
if item != nil {
byID[item.ContentID] = item
}
}
result := make([]*models.MediaItem, 0, len(ordered))
for _, item := range ordered {
if item == nil {
continue
}
if original, ok := byID[item.ContentID]; ok {
result = append(result, original)
}
}
return result, nil
}
// collectionSortOverride loads the viewing profile's saved sort for a
// collection, or nil when there is none. A lookup failure is logged and treated
// as "no override": a browse must not fail because a preference row could not
// be read.
func (r *CatalogResolver) collectionSortOverride(
ctx context.Context,
access AccessFilter,
kind string,
collectionID string,
) *userstore.CollectionSortPreference {
if r.storeProvider == nil || access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil
}
store, err := r.storeProvider.ForUser(ctx, access.UserID)
if err != nil || store == nil {
if err != nil {
slog.WarnContext(ctx, "loading user store for collection sort preference",
"collection_kind", kind, "collection_id", collectionID, "error", err)
}
return nil
}
pref, err := store.GetCollectionSortPreference(ctx, access.ProfileID, kind, collectionID)
if err != nil {
slog.WarnContext(ctx, "loading collection sort preference",
"collection_kind", kind, "collection_id", collectionID, "error", err)
return nil
}
return pref
}
-289
View File
@@ -1,289 +0,0 @@
package catalog
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/userstore/pgstore"
)
// These tests run the default-sort / saved-override flow against a real
// Postgres: real SQL ordering through the query executor, and a real
// PostgresUserStore writing the override row. They cover the reported
// behavior end to end — a collection's creator picks a default order, a viewer
// re-sorts, and the viewer's choice survives a fresh read.
//
// Set SILO_TEST_DATABASE_URL to a migrated database to run them.
func collectionSortTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.user_collection_sort_preferences')::text`).Scan(&tableName); err != nil {
t.Fatalf("check user_collection_sort_preferences table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the collection sort preference migration")
}
return pool
}
// seedSortableItem inserts one sortable item. Callers choose title/year pairs
// that make a title sort and a year sort disagree, so an order assertion cannot
// pass by coincidence.
func seedSortableItem(t *testing.T, pool *pgxpool.Pool, contentID, title string, year int) {
t.Helper()
ctx := context.Background()
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, sort_title, year, genres)
VALUES ($1, 'movie', $2, $2, $3, '{}'::text[])
`, contentID, title, year); err != nil {
t.Fatalf("seed media item %s: %v", contentID, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id = $1`, contentID)
})
}
func seedSortTestUser(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
ctx := context.Background()
var userID int
// A bare fixture row: the override table has an FK to users(id) and the
// test needs something to point at. No credentials are set.
if err := pool.QueryRow(ctx, `
INSERT INTO users (username, email, password_hash, role)
VALUES ('collection-sort-fixture', 'collection-sort-fixture@invalid.test', '', 'user')
RETURNING id
`).Scan(&userID); err != nil {
t.Fatalf("seed fixture user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
return userID
}
func itemsForSort(contentIDs ...string) []*models.MediaItem {
items := make([]*models.MediaItem, 0, len(contentIDs))
for _, id := range contentIDs {
items = append(items, &models.MediaItem{ContentID: id})
}
return items
}
func orderedIDs(items []*models.MediaItem) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ContentID)
}
return ids
}
func assertOrder(t *testing.T, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("order = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
// TestCollectionDefaultSortAndOverrideEndToEnd walks the whole reported
// scenario against real Postgres, for both collection kinds.
func TestCollectionDefaultSortAndOverrideEndToEnd(t *testing.T) {
pool := collectionSortTestPool(t)
ctx := context.Background()
// Years are shuffled against titles on purpose: title-asc gives A,B,C but
// year-desc gives B,C,A. If the two agreed, an assertion on the override's
// order would also pass when the override was ignored entirely.
seedSortableItem(t, pool, "cs-a", "Alpha", 2001)
seedSortableItem(t, pool, "cs-b", "Bravo", 2003)
seedSortableItem(t, pool, "cs-c", "Charlie", 2002)
// Source order is deliberately neither sorted order.
sourceOrder := itemsForSort("cs-c", "cs-a", "cs-b")
limited, total, err := QueryCollectionItemsBySort(
ctx,
pool,
[]string{"cs-c", "cs-a", "cs-b"},
QuerySort{Field: "title", Order: "asc"},
AccessFilter{},
2,
"",
)
if err != nil {
t.Fatalf("querying limited sorted collection: %v", err)
}
assertOrder(t, orderedIDs(limited), "cs-a", "cs-b")
if total != 3 {
t.Fatalf("limited sorted collection total = %d, want 3", total)
}
userID := seedSortTestUser(t, pool)
provider := pgstore.NewPostgresProvider(pool)
store, err := provider.ForUser(ctx, userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
if err := store.CreateProfile(ctx, userstore.Profile{ID: "profile-e2e", Name: "E2E sort profile"}); err != nil {
t.Fatalf("seed profile: %v", err)
}
t.Cleanup(func() { _ = store.DeleteProfile(context.Background(), "profile-e2e") })
resolver := &CatalogResolver{storeProvider: provider}
access := AccessFilter{UserID: userID, ProfileID: "profile-e2e"}
for _, kind := range []string{userstore.CollectionKindLibrary, userstore.CollectionKindUser} {
t.Run(kind, func(t *testing.T) {
collectionID := "cs-collection-" + kind
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(ctx, access.ProfileID, kind, collectionID)
})
// 1. No default, no override — the collection keeps source order.
if _, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, []byte(`{}`)); ok {
t.Fatal("a collection with no default reported a sort")
}
// 2. The creator sets a default of Title (A→Z).
defaultSort := []byte(`{"field":"title","order":"asc"}`)
qs, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok {
t.Fatal("configured default sort was not applied")
}
ordered, err := OrderCollectionItemsBySort(ctx, pool, sourceOrder, qs, access)
if err != nil {
t.Fatalf("ordering by default sort: %v", err)
}
assertOrder(t, orderedIDs(ordered), "cs-a", "cs-b", "cs-c")
// 3. The viewer re-sorts to Year (newest first) and it is saved.
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: access.ProfileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: "year",
SortOrder: "desc",
}); err != nil {
t.Fatalf("saving override: %v", err)
}
// 4. A fresh resolution — the "leave and come back" case — reads the
// override back out of the database and beats the creator's default.
freshResolver := &CatalogResolver{storeProvider: pgstore.NewPostgresProvider(pool)}
qs, ok = freshResolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok {
t.Fatal("saved override was not applied on a fresh read")
}
if qs.Field != "year" || qs.Order != "desc" {
t.Fatalf("effective sort = %q/%q, want year/desc", qs.Field, qs.Order)
}
ordered, err = OrderCollectionItemsBySort(ctx, pool, sourceOrder, qs, access)
if err != nil {
t.Fatalf("ordering by override: %v", err)
}
// Distinct from both the default's A,B,C and source order's C,A,B.
assertOrder(t, orderedIDs(ordered), "cs-b", "cs-c", "cs-a")
assertOrder(t, orderedIDs(sourceOrder), "cs-c", "cs-a", "cs-b")
// 5. The viewer pins back to the collection's own order. That is a
// real choice and must suppress the creator's default, not fall
// through to it.
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: access.ProfileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: "",
SortOrder: "",
}); err != nil {
t.Fatalf("pinning to source order: %v", err)
}
if _, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort); ok {
t.Fatal("source-order override fell through to the creator's default")
}
// 6. Clearing the override returns the viewer to the default.
if err := store.ClearCollectionSortPreference(ctx, access.ProfileID, kind, collectionID); err != nil {
t.Fatalf("clearing override: %v", err)
}
qs, ok = resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok || qs.Field != "title" {
t.Fatalf("after clearing, effective sort = %q (ok=%v), want title", qs.Field, ok)
}
})
}
}
// TestCollectionSortOverrideIsPerProfile guards the isolation that makes a
// server-wide collection safe to re-sort: one profile's choice must not change
// what another profile sees.
func TestCollectionSortOverrideIsPerProfile(t *testing.T) {
pool := collectionSortTestPool(t)
ctx := context.Background()
userID := seedSortTestUser(t, pool)
provider := pgstore.NewPostgresProvider(pool)
store, err := provider.ForUser(ctx, userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
for _, profile := range []userstore.Profile{
{ID: "profile-one", Name: "Profile One"},
{ID: "profile-two", Name: "Profile Two"},
} {
if err := store.CreateProfile(ctx, profile); err != nil {
t.Fatalf("seed profile %s: %v", profile.ID, err)
}
}
t.Cleanup(func() {
_ = store.DeleteProfile(context.Background(), "profile-two")
_ = store.DeleteProfile(context.Background(), "profile-one")
})
resolver := &CatalogResolver{storeProvider: provider}
const collectionID = "cs-shared-collection"
defaultSort := []byte(`{"field":"title","order":"asc"}`)
kind := userstore.CollectionKindLibrary
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: "profile-one",
CollectionKind: kind,
CollectionID: collectionID,
SortField: "year",
SortOrder: "desc",
}); err != nil {
t.Fatalf("saving override: %v", err)
}
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(context.Background(), "profile-one", kind, collectionID)
})
one, ok := resolver.EffectiveCollectionSort(ctx, AccessFilter{UserID: userID, ProfileID: "profile-one"}, kind, collectionID, defaultSort)
if !ok || one.Field != "year" {
t.Fatalf("profile-one sort = %q (ok=%v), want year", one.Field, ok)
}
two, ok := resolver.EffectiveCollectionSort(ctx, AccessFilter{UserID: userID, ProfileID: "profile-two"}, kind, collectionID, defaultSort)
if !ok || two.Field != "title" {
t.Fatalf("profile-two sort = %q (ok=%v), want the collection default title", two.Field, ok)
}
}
@@ -1,189 +0,0 @@
package catalog
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// sortPrefStore is a UserStore that only answers collection-sort-preference
// lookups; every other method panics so a test that strays off this path fails
// loudly rather than silently exercising unrelated behavior.
type sortPrefStore struct {
userstore.UserStore
pref *userstore.CollectionSortPreference
err error
// calls records the lookups made, so tests can assert the store is not
// consulted when there is no user scope to consult it with.
calls int
}
func (s *sortPrefStore) GetCollectionSortPreference(_ context.Context, _, _, _ string) (*userstore.CollectionSortPreference, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.pref, nil
}
type sortPrefProvider struct {
store *sortPrefStore
err error
}
func (p *sortPrefProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
if p.err != nil {
return nil, p.err
}
return p.store, nil
}
func (p *sortPrefProvider) Close() error { return nil }
func TestEffectiveCollectionSortPrecedence(t *testing.T) {
const (
libraryDefault = `{"field":"added_at","order":"desc"}`
noDefault = `{}`
)
scopedAccess := AccessFilter{UserID: 7, ProfileID: "profile-1"}
cases := []struct {
name string
access AccessFilter
sortConfig string
pref *userstore.CollectionSortPreference
storeErr error
wantOK bool
wantField string
wantOrder string
wantCalls int
}{
{
name: "no override falls back to the collection default",
access: scopedAccess,
sortConfig: libraryDefault,
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
// The reported behavior: admin defaults to Date Added, the viewer
// switches to Title, and Title is what they get from then on.
name: "viewer override beats the collection default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "title", SortOrder: "asc"},
wantOK: true,
wantField: "title",
wantOrder: "asc",
wantCalls: 1,
},
{
// An empty stored field is a deliberate "put it back to the list's
// own order" and must not fall through to the creator's default.
name: "override pinned to source order suppresses the default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "", SortOrder: ""},
wantCalls: 1,
},
{
name: "no override and no default means source order",
access: scopedAccess,
sortConfig: noDefault,
wantCalls: 1,
},
{
name: "personalized viewer override is valid on a library collection",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "progress", SortOrder: "desc"},
wantOK: true,
wantField: "progress",
wantOrder: "desc",
wantCalls: 1,
},
{
name: "unusable override falls back to the default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "retired_sort", SortOrder: "desc"},
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
name: "store failure degrades to the default",
access: scopedAccess,
sortConfig: libraryDefault,
storeErr: errors.New("boom"),
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
// Unauthenticated / profile-less reads (jellycompat, public paths)
// have no override to look up and must not query for one.
name: "no user scope skips the override lookup",
access: AccessFilter{},
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "title", SortOrder: "asc"},
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := &sortPrefStore{pref: tc.pref, err: tc.storeErr}
resolver := &CatalogResolver{storeProvider: &sortPrefProvider{store: store}}
qs, ok := resolver.EffectiveCollectionSort(
context.Background(),
tc.access,
userstore.CollectionKindLibrary,
"collection-1",
[]byte(tc.sortConfig),
)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if store.calls != tc.wantCalls {
t.Fatalf("override lookups = %d, want %d", store.calls, tc.wantCalls)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestEffectiveCollectionSortAllowsPersonalizedOnUserCollections(t *testing.T) {
store := &sortPrefStore{pref: &userstore.CollectionSortPreference{SortField: "progress", SortOrder: "desc"}}
resolver := &CatalogResolver{storeProvider: &sortPrefProvider{store: store}}
qs, ok := resolver.EffectiveCollectionSort(
context.Background(),
AccessFilter{UserID: 7, ProfileID: "profile-1"},
userstore.CollectionKindUser,
"collection-1",
[]byte(`{}`),
)
if !ok {
t.Fatal("personalized override rejected on a personal collection")
}
if qs.Field != "progress" || qs.Order != "desc" {
t.Fatalf("got %q/%q, want progress/desc", qs.Field, qs.Order)
}
}
-105
View File
@@ -1,105 +0,0 @@
package catalog
import "testing"
func TestNormalizeCollectionSort(t *testing.T) {
cases := []struct {
name string
field string
order string
allowPersonalized bool
wantOK bool
wantField string
wantOrder string
}{
{name: "applies the field's default order", field: "release_date", wantOK: true, wantField: "release_date", wantOrder: "desc"},
{name: "title defaults ascending", field: "title", wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "explicit order wins", field: "title", order: "desc", wantOK: true, wantField: "title", wantOrder: "desc"},
{name: "trims and lowercases", field: " Title ", order: "ASC", wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "garbage order is rejected", field: "year", order: "sideways"},
{name: "empty field means source order", field: ""},
{name: "unknown field is rejected", field: "not_a_sort"},
{name: "personalized rejected for library collections", field: "progress"},
{
name: "personalized allowed for personal collections",
field: "progress",
allowPersonalized: true,
wantOK: true,
wantField: "progress",
wantOrder: "desc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
qs, ok := NormalizeCollectionSort(tc.field, tc.order, tc.allowPersonalized)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestParseCollectionDefaultSort(t *testing.T) {
cases := []struct {
name string
raw string
wantOK bool
wantField string
wantOrder string
}{
{name: "empty column", raw: ""},
{name: "legacy empty object", raw: `{}`},
{name: "json null", raw: `null`},
{name: "unparseable json", raw: `{`},
// The unimplemented manual-pin mode carries no field, so it must not be
// mistaken for a configured sort.
{name: "manual pins mode", raw: `{"mode":"manual_pins"}`},
{name: "configured sort", raw: `{"field":"title","order":"asc"}`, wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "order filled in", raw: `{"field":"added_at"}`, wantOK: true, wantField: "added_at", wantOrder: "desc"},
{name: "invalid order is rejected", raw: `{"field":"title","order":"sideways"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
qs, ok := ParseCollectionDefaultSort([]byte(tc.raw), false)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestEncodeCollectionDefaultSort(t *testing.T) {
encoded, err := EncodeCollectionDefaultSort(QuerySort{Field: "title", Order: "asc"}, true)
if err != nil {
t.Fatalf("encoding: %v", err)
}
if encoded != `{"field":"title","order":"asc"}` {
t.Fatalf("encoded = %s", encoded)
}
// Source order round-trips through the empty object the column defaults to.
encoded, err = EncodeCollectionDefaultSort(QuerySort{}, false)
if err != nil {
t.Fatalf("encoding source order: %v", err)
}
if encoded != "{}" {
t.Fatalf("source order encoded = %s, want {}", encoded)
}
if _, ok := ParseCollectionDefaultSort([]byte(encoded), false); ok {
t.Fatal("re-parsing the source-order encoding reported a configured sort")
}
}
+1 -1
View File
@@ -200,7 +200,7 @@ func (r *LibraryCollectionRepository) Create(ctx context.Context, input CreateLi
input.SortConfig = json.RawMessage(`{}`)
}
if input.Visibility == "" {
input.Visibility = LibraryCollectionVisibilityVisible
input.Visibility = "visible"
}
if input.CollectionType == "" {
input.CollectionType = "manual"
-65
View File
@@ -1,7 +1,6 @@
package catalog
import (
"bytes"
"encoding/json"
"fmt"
"sort"
@@ -214,70 +213,6 @@ func NormalizePersonalListSort(field, order string) (QuerySort, bool) {
return QuerySort{Field: field, Order: order}, true
}
// NormalizeCollectionSort validates a collection's default sort (its stored
// sort_config) or a viewer's saved override, applying the field's default order
// when none is given. Returns false when the field or a supplied order is
// unsupported. An empty field means the collection's own source order should
// be kept.
//
// allowPersonalized gates the per-profile sort fields (progress, date viewed,
// plays). They are legitimate for personal collections, which always resolve
// with the owning profile in scope, but not for library collections: those
// resolve with user scope stripped (see stripCatalogUserScope), so the query
// builder's ensureUserScope would reject the sort at browse time.
func NormalizeCollectionSort(field, order string, allowPersonalized bool) (QuerySort, bool) {
field = strings.ToLower(strings.TrimSpace(field))
def, ok := querySortDefs[field]
if !ok {
return QuerySort{}, false
}
if def.personalized && !allowPersonalized {
return QuerySort{}, false
}
order = strings.ToLower(strings.TrimSpace(order))
if order == "" {
order = def.defaultOrder
} else if order != "asc" && order != "desc" {
return QuerySort{}, false
}
return QuerySort{Field: field, Order: order}, true
}
// collectionSortConfig is the shape a collection's sort_config carries when it
// pins a default order. Everything else the column may hold (legacy `{}`, the
// unimplemented manual-pin mode) leaves the collection on source order.
type collectionSortConfig struct {
Field string `json:"field"`
Order string `json:"order"`
}
// ParseCollectionDefaultSort reads the default sort a collection's creator
// configured. An absent, empty, or unrecognized sort_config yields false,
// meaning source order.
func ParseCollectionDefaultSort(raw []byte, allowPersonalized bool) (QuerySort, bool) {
if len(bytes.TrimSpace(raw)) == 0 {
return QuerySort{}, false
}
var cfg collectionSortConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return QuerySort{}, false
}
return NormalizeCollectionSort(cfg.Field, cfg.Order, allowPersonalized)
}
// EncodeCollectionDefaultSort renders a validated default sort back into the
// sort_config JSON shape. An empty field encodes as `{}` — source order.
func EncodeCollectionDefaultSort(qs QuerySort, ok bool) (string, error) {
if !ok || strings.TrimSpace(qs.Field) == "" {
return "{}", nil
}
encoded, err := json.Marshal(collectionSortConfig(qs))
if err != nil {
return "", fmt.Errorf("encoding collection sort_config: %w", err)
}
return string(encoded), nil
}
func (q QueryDefinition) Normalize() QueryDefinition {
normalized := q
normalized.MediaScope = strings.ToLower(strings.TrimSpace(normalized.MediaScope))
+1 -13
View File
@@ -259,10 +259,6 @@ var attrRegistry = map[string]map[string]attrValueType{
"bitrate_kbps": attrInteger,
"dropped_frames": attrInteger,
"audio_underruns": attrInteger,
"session_id": attrString,
"play_method": attrString,
"reason": attrString,
"position_ms": attrInteger,
},
"focus": {
"target": attrString,
@@ -273,17 +269,9 @@ var attrRegistry = map[string]map[string]attrValueType{
"path": attrString,
"status": attrInteger,
"duration_ms": attrInteger,
"outcome": attrString,
"error_code": attrString,
"attempt": attrInteger,
},
"lifecycle": {
"state": attrString,
"phase": attrString,
"duration_ms": attrInteger,
"outcome": attrString,
"reason": attrString,
"launch_type": attrString,
"state": attrString,
},
"crash": {
"fingerprint": attrString,
-50
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strings"
"time"
)
type matcherRepository interface {
@@ -12,7 +11,6 @@ type matcherRepository interface {
MatchMediaByTitleYear(ctx context.Context, kind, title string, year int) ([]mediaLookupRow, error)
MatchEpisodeByExternalID(ctx context.Context, column, value string) ([]mediaLookupRow, error)
MatchEpisodeBySeries(ctx context.Context, seriesID string, seasonNumber, episodeNumber int) (*Match, error)
MatchEpisodesBySeries(ctx context.Context, seriesID string, seasonNumber *int, watchedAt *time.Time) ([]Match, error)
}
type Matcher struct {
@@ -36,54 +34,6 @@ func (m *Matcher) Match(ctx context.Context, record Record) (*Match, string, err
}
}
// MatchLeaves resolves a provider watched marker to concrete playable items.
// Movies and episodes have one leaf. Whole-show and whole-season markers must
// expand to the local episodes that existed when the provider marker was
// recorded; Silo derives series/season completion from those episode leaves.
func (m *Matcher) MatchLeaves(ctx context.Context, record Record) ([]Match, string, error) {
switch record.Kind {
case KindMovie, KindEpisode:
match, reason, err := m.Match(ctx, record)
if err != nil || match == nil {
return nil, reason, err
}
return []Match{*match}, "", nil
case KindSeries, KindSeason:
seriesRecord := record
var seasonNumber *int
if record.Kind == KindSeason {
seriesRecord = Record{
Kind: KindSeries,
Title: record.SeriesTitle,
Year: record.SeriesYear,
IMDbID: record.SeriesIMDbID,
TMDBID: record.SeriesTMDBID,
TVDBID: record.SeriesTVDBID,
}
season := record.SeasonNumber
seasonNumber = &season
}
seriesRecord.Kind = KindSeries
series, reason, err := m.matchSeries(ctx, seriesRecord)
if err != nil || series == nil {
return nil, reason, err
}
matches, err := m.repo.MatchEpisodesBySeries(ctx, series.MediaItemID, seasonNumber, record.LastPlayedAt)
if err != nil {
return nil, "", err
}
if len(matches) == 0 {
if seasonNumber != nil {
return nil, fmt.Sprintf("no available episodes matched for season %d", *seasonNumber), nil
}
return nil, "no available episodes matched for series", nil
}
return matches, "", nil
default:
return nil, "unsupported item kind", nil
}
}
func (m *Matcher) matchMovie(ctx context.Context, record Record) (*Match, string, error) {
match, reason, err := m.matchMedia(ctx, KindMovie, record.TMDBID, record.IMDbID, "", record.Title, record.Year, false)
if err != nil {
-69
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"testing"
"time"
)
type matcherRepoStub struct {
@@ -12,15 +11,11 @@ type matcherRepoStub struct {
mediaByExternal map[string][]mediaLookupRow
mediaByTitleYear map[string][]mediaLookupRow
episodeBySeries *Match
episodesBySeries []Match
episodeBySeriesCalls int
episodeBySeriesID string
episodeBySeriesSeason int
episodeBySeriesEpisode int
episodesBySeriesID string
episodesBySeriesSeason *int
episodesBySeriesAt *time.Time
mediaByExternalCalls int
}
@@ -45,70 +40,6 @@ func (s *matcherRepoStub) MatchEpisodeBySeries(_ context.Context, seriesID strin
return s.episodeBySeries, nil
}
func (s *matcherRepoStub) MatchEpisodesBySeries(_ context.Context, seriesID string, seasonNumber *int, watchedAt *time.Time) ([]Match, error) {
s.episodesBySeriesID = seriesID
if seasonNumber != nil {
season := *seasonNumber
s.episodesBySeriesSeason = &season
}
if watchedAt != nil {
at := *watchedAt
s.episodesBySeriesAt = &at
}
return s.episodesBySeries, nil
}
func TestMatcherMatchLeavesExpandsWholeShowToEpisodes(t *testing.T) {
t.Parallel()
watchedAt := time.Date(2025, time.October, 15, 20, 0, 0, 0, time.UTC)
repo := &matcherRepoStub{
mediaByExternal: map[string][]mediaLookupRow{
"series:tmdb_id:1396": {{ContentID: "series-1", Title: "Breaking Bad", Year: 2008}},
},
episodesBySeries: []Match{
{MediaItemID: "episode-1", Kind: KindEpisode},
{MediaItemID: "episode-2", Kind: KindEpisode},
},
}
matches, reason, err := NewMatcher(repo).MatchLeaves(context.Background(), Record{
Kind: KindSeries, Title: "Breaking Bad", Year: 2008, TMDBID: "1396", LastPlayedAt: &watchedAt,
})
if err != nil || reason != "" {
t.Fatalf("MatchLeaves error = %v, reason = %q", err, reason)
}
if len(matches) != 2 || matches[0].MediaItemID != "episode-1" || matches[1].MediaItemID != "episode-2" {
t.Fatalf("matches = %#v", matches)
}
if repo.episodesBySeriesID != "series-1" || repo.episodesBySeriesSeason != nil ||
repo.episodesBySeriesAt == nil || !repo.episodesBySeriesAt.Equal(watchedAt) {
t.Fatalf("unexpected expansion scope: id=%q season=%v watched_at=%v",
repo.episodesBySeriesID, repo.episodesBySeriesSeason, repo.episodesBySeriesAt)
}
}
func TestMatcherMatchLeavesPreservesSpecialsSeasonZero(t *testing.T) {
t.Parallel()
repo := &matcherRepoStub{
mediaByExternal: map[string][]mediaLookupRow{
"series:tvdb_id:75978": {{ContentID: "series-1", Title: "Family Guy", Year: 1999}},
},
episodesBySeries: []Match{{MediaItemID: "special-1", Kind: KindEpisode}},
}
matches, reason, err := NewMatcher(repo).MatchLeaves(context.Background(), Record{
Kind: KindSeason, SeriesTitle: "Family Guy", SeriesYear: 1999,
SeriesTVDBID: "75978", SeasonNumber: 0,
})
if err != nil || reason != "" || len(matches) != 1 {
t.Fatalf("matches = %#v, reason = %q, err = %v", matches, reason, err)
}
if repo.episodesBySeriesSeason == nil || *repo.episodesBySeriesSeason != 0 {
t.Fatalf("season scope = %v, want explicit season 0", repo.episodesBySeriesSeason)
}
}
func TestMatcherMatchEpisode_UsesExternalIDsWithoutSeasonEpisodeNumbers(t *testing.T) {
t.Parallel()
-39
View File
@@ -1050,45 +1050,6 @@ func (r *Repository) MatchEpisodeBySeries(ctx context.Context, seriesID string,
return &match, nil
}
func (r *Repository) MatchEpisodesBySeries(
ctx context.Context,
seriesID string,
seasonNumber *int,
watchedAt *time.Time,
) ([]Match, error) {
rows, err := r.pool.Query(ctx, `
SELECT e.content_id, COALESCE(e.title, ''), COALESCE(series.year, 0)
FROM episodes e
LEFT JOIN media_items series ON series.content_id = e.series_id
WHERE e.series_id = $1
AND ($2::integer IS NULL OR e.season_number = $2)
AND ($3::timestamptz IS NULL OR e.air_date IS NULL OR e.air_date <= ($3::timestamptz AT TIME ZONE 'UTC')::date)
AND EXISTS (
SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id
)
ORDER BY e.season_number, e.episode_number, e.content_id`,
seriesID, seasonNumber, watchedAt,
)
if err != nil {
return nil, fmt.Errorf("matching episodes by series: %w", err)
}
defer rows.Close()
matches := make([]Match, 0)
for rows.Next() {
var match Match
if err := rows.Scan(&match.MediaItemID, &match.Title, &match.Year); err != nil {
return nil, fmt.Errorf("scanning series episode match: %w", err)
}
match.Kind = KindEpisode
matches = append(matches, match)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating series episode matches: %w", err)
}
return matches, nil
}
func scanSource(scanner interface{ Scan(dest ...any) error }) (*Source, error) {
var source Source
if err := scanner.Scan(
-1
View File
@@ -21,7 +21,6 @@ const (
KindMovie = "movie"
KindSeries = "series"
KindSeason = "season"
KindEpisode = "episode"
)
@@ -526,15 +526,6 @@ func (s *progressCountingStore) GetSeriesPlaybackPreference(context.Context, str
func (s *progressCountingStore) DeleteSeriesPlaybackPreference(context.Context, string, string) error {
panic("unused")
}
func (s *progressCountingStore) SetCollectionSortPreference(context.Context, userstore.CollectionSortPreference) error {
panic("unused")
}
func (s *progressCountingStore) GetCollectionSortPreference(context.Context, string, string, string) (*userstore.CollectionSortPreference, error) {
panic("unused")
}
func (s *progressCountingStore) ClearCollectionSortPreference(context.Context, string, string, string) error {
panic("unused")
}
func (s *progressCountingStore) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
panic("unused")
}
+9 -42
View File
@@ -193,27 +193,24 @@ func (p *Planner) PlanSessionWith(sessionID, currentTranscodeURL string, needsTr
}
proxies := p.proxies.Nodes()
transcodes := p.transcodes.Nodes()
// Group health is computed over the full pool before any narrowing:
// eligibility restricts what may be selected, not co-location semantics.
groupHealthy := groupHealth(proxies, transcodes)
if eligible != nil {
filtered := make([]*Node, 0, len(transcodes))
for _, node := range transcodes {
if eligible(node) {
filtered = append(filtered, node)
}
}
transcodes = filtered
}
var plan Plan
if needsTranscode {
if eligible != nil {
transcodes = filterNodes(transcodes, eligible)
}
plan.TranscodeNode = p.pickTranscode(transcodes, proxies, groupHealthy, currentTranscodeURL, estBitrateKbps, now)
if plan.TranscodeNode != nil {
plan.ProxyNode = p.pickProxy(proxies, groupHealthy, plan.TranscodeNode.Group, estBitrateKbps, now)
}
} else {
// A proxy-only plan has no transcode node, so the predicate applies to
// the proxy: it is the node that will execute the recipe. Filtering
// before selection means a capability mismatch skips to a capable
// sibling instead of abandoning the pool after one round-robin pick.
if eligible != nil {
proxies = filterNodes(proxies, eligible)
}
plan.ProxyNode = p.pickProxy(proxies, groupHealthy, nil, estBitrateKbps, now)
}
@@ -231,36 +228,6 @@ func (p *Planner) PlanSessionWith(sessionID, currentTranscodeURL string, needsTr
return plan
}
// filterNodes returns the nodes accepted by keep, preserving pool order so
// round-robin cursors stay meaningful across selections.
func filterNodes(nodes []*Node, keep func(*Node) bool) []*Node {
filtered := make([]*Node, 0, len(nodes))
for _, node := range nodes {
if keep(node) {
filtered = append(filtered, node)
}
}
return filtered
}
// ProxyNodeURLs lists the URLs of every enabled pooled proxy node, healthy or
// not, mirroring TranscodeNodeURLs. Capability planning wants the deployment's
// toolchain; an unreachable node excludes itself when its capability fetch
// fails.
func (p *Planner) ProxyNodeURLs() []string {
if p == nil || p.proxies == nil {
return nil
}
nodes := p.proxies.Nodes()
urls := make([]string, 0, len(nodes))
for _, node := range nodes {
if node != nil && node.URL != "" {
urls = append(urls, node.URL)
}
}
return urls
}
// TranscodeNodeURLs lists the URLs of every enabled pooled transcode node,
// healthy or not: capability planning wants the deployment's toolchain, and
// an unreachable node excludes itself when its capability fetch fails. An
-56
View File
@@ -1,7 +1,6 @@
package nodepool
import (
"strconv"
"testing"
"time"
)
@@ -616,58 +615,3 @@ func TestPlanDownloadFallsBackWhenOriginGroupHasNoProxy(t *testing.T) {
t.Fatalf("plan = %+v, want proxy-a fallback", plan)
}
}
// A proxy-only plan applies the eligibility predicate to the proxy: it is the
// node that executes the recipe. Without this, one incapable round-robin pick
// would abandon a pool that still holds a capable sibling.
func TestPlanSessionWithFiltersProxiesForProxyOnlyPlans(t *testing.T) {
proxies := NewProxyPool()
proxies.SetNodes([]*Node{
{ID: 1, URL: "http://proxy-old", Enabled: true, Healthy: true},
{ID: 2, URL: "http://proxy-new", Enabled: true, Healthy: true},
})
planner := NewPlanner(proxies, NewTranscodePool())
// Only the upgraded proxy can run the recipe; every selection must land
// there regardless of where the round-robin cursor happens to be.
for i := 0; i < 4; i++ {
plan := planner.PlanSessionWith("session-"+strconv.Itoa(i), "", false, 0, func(n *Node) bool {
return n.URL == "http://proxy-new"
})
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-new" {
t.Fatalf("selection %d = %#v, want the capable proxy", i, plan.ProxyNode)
}
}
}
func TestPlanSessionWithReturnsNoProxyWhenNoneAreCapable(t *testing.T) {
proxies := NewProxyPool()
proxies.SetNodes([]*Node{{ID: 1, URL: "http://proxy-old", Enabled: true, Healthy: true}})
planner := NewPlanner(proxies, NewTranscodePool())
plan := planner.PlanSessionWith("session-none", "", false, 0, func(*Node) bool { return false })
if plan.ProxyNode != nil {
t.Fatalf("proxy = %#v, want none when the pool cannot execute the recipe", plan.ProxyNode)
}
// An empty plan must not leave a reservation pinning capacity.
if _, reserved := planner.reserved["session-none"]; reserved {
t.Fatal("an unsatisfiable plan left a reservation behind")
}
}
func TestProxyNodeURLsListsEnabledProxies(t *testing.T) {
proxies := NewProxyPool()
proxies.SetNodes([]*Node{
{ID: 1, URL: "http://proxy-1", Enabled: true, Healthy: true},
{ID: 2, URL: "http://proxy-2", Enabled: true, Healthy: false},
})
planner := NewPlanner(proxies, NewTranscodePool())
// Unhealthy nodes are still listed: capability planning wants the
// deployment's toolchain, and an unreachable node excludes itself when its
// capability fetch fails.
urls := planner.ProxyNodeURLs()
if len(urls) != 2 {
t.Fatalf("proxy urls = %v, want both pooled proxies", urls)
}
}
+2 -133
View File
@@ -39,28 +39,12 @@ func (p *interestTrackingProvider) ForUser(ctx context.Context, userID int) (use
return store, err
}
tracked := &interestTrackingStore{UserStore: store, userID: userID, system: p.system, updater: p.system.Interest}
// Preserve the interface upgrades callers probe for. Both are conditional
// on the backing store: advertising a capability it does not have would
// send callers down a fast path that can only fail.
registry, hasDevices := store.(userstore.DeviceRegistry)
rollup, hasRollup := store.(userstore.SeriesEpisodeRollupStore)
switch {
case hasDevices && hasRollup:
return &interestTrackingStoreWithDevicesAndRollup{
interestTrackingStore: tracked,
DeviceRegistry: registry,
SeriesEpisodeRollupStore: rollup,
}, nil
case hasDevices:
// Preserve the DeviceRegistry interface upgrade some callers probe for.
if registry, ok := store.(userstore.DeviceRegistry); ok {
return &interestTrackingStoreWithDevices{
interestTrackingStore: tracked,
DeviceRegistry: registry,
}, nil
case hasRollup:
return &interestTrackingStoreWithRollup{
interestTrackingStore: tracked,
SeriesEpisodeRollupStore: rollup,
}, nil
}
return tracked, nil
}
@@ -81,48 +65,11 @@ type interestTrackingStoreWithDevices struct {
userstore.DeviceRegistry
}
// interestTrackingStoreWithRollup adds the series-rollup capability only when
// the backing store actually has it. Unlike the other capabilities, this one
// cannot be forwarded unconditionally: the per-user SQLite backend has no
// catalog tables and genuinely cannot answer the query, and callers treat
// "implements the interface" as "can do this". Advertising it anyway would
// send every jellycompat series request down the fast path to fail, logging a
// warning each time before falling back — turning an expected capability
// absence into recurring noise on successful requests.
type interestTrackingStoreWithRollup struct {
*interestTrackingStore
userstore.SeriesEpisodeRollupStore
}
type interestTrackingStoreWithDevicesAndRollup struct {
*interestTrackingStore
userstore.DeviceRegistry
userstore.SeriesEpisodeRollupStore
}
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStore)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStore)(nil)
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStoreWithDevices)(nil)
// Optional store capabilities must survive the decorator. Embedding the
// UserStore interface promotes only that interface's methods, so each of these
// needs an explicit forward below; the assertions make a missing one a compile
// error instead of a silent production slowdown.
//
// SeriesEpisodeRollupStore is deliberately absent here: it is conditional on
// the backing store, so it lives on the wrapper types above rather than being
// forwarded unconditionally.
var _ userstore.WatchedBatchWriter = (*interestTrackingStore)(nil)
var _ userstore.VisibleHistoryAdder = (*interestTrackingStore)(nil)
var _ userstore.HistoryVisibilityStore = (*interestTrackingStore)(nil)
var _ userstore.WatchedBatchWriter = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.VisibleHistoryAdder = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.HistoryVisibilityStore = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.SeriesEpisodeRollupStore = (*interestTrackingStoreWithRollup)(nil)
var _ userstore.SeriesEpisodeRollupStore = (*interestTrackingStoreWithDevicesAndRollup)(nil)
var _ userstore.DeviceRegistry = (*interestTrackingStoreWithDevicesAndRollup)(nil)
// WithPreferenceSettingsTransaction preserves the optional atomic-settings
// capability of the wrapped store. Preference writes do not affect interest
// signals, so the transaction can pass through unchanged; keeping the method
@@ -351,84 +298,6 @@ func (s *interestTrackingStore) RemoveHistoryItems(ctx context.Context, profileI
return err
}
// --- Optional store capabilities.
//
// This decorator embeds the userstore.UserStore *interface*, which promotes
// only the methods declared on that interface. Any optional capability the
// backing store implements is invisible through the wrapper unless it is
// forwarded explicitly here — and because callers reach these via type
// assertion with a working fallback, a missing forward is silent: no error, no
// test failure, just a much slower path in production. Marking a series
// watched regressed exactly this way. When adding a new optional capability to
// userstore, forward it here and extend the compile-time assertions below.
// MarkWatchedBatch forwards the transactional batch mark-watched write, whose
// whole purpose is that a large series commits as one unit. Falling through to
// the per-target loop would restore the partial-write window it removes.
func (s *interestTrackingStore) MarkWatchedBatch(
ctx context.Context,
profileID string,
targets []userstore.MarkWatchedTarget,
entries []userstore.WatchHistoryEntry,
) ([]userstore.WatchHistoryEntry, error) {
writer, ok := s.UserStore.(userstore.WatchedBatchWriter)
if !ok {
// The helper runs against the inner store, so this decorator's own
// MarkWatched hook never fires; queue here or the recompute is lost.
// A mid-loop error still leaves earlier targets written, so queue
// regardless of err — a redundant queue only costs one recompute,
// while a missing one leaves profile_series_interest stale until some
// unrelated mutation or the rebuild task touches the series.
written, err := userstore.MarkWatchedBatch(ctx, s.UserStore, profileID, targets, entries)
s.queueTargetMutations(profileID, targets)
return written, err
}
written, err := writer.MarkWatchedBatch(ctx, profileID, targets, entries)
// The batch write is one transaction: on error nothing landed, so there is
// nothing to recompute.
if err == nil {
s.queueItemMutations(profileID, written)
}
return written, err
}
// queueItemMutations records one interest mutation per written entry, matching
// what the per-target path queues through MarkWatched.
func (s *interestTrackingStore) queueItemMutations(profileID string, entries []userstore.WatchHistoryEntry) {
for _, entry := range entries {
s.updater.QueueItemMutation(s.userID, profileID, entry.MediaItemID)
}
}
// queueTargetMutations queues by requested target rather than written entry,
// for the non-transactional path where a partial write may have occurred.
func (s *interestTrackingStore) queueTargetMutations(profileID string, targets []userstore.MarkWatchedTarget) {
for _, target := range targets {
s.updater.QueueItemMutation(s.userID, profileID, target.MediaItemID)
}
}
// AddVisibleHistory forwards the watermark-aware history insert. The generic
// fallback needs two round-trips (timestamp lookup, then insert) to do the
// same job.
func (s *interestTrackingStore) AddVisibleHistory(ctx context.Context, entry userstore.WatchHistoryEntry) (userstore.WatchHistoryEntry, error) {
adder, ok := s.UserStore.(userstore.VisibleHistoryAdder)
if !ok {
return userstore.AddVisibleHistory(ctx, s.UserStore, entry)
}
return adder.AddVisibleHistory(ctx, entry)
}
// VisibleHistoryTimestamps forwards the batched watermark lookup; without it
// callers assume a single wall-clock timestamp for every item.
func (s *interestTrackingStore) VisibleHistoryTimestamps(ctx context.Context, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
visibility, ok := s.UserStore.(userstore.HistoryVisibilityStore)
if !ok {
return userstore.VisibleHistoryTimestamps(ctx, s.UserStore, profileID, mediaItemIDs, at)
}
return visibility.VisibleHistoryTimestamps(ctx, profileID, mediaItemIDs, at)
}
func (s *interestTrackingStore) DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source userstore.WatchHistorySource) error {
err := s.UserStore.DeleteHistoryBySource(ctx, profileID, mediaItemIDs, source)
if err == nil {
@@ -100,231 +100,3 @@ func TestInterestTrackingStorePreservesSettingCapabilities(t *testing.T) {
t.Fatalf("wrapped receipt = %+v (%v)", receipt, err)
}
}
// TestInterestTrackingStorePreservesWatchStateCapabilities pins the optional
// watch-state capabilities through the wrapper. These are type-asserted at
// their call sites (userstore.MarkWatchedBatch, AddVisibleHistory,
// VisibleHistoryTimestamps, jellycompat's rollup), and the decorator embeds
// the UserStore *interface* — which promotes only the methods in that
// interface. A capability the decorator does not forward explicitly is
// therefore invisible in production, and the caller silently degrades to its
// slow per-item fallback with no error and no test failure.
func TestInterestTrackingStorePreservesWatchStateCapabilities(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
inner := userdb.NewSQLiteUserStore(db)
provider := WrapUserStoreProvider(
preferenceTransactionTestProvider{store: inner},
&System{},
)
wrapped, err := provider.ForUser(context.Background(), 1)
if err != nil {
t.Fatalf("ForUser: %v", err)
}
// Every capability the bare store advertises must survive wrapping,
// otherwise the wrapped store is a silent performance downgrade.
for _, capability := range []struct {
name string
has func(userstore.UserStore) bool
}{
{"WatchedBatchWriter", func(s userstore.UserStore) bool {
_, ok := s.(userstore.WatchedBatchWriter)
return ok
}},
{"VisibleHistoryAdder", func(s userstore.UserStore) bool {
_, ok := s.(userstore.VisibleHistoryAdder)
return ok
}},
{"HistoryVisibilityStore", func(s userstore.UserStore) bool {
_, ok := s.(userstore.HistoryVisibilityStore)
return ok
}},
} {
if !capability.has(inner) {
t.Fatalf("test setup: bare store does not implement %s", capability.name)
}
if !capability.has(wrapped) {
t.Errorf("interest-tracking wrapper dropped %s; callers silently fall back to the per-item path",
capability.name)
}
}
// SeriesEpisodeRollupStore is the exception: it must be advertised only
// when the backing store can actually answer it. The per-user SQLite store
// has no catalog tables, so claiming the capability would send every
// jellycompat series request down a fast path that can only fail and log.
if _, ok := userstore.UserStore(inner).(userstore.SeriesEpisodeRollupStore); ok {
t.Fatal("test setup: SQLite store unexpectedly implements SeriesEpisodeRollupStore")
}
if _, ok := wrapped.(userstore.SeriesEpisodeRollupStore); ok {
t.Error("wrapper advertises SeriesEpisodeRollupStore for a store that cannot perform the rollup")
}
// The forwarded batch write must actually reach the backing store.
writer, ok := wrapped.(userstore.WatchedBatchWriter)
if !ok {
t.Fatal("wrapper dropped WatchedBatchWriter; cannot verify pass-through")
}
if err := wrapped.CreateProfile(context.Background(), userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
watchedAt := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC).Format(time.RFC3339)
written, err := writer.MarkWatchedBatch(context.Background(), "p1",
[]userstore.MarkWatchedTarget{
{MediaItemID: "ep-1", DurationSeconds: 1200},
{MediaItemID: "ep-2", DurationSeconds: 1500},
},
[]userstore.WatchHistoryEntry{
{ProfileID: "p1", MediaItemID: "ep-1", WatchedAt: watchedAt, DurationSeconds: 1200, Completed: true, Source: userstore.WatchHistorySourceManual},
{ProfileID: "p1", MediaItemID: "ep-2", WatchedAt: watchedAt, DurationSeconds: 1500, Completed: true, Source: userstore.WatchHistorySourceManual},
},
)
if err != nil {
t.Fatalf("MarkWatchedBatch through wrapper: %v", err)
}
if len(written) != 2 {
t.Fatalf("MarkWatchedBatch returned %d entries, want 2", len(written))
}
for _, id := range []string{"ep-1", "ep-2"} {
progress, err := wrapped.GetProgress(context.Background(), "p1", id)
if err != nil || progress == nil || !progress.Completed {
t.Fatalf("GetProgress(%s) = %+v (%v), want completed", id, progress, err)
}
}
}
// storeWithoutBatchWriter wraps a UserStore and hides the WatchedBatchWriter
// capability, standing in for a backend that has not implemented it.
type storeWithoutBatchWriter struct {
userstore.UserStore
}
// TestInterestTrackingStoreQueuesMutationsOnBatchFallback covers the branch
// taken when the backing store lacks WatchedBatchWriter. The decorator hands
// the work to the generic helper against s.UserStore — the *inner* store — so
// the decorator's own MarkWatched hook never runs and nothing queues an
// interest recompute. Without this, marking a series watched on such a backend
// updates progress and history but leaves profile_series_interest stale until
// some unrelated mutation or the rebuild task happens to touch the series.
func TestInterestTrackingStoreQueuesMutationsOnBatchFallback(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
inner := storeWithoutBatchWriter{UserStore: userdb.NewSQLiteUserStore(db)}
if _, ok := userstore.UserStore(inner).(userstore.WatchedBatchWriter); ok {
t.Fatal("test setup: inner store must not implement WatchedBatchWriter")
}
updater := &InterestUpdater{pending: map[interestMutation]int{}}
store := &interestTrackingStore{
UserStore: inner,
userID: 1,
system: &System{},
updater: updater,
}
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
watchedAt := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC).Format(time.RFC3339)
if _, err := store.MarkWatchedBatch(context.Background(), "p1",
[]userstore.MarkWatchedTarget{
{MediaItemID: "ep-1", DurationSeconds: 1200},
{MediaItemID: "ep-2", DurationSeconds: 1500},
},
[]userstore.WatchHistoryEntry{
{ProfileID: "p1", MediaItemID: "ep-1", WatchedAt: watchedAt, DurationSeconds: 1200, Completed: true, Source: userstore.WatchHistorySourceManual},
{ProfileID: "p1", MediaItemID: "ep-2", WatchedAt: watchedAt, DurationSeconds: 1500, Completed: true, Source: userstore.WatchHistorySourceManual},
},
); err != nil {
t.Fatalf("MarkWatchedBatch (fallback path): %v", err)
}
updater.mu.Lock()
queued := make(map[string]struct{}, len(updater.pending))
for mutation := range updater.pending {
queued[mutation.itemID] = struct{}{}
}
updater.mu.Unlock()
for _, itemID := range []string{"ep-1", "ep-2"} {
if _, ok := queued[itemID]; !ok {
t.Errorf("no interest mutation queued for %s on the batch fallback path; interest goes stale", itemID)
}
}
}
// rollupCapableStore is a UserStore that also implements the series rollup,
// standing in for the Postgres backend.
type rollupCapableStore struct {
userstore.UserStore
called bool
}
func (s *rollupCapableStore) SeriesEpisodeWatchCounts(_ context.Context, _ string, seriesIDs []string) (map[string]userstore.SeriesWatchCounts, error) {
s.called = true
counts := make(map[string]userstore.SeriesWatchCounts, len(seriesIDs))
for _, seriesID := range seriesIDs {
counts[seriesID] = userstore.SeriesWatchCounts{TotalEpisodes: 3, WatchedCount: 2}
}
return counts, nil
}
// TestInterestTrackingStoreForwardsRollupWhenSupported is the other half of the
// conditional: a backend that can do the rollup must keep advertising it
// through the wrapper, and calls must reach it. Losing this would silently
// push jellycompat back onto the per-episode rollup for every series.
func TestInterestTrackingStoreForwardsRollupWhenSupported(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
inner := &rollupCapableStore{UserStore: userdb.NewSQLiteUserStore(db)}
provider := WrapUserStoreProvider(preferenceTransactionTestProvider{store: inner}, &System{})
wrapped, err := provider.ForUser(context.Background(), 1)
if err != nil {
t.Fatalf("ForUser: %v", err)
}
rollup, ok := wrapped.(userstore.SeriesEpisodeRollupStore)
if !ok {
t.Fatal("wrapper dropped SeriesEpisodeRollupStore for a store that supports it")
}
counts, err := rollup.SeriesEpisodeWatchCounts(context.Background(), "p1", []string{"series-1"})
if err != nil {
t.Fatalf("SeriesEpisodeWatchCounts: %v", err)
}
if !inner.called {
t.Error("rollup call did not reach the backing store")
}
if counts["series-1"].WatchedCount != 2 {
t.Errorf("counts = %+v, want WatchedCount 2", counts["series-1"])
}
// The other capabilities must still survive alongside the rollup.
if _, ok := wrapped.(userstore.WatchedBatchWriter); !ok {
t.Error("rollup-capable wrapper dropped WatchedBatchWriter")
}
if _, ok := wrapped.(userstore.SettingValueCompareAndSetter); !ok {
t.Error("rollup-capable wrapper dropped SettingValueCompareAndSetter")
}
}
+23 -71
View File
@@ -2,6 +2,7 @@ package notifications
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
@@ -9,6 +10,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -125,34 +127,18 @@ func (u *InterestUpdater) flush(ctx context.Context) {
profileID string
seriesID string
}
// Resolve every queued item to its series in one query. Marking or
// unmarking a whole series queues one mutation per episode, and those
// thousands of items collapse to a single series — resolving them
// one-at-a-time cost thousands of round-trips before the dedupe below
// could discard them.
itemIDs := make([]string, 0, len(batch))
for mutation := range batch {
itemIDs = append(itemIDs, mutation.itemID)
}
seriesByItem, resolveErr := u.resolveSeriesIDs(ctx, itemIDs)
if resolveErr != nil {
u.logger.WarnContext(ctx, "interest series resolution failed",
"item_count", len(itemIDs), "error", resolveErr)
}
seen := make(map[recomputeKey]struct{}, len(batch))
for mutation, failures := range batch {
if ctx.Err() != nil {
return // shutting down; the rebuild task repairs anything dropped
}
if resolveErr != nil {
// The lookup failed for the whole batch, so no item can be
// classified as "not a series". Requeue rather than silently
// dropping interest updates.
seriesID, ok, err := u.resolveSeriesID(ctx, mutation.itemID)
if err != nil {
u.logger.WarnContext(ctx, "interest series resolution failed",
"item_id", mutation.itemID, "error", err)
requeue[mutation] = failures + 1
continue
}
seriesID, ok := seriesByItem[mutation.itemID]
if !ok {
continue // movies and unknown items have no series interest
}
@@ -163,7 +149,7 @@ func (u *InterestUpdater) flush(ctx context.Context) {
seen[key] = struct{}{}
recomputeCtx, cancel := context.WithTimeout(ctx, interestRecomputeTime)
err := u.RecomputeSeries(recomputeCtx, mutation.userID, mutation.profileID, seriesID)
err = u.RecomputeSeries(recomputeCtx, mutation.userID, mutation.profileID, seriesID)
cancel()
if err != nil {
u.logger.WarnContext(ctx, "interest recompute failed",
@@ -192,60 +178,26 @@ func (u *InterestUpdater) flush(ctx context.Context) {
u.mu.Unlock()
}
// resolveSeriesIDs maps many media item IDs (series, season, or episode) to
// their series content IDs in one query. Items with no series — movies and
// unknown IDs — are absent from the result, which is what lets the caller keep
// treating "missing" as "no series interest". Semantics match resolveSeriesID
// per item; only the number of round-trips differs.
func (u *InterestUpdater) resolveSeriesIDs(ctx context.Context, itemIDs []string) (map[string]string, error) {
result := make(map[string]string, len(itemIDs))
ids := make([]string, 0, len(itemIDs))
seen := make(map[string]struct{}, len(itemIDs))
for _, itemID := range itemIDs {
if itemID == "" {
continue
}
if _, dup := seen[itemID]; dup {
continue
}
seen[itemID] = struct{}{}
ids = append(ids, itemID)
}
if len(ids) == 0 {
return result, nil
}
// One branch per item kind, unioned, mirroring the single-item lookup.
rows, err := u.pool.Query(ctx, `
SELECT content_id, series_id FROM episodes WHERE content_id = ANY($1::text[])
// resolveSeriesID maps a media item ID (series, season, or episode) to its
// series content ID. Returns ok=false for movies and unknown items.
func (u *InterestUpdater) resolveSeriesID(ctx context.Context, itemID string) (string, bool, error) {
var seriesID string
err := u.pool.QueryRow(ctx, `
SELECT series_id FROM episodes WHERE content_id = $1
UNION ALL
SELECT content_id, series_id FROM seasons WHERE content_id = ANY($1::text[])
SELECT series_id FROM seasons WHERE content_id = $1
UNION ALL
SELECT content_id, content_id FROM media_items WHERE content_id = ANY($1::text[]) AND type = 'series'`,
ids,
)
SELECT content_id FROM media_items WHERE content_id = $1 AND type = 'series'
LIMIT 1`,
itemID,
).Scan(&seriesID)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return nil, err
return "", false, err
}
defer rows.Close()
for rows.Next() {
var itemID, seriesID string
if err := rows.Scan(&itemID, &seriesID); err != nil {
return nil, err
}
if seriesID == "" {
continue
}
// An ID matching more than one branch keeps the first row, matching
// the single-item query's UNION ALL ... LIMIT 1 ordering.
if _, exists := result[itemID]; !exists {
result[itemID] = seriesID
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
return seriesID, seriesID != "", nil
}
// RecomputeSeries rebuilds the (profile, series) interest rows from
@@ -1,85 +0,0 @@
package notifications
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestResolveSeriesIDsBatchesLookups pins the batched item→series resolution
// used by the interest flush loop. Marking or unmarking a whole series queues
// one mutation per episode, and those all collapse to a single series; the
// previous per-item lookup issued one query each, which is what made a
// large-series unmark spend minutes in `episodes` lookups.
//
// Uses temp tables shadowing episodes/seasons/media_items, with the pool
// pinned to one connection so every query sees them.
func TestResolveSeriesIDsBatchesLookups(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SILO_TEST_DATABASE_URL to run the DB-backed series resolution test")
}
ctx := context.Background()
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse db config: %v", err)
}
config.MaxConns = 1
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatalf("connect db: %v", err)
}
t.Cleanup(pool.Close)
for _, ddl := range []string{
`CREATE TEMP TABLE episodes (content_id text PRIMARY KEY, series_id text NOT NULL)`,
`CREATE TEMP TABLE seasons (content_id text PRIMARY KEY, series_id text NOT NULL)`,
`CREATE TEMP TABLE media_items (content_id text PRIMARY KEY, type text NOT NULL)`,
} {
if _, err := pool.Exec(ctx, ddl); err != nil {
t.Fatalf("create temp table: %v", err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO episodes (content_id, series_id) VALUES ('ep-1','series-1'),('ep-2','series-1'),('ep-3','series-2');
INSERT INTO seasons (content_id, series_id) VALUES ('season-1','series-1');
INSERT INTO media_items (content_id, type) VALUES ('series-1','series'),('movie-1','movie');
`); err != nil {
t.Fatalf("seed: %v", err)
}
updater := &InterestUpdater{pool: pool}
got, err := updater.resolveSeriesIDs(ctx, []string{
"ep-1", "ep-2", "ep-3", "season-1", "series-1", "movie-1", "missing", "", "ep-1",
})
if err != nil {
t.Fatalf("resolveSeriesIDs: %v", err)
}
for itemID, wantSeries := range map[string]string{
"ep-1": "series-1",
"ep-2": "series-1",
"ep-3": "series-2",
"season-1": "series-1",
"series-1": "series-1",
} {
if got[itemID] != wantSeries {
t.Errorf("resolveSeriesIDs[%s] = %q, want %q", itemID, got[itemID], wantSeries)
}
}
// Movies, unknown IDs, and blanks must be absent rather than mapped: the
// flush loop reads "missing" as "no series interest" and skips them.
for _, itemID := range []string{"movie-1", "missing", ""} {
if seriesID, ok := got[itemID]; ok {
t.Errorf("resolveSeriesIDs[%q] = %q, want absent", itemID, seriesID)
}
}
if empty, err := updater.resolveSeriesIDs(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("resolveSeriesIDs(nil) = %v (%v), want empty", empty, err)
}
}
@@ -512,34 +512,6 @@ func TestRequestSchemaRequiredFieldsMatchValidators(t *testing.T) {
assertStringsEqual(t, "route_event.required", schemaStrings(t, routeEvent, "required"), []string{"protocol_version", "playback_attempt_id", "event"})
}
// TestRequestSchemasShareIdenticalDefs pins the one thing two copies of a
// definition can never be trusted to keep on their own. Start and replan
// deserialize the *same* Go types — ClientCodecCapabilitiesV3 and
// ClientPlaybackContextV3 — through the same validator, so any $def they both
// declare must be byte-identical. Adding a field to one schema and not the
// other compiles, validates, and ships a contract that lies about one of the
// two endpoints; this is what catches it.
func TestRequestSchemasShareIdenticalDefs(t *testing.T) {
start := schemaValue(t, mustReadObject(t, filepath.Join(schemaRootV3, "v3", "start-request.schema.json")), "$defs").(map[string]any)
replan := schemaValue(t, mustReadObject(t, filepath.Join(schemaRootV3, "v3", "replan-request.schema.json")), "$defs").(map[string]any)
shared := make([]string, 0, len(start))
for name := range start {
if _, ok := replan[name]; ok {
shared = append(shared, name)
}
}
sort.Strings(shared)
if !slices.Contains(shared, "client_playback_context") || !slices.Contains(shared, "client_capabilities") {
t.Fatalf("shared $defs = %v, want the client contract definitions in both request schemas", shared)
}
for _, name := range shared {
if !reflect.DeepEqual(start[name], replan[name]) {
t.Errorf("$defs.%s differs between start-request and replan-request; both deserialize the same Go type", name)
}
}
}
func compileSchemasV3(t *testing.T) map[string]*jsonschema.Schema {
t.Helper()
+3 -6
View File
@@ -360,16 +360,13 @@ func (s *Postgres) RecordRouteEvent(ctx context.Context, record playback.RouteEv
INSERT INTO playback_route_events (
playback_attempt_id, session_id, plan_id, plan_attempt_id, plan_attempt_key,
event, failure_classification, fallback_reason, output_context_id,
diagnostics, user_id, profile_id, client_name, client_version, client_build,
client_channel, client_model
diagnostics, user_id, profile_id, client_name, client_version, client_model
) VALUES ($1, NULLIF($2, '')::uuid, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
$6, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), $10, $11, $12,
NULLIF($13, ''), NULLIF($14, ''), NULLIF($15, ''), NULLIF($16, ''),
NULLIF($17, ''))`,
NULLIF($13, ''), NULLIF($14, ''), NULLIF($15, ''))`,
record.PlaybackAttemptID, record.SessionID, record.PlanID, record.PlanAttemptID, record.PlanAttemptKey,
record.Event, record.FailureClassification, record.FallbackReason, record.OutputContextID,
diagnostics, record.UserID, record.ProfileID, record.ClientName, record.ClientVersion,
record.ClientBuild, record.ClientChannel, record.ClientModel)
diagnostics, record.UserID, record.ProfileID, record.ClientName, record.ClientVersion, record.ClientModel)
return err
}
-4
View File
@@ -57,10 +57,6 @@ type RouteEventRecordV3 struct {
ProfileID string
ClientName string
ClientVersion string
// ClientBuild and ClientChannel are opaque client-reported identifiers,
// recorded so a route decision can be attributed to an exact app build.
ClientBuild string
ClientChannel string
ClientModel string
}
+6 -18
View File
@@ -331,17 +331,12 @@ type DeliveryCapabilityV3 struct {
// advertisement lives exclusively in the request's top-level client_features
// list; there is deliberately no second features location here.
type ClientPlaybackContextV3 struct {
ProtocolVersion int `json:"protocol_version"`
FormFactor string `json:"form_factor"`
AppVersion string `json:"app_version"`
// AppBuild and AppChannel are the request-body fallback for the
// X-Silo-Client-Build / X-Silo-Client-Channel headers. Both are opaque
// strings the server stores verbatim.
AppBuild string `json:"app_build,omitempty"`
AppChannel string `json:"app_channel,omitempty"`
Device DeviceContextV3 `json:"device"`
Output OutputContextV3 `json:"output"`
Deliveries map[string]DeliveryCapabilityV3 `json:"deliveries"`
ProtocolVersion int `json:"protocol_version"`
FormFactor string `json:"form_factor"`
AppVersion string `json:"app_version"`
Device DeviceContextV3 `json:"device"`
Output OutputContextV3 `json:"output"`
Deliveries map[string]DeliveryCapabilityV3 `json:"deliveries"`
}
type StartRequestV3 struct {
@@ -895,13 +890,6 @@ func validateCapabilitiesV3(c *ClientCodecCapabilitiesV3, ctx *ClientPlaybackCon
if len(c.CodecsVideo) > 64 || len(c.CodecsVideoHardware) > 64 || len(c.CodecsAudio) > 64 || len(c.Containers) > 64 || len(c.VideoDecode) > 64 || len(ctx.Deliveries) > 16 || len(ctx.Device.Platform) > 32 || len(ctx.FormFactor) > 32 || len(ctx.AppVersion) > 64 {
return errors.New("capability list exceeds supported size")
}
// Build and channel are opaque diagnostic labels, so an over-long value is
// worth clamping and never worth refusing playback over. The header route
// (X-Silo-Client-Build / -Channel) clamps with the same helper; rejecting
// here would mean the same string plays from a header and 400s from the
// body.
ctx.AppBuild = normalizeClientMetadataValue(ctx.AppBuild, 64)
ctx.AppChannel = normalizeClientMetadataValue(ctx.AppChannel, 32)
deviceValues := []string{
ctx.Device.OSVersion, ctx.Device.Manufacturer, ctx.Device.Model,
ctx.Output.CurrentSink, ctx.Output.SinkType, ctx.Output.OutputContextID,
+9 -178
View File
@@ -8,8 +8,6 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
)
@@ -28,8 +26,6 @@ type Session struct {
ClientIP string // resolved client IP for the playback session
ClientName string // reported playback client name, when available
ClientVersion string // reported playback client version, when available
ClientBuild string // opaque reported client build identifier, when available
ClientChannel string // opaque reported client distribution channel, when available
ClientUserAgent string // trimmed request user agent for the playback session
IsJellyfinCompat bool // immutable origin identity for Jellyfin compatibility sessions
@@ -66,10 +62,6 @@ type Session struct {
activeTransportCount int
replacementPlayMethod PlayMethod
streamRevision uint64
// remoteTransport marks a session whose media bytes are served by another
// node, so this server never sees the transport request that would
// otherwise keep it alive. See SetRemoteTransport.
remoteTransport bool
}
// SessionStreamState stores the mutable stream-specific details that can
@@ -151,76 +143,12 @@ type clientInfoContextKey struct{}
// ClientInfo carries best-effort client metadata from request handling into
// the playback session manager.
type ClientInfo struct {
Name string
Version string
// Build is the client's opaque per-platform build identifier (Android
// versionCode, Apple CFBundleVersion, …). The server never parses or
// compares it; it exists so an admin can name the exact build.
Build string
// Channel is the client's opaque distribution channel ("release", "beta",
// "sideload", "dev", …). Stored verbatim — deliberately not validated
// against an enum so a new channel needs no server change.
Channel string
Name string
Version string
UserAgent string
IsCompat bool
}
// Normalized returns the identity with every field trimmed and clamped to the
// bound published for it (docs/settings-api.md, and maxLength in the v3 request
// schemas). This is the single definition of those bounds, and callers apply it
// at the request boundary: a session stamps normalized values, but the decision
// logs and playback_route_events are written straight from the resolved
// identity, so clamping only at session creation would let a client's oversized
// header through to both.
func (c ClientInfo) Normalized() ClientInfo {
c.Name = normalizeClientMetadataValue(c.Name, 128)
c.Version = normalizeClientMetadataValue(c.Version, 64)
c.Build = normalizeClientMetadataValue(c.Build, 64)
c.Channel = normalizeClientMetadataValue(c.Channel, 32)
c.UserAgent = normalizeClientMetadataValue(c.UserAgent, 512)
return c
}
// LogAttrs renders the app identity as slog key/value pairs, skipping the
// fields the client did not report. This is the single definition of those log
// keys — every playback decision and the session-expiry line share it, so a
// rename cannot leave one surface keyed differently from another. Skipping
// empty values matters as much: browsers and Jellyfin-ecosystem clients report
// none of them, and opslog persists the attrs it is handed, so emitting four
// empty keys per decision would grow /admin/logs for no diagnostic value.
func (c ClientInfo) LogAttrs() []any {
attrs := make([]any, 0, 8)
for _, pair := range [...]struct{ key, value string }{
{"client_name", c.Name},
{"client_version", c.Version},
{"client_build", c.Build},
{"client_channel", c.Channel},
} {
if pair.value != "" {
attrs = append(attrs, pair.key, pair.value)
}
}
return attrs
}
// ClientInfo returns the app identity stamped on the session when it was
// created. Surfaces that only hold a session — expiry logging, route events
// posted out of band — recover the reporting client through this instead of
// re-reading request headers that may no longer be present.
func (s *Session) ClientInfo() ClientInfo {
if s == nil {
return ClientInfo{}
}
return ClientInfo{
Name: s.ClientName,
Version: s.ClientVersion,
Build: s.ClientBuild,
Channel: s.ClientChannel,
UserAgent: s.ClientUserAgent,
IsCompat: s.IsJellyfinCompat,
}
}
// WithClientInfo stores playback client metadata on a context.
func WithClientInfo(ctx context.Context, info ClientInfo) context.Context {
if ctx == nil {
@@ -303,12 +231,6 @@ const (
// observed playback activity before it stops counting toward limits.
DefaultActiveSessionGrace = 45 * time.Second
// remoteTransportIdleGrace is the floor on the idle windows for a session
// whose media is served by another node. See remoteTransportGrace: it must
// outlast a heartbeat gap on an otherwise healthy proxy stream, while still
// reaping a session whose client vanished without stopping.
remoteTransportIdleGrace = 5 * time.Minute
// DefaultPausedSessionGrace is the longer grace period for paused
// sessions. It must comfortably cover an intentional pause (dinner
// break, phone call): reaping a paused session kills its transcode
@@ -370,41 +292,9 @@ func (m *SessionManager) SetExpirationHook(fn func(*Session)) {
}
func normalizeClientMetadataValue(value string, maxLen int) string {
// A text column takes neither invalid UTF-8 nor a NUL, and Postgres refuses
// the whole statement for either. The per-node session upserts share one
// transaction, so a single malformed client string would stop that entire
// node from reconciling — not just its own row — until the session goes
// away. Both scrubs are needed: NUL is perfectly valid UTF-8, so
// ToValidUTF8 leaves it, and a v3 start body can carry one as the JSON
// escape (headers cannot — net/http rejects bytes below 0x20).
// Control characters are stripped wholesale rather than just NUL: none of
// them belong in an identity label rendered in the admin UI and written to
// structured logs.
if !utf8.ValidString(value) {
value = strings.ToValidUTF8(value, "")
}
if strings.ContainsFunc(value, unicode.IsControl) {
value = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
value = strings.TrimSpace(value)
if maxLen <= 0 || len(value) <= maxLen {
return value
}
// Clamp by runes, not bytes. These bounds are published to clients as JSON
// Schema maxLength, which counts characters — a byte clamp would silently
// cut a value the contract calls valid, and cutting mid-rune would produce
// exactly the invalid UTF-8 scrubbed above.
runes := 0
for offset := range value {
if runes == maxLen {
return value[:offset]
}
runes++
if maxLen > 0 && len(value) > maxLen {
value = value[:maxLen]
}
return value
}
@@ -539,10 +429,7 @@ func newSession(
transcodeAudio bool,
) *Session {
now := time.Now()
// Normalize here as well as at the request boundary: identities also reach
// the manager from the Jellyfin and Audiobookshelf compat surfaces, which
// build a ClientInfo from their own header vocabularies.
clientInfo := ClientInfoFromContext(ctx).Normalized()
clientInfo := ClientInfoFromContext(ctx)
return &Session{
ID: uuid.New().String(),
UserID: userID,
@@ -554,11 +441,9 @@ func newSession(
TranscodeAudio: transcodeAudio,
Position: 0,
IsPaused: false,
ClientName: clientInfo.Name,
ClientVersion: clientInfo.Version,
ClientBuild: clientInfo.Build,
ClientChannel: clientInfo.Channel,
ClientUserAgent: clientInfo.UserAgent,
ClientName: normalizeClientMetadataValue(clientInfo.Name, 128),
ClientVersion: normalizeClientMetadataValue(clientInfo.Version, 64),
ClientUserAgent: normalizeClientMetadataValue(clientInfo.UserAgent, 512),
IsJellyfinCompat: clientInfo.IsCompat,
StartedAt: now,
UpdatedAt: now,
@@ -1210,34 +1095,6 @@ func (m *SessionManager) BeginTransport(sessionID string) error {
return nil
}
// SetRemoteTransport records whether this session's media bytes are served by
// another node (a proxy), rather than by a transport request this server
// handles itself.
//
// A locally-served stream is protected from the idle reaper by
// BeginTransport/EndTransport around the serve call. A proxy-served stream
// never enters that path: the client talks to the proxy directly, so from this
// server's point of view a healthy multi-hour stream looks identical to an
// abandoned session, and a heartbeat gap longer than the active grace would
// reap it mid-playback — after which progress, stop, and replan all fail with
// session-not-found while bytes are still flowing.
//
// This marks the session as remotely transported for its whole lifetime rather
// than per-request, because there is no request to bracket. Progress
// heartbeats still drive UI liveness; this only prevents reaping.
func (m *SessionManager) SetRemoteTransport(sessionID string, remote bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.remoteTransport = remote
m.touchSessionLocked(s)
return nil
}
// EndTransport decrements the count of in-flight media transport requests for
// the session and refreshes its activity timestamp.
func (m *SessionManager) EndTransport(sessionID string) error {
@@ -1418,7 +1275,6 @@ func (m *SessionManager) CleanInactive(activeIdle, pausedIdle time.Duration) []*
if s.activeTransportCount > 0 {
continue
}
activeIdle, pausedIdle := remoteTransportGrace(s, activeIdle, pausedIdle)
if m.sessionIsInactiveLocked(s, now, activeIdle, pausedIdle) {
cp := *s
expired = append(expired, &cp)
@@ -1449,32 +1305,7 @@ func (m *SessionManager) countsTowardLimitsLocked(s *Session, now time.Time) boo
if s.activeTransportCount > 0 {
return true
}
activeGrace, pausedGrace := remoteTransportGrace(s, m.activeGrace, m.pausedGrace)
return !m.sessionIsInactiveLocked(s, now, activeGrace, pausedGrace)
}
// remoteTransportGrace widens the idle windows for a session whose bytes are
// served by another node.
//
// A locally-served stream is held open by an in-flight transport request. A
// proxy-served one has no such request here, so it is protected only by the
// client's progress heartbeats — and a gap longer than the active grace would
// reap it while media is still flowing. The windows are widened rather than
// made infinite: there is no absolute session lifetime cap in this manager, so
// unconditional immunity would leak a session forever whenever a client
// disappears without stopping. A client that has gone quiet for this long has
// genuinely stopped watching.
func remoteTransportGrace(s *Session, activeIdle, pausedIdle time.Duration) (time.Duration, time.Duration) {
if s == nil || !s.remoteTransport {
return activeIdle, pausedIdle
}
if activeIdle > 0 && activeIdle < remoteTransportIdleGrace {
activeIdle = remoteTransportIdleGrace
}
if pausedIdle > 0 && pausedIdle < remoteTransportIdleGrace {
pausedIdle = remoteTransportIdleGrace
}
return activeIdle, pausedIdle
return !m.sessionIsInactiveLocked(s, now, m.activeGrace, m.pausedGrace)
}
func (m *SessionManager) sessionIsInactiveLocked(s *Session, now time.Time, activeIdle, pausedIdle time.Duration) bool {
@@ -1,97 +0,0 @@
package playback
import (
"testing"
"time"
)
// A proxy-served session has no in-flight transport request on this server, so
// the idle sweep is all that stands between a healthy multi-hour stream and
// being reaped mid-playback.
func TestRemoteTransportGraceWidensIdleWindows(t *testing.T) {
local := &Session{}
remote := &Session{remoteTransport: true}
if active, paused := remoteTransportGrace(local, 45*time.Second, 2*time.Minute); active != 45*time.Second || paused != 2*time.Minute {
t.Fatalf("local grace = (%v, %v), want the configured windows unchanged", active, paused)
}
active, paused := remoteTransportGrace(remote, 45*time.Second, 2*time.Minute)
if active != remoteTransportIdleGrace || paused != remoteTransportIdleGrace {
t.Fatalf("remote grace = (%v, %v), want both widened to %v", active, paused, remoteTransportIdleGrace)
}
}
// The widened window must not become immunity: this manager has no absolute
// session lifetime, so a client that disappears without stopping would leak the
// session forever.
func TestRemoteTransportGraceStillExpires(t *testing.T) {
manager := NewSessionManager(0, 0)
session, err := manager.StartSession(7, "profile-1", 42, PlayDirect, false)
if err != nil {
t.Fatalf("start session: %v", err)
}
if err := manager.SetRemoteTransport(session.ID, true); err != nil {
t.Fatalf("mark remote transport: %v", err)
}
// Backdate the session past the widened window, as an abandoned client would.
manager.mu.Lock()
stale := time.Now().Add(-2 * remoteTransportIdleGrace)
manager.sessions[session.ID].LastActivityAt = stale
manager.sessions[session.ID].UpdatedAt = stale
manager.mu.Unlock()
if expired := manager.CleanInactive(45*time.Second, 2*time.Minute); len(expired) != 1 {
t.Fatalf("expired %d abandoned proxy sessions, want 1", len(expired))
}
}
// A session inside the widened window survives a heartbeat gap that would have
// reaped it before, which is the reported failure this guards against.
func TestRemoteTransportSurvivesHeartbeatGap(t *testing.T) {
manager := NewSessionManager(0, 0)
session, err := manager.StartSession(7, "profile-1", 42, PlayDirect, false)
if err != nil {
t.Fatalf("start session: %v", err)
}
if err := manager.SetRemoteTransport(session.ID, true); err != nil {
t.Fatalf("mark remote transport: %v", err)
}
manager.mu.Lock()
gap := time.Now().Add(-90 * time.Second) // twice the default active grace
manager.sessions[session.ID].LastActivityAt = gap
manager.sessions[session.ID].UpdatedAt = gap
manager.mu.Unlock()
if expired := manager.CleanInactive(DefaultActiveSessionGrace, DefaultPausedSessionGrace); len(expired) != 0 {
t.Fatalf("reaped %d healthy proxy-served sessions during a heartbeat gap", len(expired))
}
}
// Clearing the mark restores normal reaping, so a re-plan that moves a session
// back onto this server does not leave it with a stale widened grace.
func TestRemoteTransportCanBeCleared(t *testing.T) {
manager := NewSessionManager(0, 0)
session, err := manager.StartSession(7, "profile-1", 42, PlayDirect, false)
if err != nil {
t.Fatalf("start session: %v", err)
}
if err := manager.SetRemoteTransport(session.ID, true); err != nil {
t.Fatalf("mark remote transport: %v", err)
}
if err := manager.SetRemoteTransport(session.ID, false); err != nil {
t.Fatalf("clear remote transport: %v", err)
}
manager.mu.Lock()
gap := time.Now().Add(-90 * time.Second)
manager.sessions[session.ID].LastActivityAt = gap
manager.sessions[session.ID].UpdatedAt = gap
manager.mu.Unlock()
if expired := manager.CleanInactive(DefaultActiveSessionGrace, DefaultPausedSessionGrace); len(expired) != 1 {
t.Fatalf("expired %d sessions after clearing the mark, want 1", len(expired))
}
}
@@ -10,7 +10,6 @@
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
@@ -5406,7 +5406,6 @@
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
@@ -7,7 +7,6 @@
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
-50
View File
@@ -1,50 +0,0 @@
package proxy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/playback"
)
// The API validates a frozen remux recipe against the proxy that will execute
// it, so a proxy must answer the same capability probe a transcode node does.
// Without it the API cannot tell a capable proxy from one on an older ffmpeg,
// and the mismatch only surfaces as a failed stream.
func TestProxyAdvertisesTransformationCapabilities(t *testing.T) {
const secret = "capability-secret"
server := newDownloadProxyServer(t, secret)
request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil)
request.Header.Set("Authorization", "Bearer "+secret)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
var info playback.HWAccelInfo
if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
// The probe reflects whatever ffmpeg this machine has; the contract under
// test is that the endpoint exists and answers in the shared shape.
for _, transformation := range info.Transformations {
if transformation.Name == "" || transformation.RecipeVersion == "" {
t.Fatalf("advertised transformation is missing identity: %#v", transformation)
}
}
}
func TestProxyCapabilitiesRequireBearer(t *testing.T) {
server := newDownloadProxyServer(t, "capability-secret")
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 for an unauthenticated capability probe", recorder.Code)
}
}
-10
View File
@@ -77,16 +77,6 @@ func (w *meteredResponseWriter) Flush() {
}
}
// Unwrap lets http.ResponseController traverse to the underlying writer.
//
// Without it the metering wrapper is a dead end: RollingDeadlineWriter's
// SetWriteDeadline call fails, it degrades to a plain pass-through, and since
// the standalone proxy runs with WriteTimeout 0 there is no server-level guard
// behind it. A client that stops reading without closing its connection would
// then block a stream write forever, holding the tracked session, the open
// file, the goroutine and the connection.
func (w *meteredResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// meterEgress wraps stream handlers so their responses count toward the
// node's measured egress bandwidth.
func (s *Server) meterEgress(next http.Handler) http.Handler {
+1 -39
View File
@@ -103,17 +103,6 @@ func (s *Server) Handler() http.Handler {
"Accept", "Authorization", "Content-Type", "Range",
"If-Match", "If-Modified-Since", "If-None-Match", "If-Range", "If-Unmodified-Since",
},
// direct_stream_resume_v1 has the client re-request a byte range with
// If-Range against the entity tag it stored. Cross-origin JavaScript
// can only read a response header that is explicitly exposed, so
// without these the client can send the conditional request headers
// above but never learn the values to put in them — the resume
// contract silently degrades to a full restart on a proxy that is on a
// different origin than the web app, which is the normal deployment.
ExposedHeaders: []string{
"Accept-Ranges", "Content-Encoding", "Content-Length", "Content-Range",
"ETag", "Last-Modified",
},
MaxAge: 86400,
}))
r.Get("/api/v1/health", s.handleHealth)
@@ -136,34 +125,12 @@ func (s *Server) Handler() http.Handler {
// Admin routes — bearer-auth protected.
r.Group(func(r chi.Router) {
r.Use(s.requireBearer)
r.Get("/hw-capabilities", s.handleHWCapabilities)
r.Post("/admin/force-reload", s.handleForceReload)
r.Get("/status", s.handleStatus)
})
return r
}
// handleHWCapabilities advertises what this proxy's ffmpeg can actually do, in
// the same shape and at the same path as a transcode node.
//
// A proxy executes recipes too: /stream/remux runs ffmpeg to convert audio or
// strip a Dolby Vision RPU. Without this endpoint the API has no way to tell
// whether the proxy it just picked can run the transformations a plan froze, so
// a pool whose proxies carry a different ffmpeg build (a rolling upgrade, a
// custom image) would fail at stream time rather than at selection time.
func (s *Server) handleHWCapabilities(w http.ResponseWriter, r *http.Request) {
ffmpegPath := ""
if cfg := s.watcher.Config(); cfg != nil {
ffmpegPath = cfg.Playback.FFmpegPath
}
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
info.Transformations = playback.ProbeTransformationRegistryV3(r.Context(), ffmpegPath).Advertised()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(info); err != nil {
slog.WarnContext(r.Context(), "encode proxy capabilities", "component", "proxy", "error", err)
}
}
type healthResponse struct {
Status string `json:"status"`
ActiveJobs int `json:"active_jobs"`
@@ -218,12 +185,7 @@ func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
s.tracker.Track(r.Context(), info)
defer s.tracker.Remove(r.Context(), claims.SessionID)
// Serve through the same path the integrated server uses rather than a bare
// http.ServeFile: direct_stream_resume_v1 requires the strong ETag that
// ServeDirectPlay sets before ServeContent (ServeFile sets none, so
// If-Range never validates and a resumed range silently restarts at 200),
// and it carries the rolling write deadline and stream metrics with it.
_ = playback.ServeDirectPlay(w, r, claims.MediaPath)
http.ServeFile(w, r, claims.MediaPath)
}
func (s *Server) handleDownloadFile(w http.ResponseWriter, r *http.Request) {
-20
View File
@@ -1943,26 +1943,6 @@ func (r *FileRepository) GetByPath(ctx context.Context, path string) (*models.Me
return scanMediaFile(r.pool.QueryRow(ctx, query, path))
}
// IsActivePath reports whether path is the exact logical path of a media file
// that is still active in the catalog. Scanner paths are authoritative here:
// they deliberately preserve readable symlinks instead of replacing them with
// their physical targets.
func (r *FileRepository) IsActivePath(ctx context.Context, path string) (bool, error) {
var active bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM media_files
WHERE file_path = $1
AND missing_since IS NULL
)
`, path).Scan(&active)
if err != nil {
return false, fmt.Errorf("checking active media file path: %w", err)
}
return active, nil
}
// GetByHash retrieves a media file by its file hash.
func (r *FileRepository) GetByHash(ctx context.Context, hash string) (*models.MediaFile, error) {
query := `SELECT ` + fileColumns + ` FROM media_files WHERE file_hash = $1 LIMIT 1`
@@ -1,69 +0,0 @@
package scanner
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestFileRepositoryIsActivePath(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled)
VALUES ('movies', 'Active Path Test', true)
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
activePath := fmt.Sprintf("/tmp/silo-active-path-%d.mkv", suffix)
missingPath := fmt.Sprintf("/tmp/silo-missing-path-%d.mkv", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (media_folder_id, file_path, missing_since)
VALUES ($1, $2, NULL), ($1, $3, NOW())
`, folderID, activePath, missingPath); err != nil {
t.Fatalf("seed media files: %v", err)
}
repo := NewFileRepository(pool)
for _, test := range []struct {
name string
path string
want bool
}{
{name: "active", path: activePath, want: true},
{name: "missing", path: missingPath, want: false},
{name: "unknown", path: fmt.Sprintf("/tmp/silo-unknown-path-%d.mkv", suffix), want: false},
} {
t.Run(test.name, func(t *testing.T) {
got, err := repo.IsActivePath(ctx, test.path)
if err != nil {
t.Fatalf("IsActivePath: %v", err)
}
if got != test.want {
t.Fatalf("IsActivePath(%q) = %v, want %v", test.path, got, test.want)
}
})
}
}
@@ -1,43 +0,0 @@
package sections
import (
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestCollectionRailItemsToFetchPreservesLegacyBoundWithoutDefaultSort(t *testing.T) {
items := make([]*models.LibraryCollectionItem, 100_000)
bounded := collectionRailItemsToFetch(items, 24)
if len(bounded) != 24 {
t.Fatalf("unsorted rail selected %d items, want 24", len(bounded))
}
visible, total := unsortedCollectionRailResult(make([]*models.MediaItem, len(bounded)))
if len(visible) != 24 || total != 24 {
t.Fatalf("unsorted rail result = %d items, total %d; want 24/24", len(visible), total)
}
}
func TestCollectionRailQueryAccessIntersectsExplicitLibrary(t *testing.T) {
requestedLibraryID := 7
matching := collectionRailQueryAccess(
catalog.AccessFilter{AllowedLibraryIDs: []int{4, 7}},
&requestedLibraryID,
nil,
)
if len(matching.AllowedLibraryIDs) != 1 || matching.AllowedLibraryIDs[0] != requestedLibraryID {
t.Fatalf("matching access = %v, want [%d]", matching.AllowedLibraryIDs, requestedLibraryID)
}
blocked := collectionRailQueryAccess(
catalog.AccessFilter{AllowedLibraryIDs: []int{4}},
&requestedLibraryID,
nil,
)
if blocked.AllowedLibraryIDs == nil || len(blocked.AllowedLibraryIDs) != 0 {
t.Fatalf("blocked access = %v, want non-nil empty scope", blocked.AllowedLibraryIDs)
}
}
+10 -90
View File
@@ -1309,8 +1309,7 @@ func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, librar
return []*models.MediaItem{}, 0, nil
}
collection, err := f.CollectionRepo.GetByID(ctx, cfg.LibraryCollectionID)
if err != nil {
if _, err := f.CollectionRepo.GetByID(ctx, cfg.LibraryCollectionID); err != nil {
return nil, 0, fmt.Errorf("loading library collection: %w", err)
}
@@ -1322,28 +1321,13 @@ func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, librar
return []*models.MediaItem{}, 0, nil
}
// Library collection rails are shared across profiles (see
// isCacheableSectionType), so only the creator's user-agnostic default
// applies here — a viewer's personal override is honored on the collection's
// own browse page.
defaultSort, hasDefaultSort := catalog.ParseCollectionDefaultSort(collection.SortConfig, false)
if hasDefaultSort {
contentIDs := make([]string, 0, len(collectionItems))
for _, item := range collectionItems {
contentIDs = append(contentIDs, item.MediaItemID)
}
queryAccess := collectionRailQueryAccess(filter, libraryID, libraryIDs)
items, total, err := catalog.QueryCollectionItemsBySort(ctx, f.pool, contentIDs, defaultSort, queryAccess, s.ItemLimit, "")
if err != nil {
return nil, 0, err
}
return items, total, nil
limit := s.ItemLimit
if limit <= 0 || limit > len(collectionItems) {
limit = len(collectionItems)
}
selectedCollectionItems := collectionRailItemsToFetch(collectionItems, s.ItemLimit)
contentIDs := make([]string, 0, len(selectedCollectionItems))
for _, item := range selectedCollectionItems {
contentIDs := make([]string, 0, limit)
for _, item := range collectionItems[:limit] {
contentIDs = append(contentIDs, item.MediaItemID)
}
@@ -1366,23 +1350,7 @@ func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, librar
orderedItems = append(orderedItems, item)
}
// Historically total represented the number of visible rail items, not the
// collection membership. Keep that contract for unsorted rails.
orderedItems, total := unsortedCollectionRailResult(orderedItems)
return orderedItems, total, nil
}
func collectionRailItemsToFetch(items []*models.LibraryCollectionItem, itemLimit int) []*models.LibraryCollectionItem {
if itemLimit > 0 && itemLimit < len(items) {
// Preserve the legacy rail path when no default sort is configured:
// bound the lookup before expanding content IDs into SQL parameters.
return items[:itemLimit]
}
return items
}
func unsortedCollectionRailResult(items []*models.MediaItem) ([]*models.MediaItem, int) {
return items, len(items)
return orderedItems, len(orderedItems), nil
}
// fetchUserCollection resolves a personal (profile-scoped) user collection.
@@ -1479,29 +1447,6 @@ func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, li
contentIDs = append(contentIDs, item.MediaItemID)
}
displayAccess := collectionRailQueryAccess(filter, libraryID, libraryIDs)
displayAccess.UserID = userID
displayAccess.ProfileID = profileID
// A configured default is executed together with the display filter and
// rail limit. Querying before hydration avoids loading an entire large
// collection merely to render a bounded home rail.
if qs, ok := catalog.ParseCollectionDefaultSort([]byte(collection.SortConfig), true); ok {
items, total, err := catalog.QueryCollectionItemsBySort(
ctx,
f.pool,
contentIDs,
qs,
displayAccess,
s.ItemLimit,
collection.DisplayQueryDefinition,
)
if err != nil {
return nil, 0, err
}
return items, total, nil
}
items, err := f.fetchItemsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
@@ -1521,6 +1466,9 @@ func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, li
orderedItems = append(orderedItems, item)
}
displayAccess := filter
displayAccess.UserID = userID
displayAccess.ProfileID = profileID
orderedItems, err = catalog.FilterCollectionItemsByDisplayQuery(ctx, f.pool, orderedItems, collection.DisplayQueryDefinition, displayAccess)
if err != nil {
return nil, 0, err
@@ -2796,34 +2744,6 @@ func effectiveFetchLibraryIDs(libraryIDs []int, filter catalog.AccessFilter) []i
return nil
}
// collectionRailQueryAccess expresses the section's explicit library scope as
// an AccessFilter for collection queries routed through QueryExecutor.
func collectionRailQueryAccess(filter catalog.AccessFilter, libraryID *int, libraryIDs []int) catalog.AccessFilter {
result := filter
effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter)
if libraryID == nil {
if effectiveLibraryIDs == nil {
result.AllowedLibraryIDs = nil
} else {
result.AllowedLibraryIDs = append([]int(nil), effectiveLibraryIDs...)
}
return result
}
if effectiveLibraryIDs == nil {
result.AllowedLibraryIDs = []int{*libraryID}
return result
}
for _, id := range effectiveLibraryIDs {
if id == *libraryID {
result.AllowedLibraryIDs = []int{*libraryID}
return result
}
}
result.AllowedLibraryIDs = []int{}
return result
}
func recentSeasonPremiereBadges(seasonNumber, episodeNumber int, airDate *time.Time) []string {
if seasonNumber <= 1 || episodeNumber != 1 || airDate == nil {
return nil
+48 -25
View File
@@ -5,50 +5,54 @@ import (
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// InputPathAuthorizer approves a local media input before a node passes it to
// FFmpeg. Implementations must reject protocol URLs and paths outside the
// authoritative media catalog.
// FFmpeg. Implementations must reject protocol URLs and paths outside managed
// library roots.
type InputPathAuthorizer interface {
Allowed(ctx context.Context, path string) (bool, error)
}
type catalogPathSource interface {
IsActivePath(ctx context.Context, path string) (bool, error)
type mediaFolderSource interface {
List(ctx context.Context) ([]*models.MediaFolder, error)
}
// CatalogPathAuthorizer permits only existing regular files whose exact
// logical path is active in the media catalog. The scanner deliberately keeps
// logical paths for readable symlinks, so catalog membership is the correct
// authority: resolving the target and requiring it to remain under the logical
// library root would reject media layouts the scanner explicitly supports.
type CatalogPathAuthorizer struct {
paths catalogPathSource
// MediaRootAuthorizer resolves the deployment's current library roots and
// permits only existing, absolute filesystem paths contained by those roots.
type MediaRootAuthorizer struct {
folders mediaFolderSource
}
// NewCatalogPathAuthorizer creates an FFmpeg input authorizer backed by the
// authoritative media-file catalog.
func NewCatalogPathAuthorizer(paths catalogPathSource) *CatalogPathAuthorizer {
return &CatalogPathAuthorizer{paths: paths}
// NewMediaRootAuthorizer creates an FFmpeg input authorizer backed by the
// authoritative media-folder repository.
func NewMediaRootAuthorizer(folders mediaFolderSource) *MediaRootAuthorizer {
return &MediaRootAuthorizer{folders: folders}
}
// Allowed reports whether path is an active catalog entry that resolves to a
// regular file on this node. os.Stat follows scanner-approved symlinks while
// rejecting dangling links, directories, and other non-regular inputs.
func (a *CatalogPathAuthorizer) Allowed(ctx context.Context, path string) (bool, error) {
if a == nil || a.paths == nil || !plainAbsolutePath(path) {
// Allowed reports whether path resolves inside one of the configured media
// roots. Symlinks are resolved on both sides so a link cannot escape a root.
func (a *MediaRootAuthorizer) Allowed(ctx context.Context, path string) (bool, error) {
if a == nil || a.folders == nil || !plainAbsolutePath(path) {
return false, nil
}
active, err := a.paths.IsActivePath(ctx, path)
folders, err := a.folders.List(ctx)
if err != nil {
return false, err
}
if !active {
return false, nil
for _, folder := range folders {
if folder == nil {
continue
}
for _, root := range folder.Paths {
if existingPathWithinRoot(root, path) {
return true, nil
}
}
}
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular(), nil
return false, nil
}
func plainAbsolutePath(path string) bool {
@@ -56,6 +60,25 @@ func plainAbsolutePath(path string) bool {
return path != "" && !strings.ContainsRune(path, '\x00') && filepath.IsAbs(path)
}
func existingPathWithinRoot(root, target string) bool {
if !plainAbsolutePath(root) || !plainAbsolutePath(target) {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
resolvedTarget, err := filepath.EvalSymlinks(filepath.Clean(target))
if err != nil {
return false
}
info, err := os.Stat(resolvedTarget)
if err != nil || !info.Mode().IsRegular() {
return false
}
return resolvedPathContained(resolvedRoot, resolvedTarget)
}
// pathWithinRoot validates a not-yet-created output by resolving the root and
// target parent. The caller creates the basename only after this check.
func pathWithinRoot(root, target string) bool {
+48 -44
View File
@@ -7,63 +7,41 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type staticCatalogPaths struct {
active map[string]bool
err error
type staticMediaFolders struct {
folders []*models.MediaFolder
err error
}
func (s staticCatalogPaths) IsActivePath(_ context.Context, path string) (bool, error) {
return s.active[path], s.err
func (s staticMediaFolders) List(context.Context) ([]*models.MediaFolder, error) {
return s.folders, s.err
}
func TestCatalogPathAuthorizerAllowsCataloguedRegularFilesAndSymlinks(t *testing.T) {
mediaPath := filepath.Join(t.TempDir(), "movie.mkv")
func TestMediaRootAuthorizerAllowsOnlyExistingFilesWithinLibraryRoots(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(mediaPath, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(target, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(t.TempDir(), "linked.mkv")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks not supported on this platform: %v", err)
}
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{active: map[string]bool{
mediaPath: true,
link: true,
}})
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
for _, path := range []string{mediaPath, link} {
allowed, err := authorizer.Allowed(context.Background(), path)
if err != nil || !allowed {
t.Fatalf("approved media path %q: allowed = %v, err = %v", path, allowed, err)
}
allowed, err := authorizer.Allowed(context.Background(), mediaPath)
if err != nil || !allowed {
t.Fatalf("approved media path: allowed = %v, err = %v", allowed, err)
}
}
func TestCatalogPathAuthorizerRejectsUnsafeOrUncataloguedInputs(t *testing.T) {
existingUncatalogued := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(existingUncatalogued, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
missingCatalogued := filepath.Join(t.TempDir(), "missing.mkv")
directoryCatalogued := t.TempDir()
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{active: map[string]bool{
missingCatalogued: true,
directoryCatalogued: true,
}})
for _, candidate := range []string{
"relative/movie.mkv",
"http://example.test/movie.mkv",
"file:" + existingUncatalogued,
"concat:" + existingUncatalogued + "|" + existingUncatalogued,
existingUncatalogued,
missingCatalogued,
directoryCatalogued,
"file:" + mediaPath,
"concat:" + mediaPath + "|" + mediaPath,
filepath.Join(t.TempDir(), "outside.mkv"),
filepath.Join(root, "missing.mkv"),
} {
allowed, err := authorizer.Allowed(context.Background(), candidate)
if err != nil {
@@ -75,9 +53,35 @@ func TestCatalogPathAuthorizerRejectsUnsafeOrUncataloguedInputs(t *testing.T) {
}
}
func TestCatalogPathAuthorizerPropagatesRepositoryErrors(t *testing.T) {
func TestMediaRootAuthorizerRejectsSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows")
}
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(outside, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked.mkv")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
allowed, err := authorizer.Allowed(context.Background(), link)
if err != nil {
t.Fatal(err)
}
if allowed {
t.Fatal("symlink escape was allowed")
}
}
func TestMediaRootAuthorizerPropagatesRepositoryErrors(t *testing.T) {
wantErr := errors.New("database unavailable")
authorizer := NewCatalogPathAuthorizer(staticCatalogPaths{err: wantErr})
authorizer := NewMediaRootAuthorizer(staticMediaFolders{err: wantErr})
allowed, err := authorizer.Allowed(context.Background(), "/media/movie.mkv")
if allowed || !errors.Is(err, wantErr) {
t.Fatalf("allowed = %v, err = %v", allowed, err)
+1 -1
View File
@@ -375,7 +375,7 @@ func TestHandleDownloadPrepareRejectsUnavailableConfig(t *testing.T) {
func TestHandleStartRejectsUnapprovedInputPath(t *testing.T) {
server := newTestServer(t)
server.inputPaths = NewCatalogPathAuthorizer(staticCatalogPaths{})
server.inputPaths = NewMediaRootAuthorizer(staticMediaFolders{})
body := []byte(`{"session_id":"unsafe-input","input_path":"http://example.test/movie.mkv"}`)
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(body))
-82
View File
@@ -1,82 +0,0 @@
package userdb
import (
"database/sql"
"fmt"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// CollectionSortPreference is an alias for the canonical type in userstore.
type CollectionSortPreference = userstore.CollectionSortPreference
// SetCollectionSortPreference creates or replaces a profile's sort override for
// a collection. Timestamps should be ISO 8601 UTC strings.
func SetCollectionSortPreference(db *sql.DB, pref CollectionSortPreference) error {
if pref.UpdatedAt == "" {
pref.UpdatedAt = nowUTC()
}
_, err := db.Exec(`
INSERT INTO collection_sort_preferences (
profile_id, collection_kind, collection_id, sort_field, sort_order, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id, collection_kind, collection_id) DO UPDATE SET
sort_field = excluded.sort_field,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at`,
pref.ProfileID,
pref.CollectionKind,
pref.CollectionID,
pref.SortField,
pref.SortOrder,
pref.UpdatedAt,
)
if err != nil {
return fmt.Errorf("setting collection sort preference for profile %q collection %q/%q: %w",
pref.ProfileID, pref.CollectionKind, pref.CollectionID, err)
}
return nil
}
// GetCollectionSortPreference returns nil (not an error) when the profile has
// never changed this collection's sort. A preference with an empty SortField
// means the viewer explicitly chose the collection's own source order.
func GetCollectionSortPreference(db *sql.DB, profileID, collectionKind, collectionID string) (*CollectionSortPreference, error) {
var pref CollectionSortPreference
err := db.QueryRow(`
SELECT profile_id, collection_kind, collection_id, sort_field, sort_order, updated_at
FROM collection_sort_preferences
WHERE profile_id = ? AND collection_kind = ? AND collection_id = ?`,
profileID, collectionKind, collectionID,
).Scan(
&pref.ProfileID,
&pref.CollectionKind,
&pref.CollectionID,
&pref.SortField,
&pref.SortOrder,
&pref.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting collection sort preference for profile %q collection %q/%q: %w",
profileID, collectionKind, collectionID, err)
}
return &pref, nil
}
// ClearCollectionSortPreference drops the override so the collection falls back
// to its creator-configured default.
func ClearCollectionSortPreference(db *sql.DB, profileID, collectionKind, collectionID string) error {
_, err := db.Exec(`
DELETE FROM collection_sort_preferences
WHERE profile_id = ? AND collection_kind = ? AND collection_id = ?`,
profileID, collectionKind, collectionID,
)
if err != nil {
return fmt.Errorf("clearing collection sort preference for profile %q collection %q/%q: %w",
profileID, collectionKind, collectionID, err)
}
return nil
}
-5
View File
@@ -243,11 +243,6 @@ func DeleteCollection(db *sql.DB, id string) error {
if _, err := tx.Exec(`DELETE FROM personal_collection_items WHERE collection_id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`
DELETE FROM collection_sort_preferences
WHERE collection_kind = 'user' AND collection_id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM personal_collections WHERE id = ?`, id); err != nil {
return err
}
-12
View File
@@ -27,14 +27,6 @@ func TestSQLiteProgressSince(t *testing.T) {
storetest.RunProgressSince(t, newConformanceStore)
}
// TestSQLiteMarkWatchedBatch runs the batch mark-watched conformance test
// (series/season mark-watched) against the real SQLite backend. The Postgres
// backend runs the same suite in internal/userstore/pgstore, which is what
// keeps the two transactional implementations from drifting.
func TestSQLiteMarkWatchedBatch(t *testing.T) {
storetest.RunMarkWatchedBatch(t, newConformanceStore)
}
// TestSQLiteSettingValues runs the canonical settings-contract storage
// conformance tests against the per-user SQLite backend. The Postgres backend
// runs the same suite in internal/userstore/pgstore, which is what keeps the two
@@ -50,10 +42,6 @@ func TestSQLiteJellycompatDisplayPrefs(t *testing.T) {
storetest.RunJellycompatDisplayPrefs(t, newConformanceStore)
}
func TestSQLiteCollectionSortPreferences(t *testing.T) {
storetest.RunCollectionSortPreferences(t, newConformanceStore)
}
func TestSQLiteAddFavoriteAtReportsInsertion(t *testing.T) {
ctx := context.Background()
store := newConformanceStore(t)
+1 -20
View File
@@ -5,7 +5,7 @@ import (
"fmt"
)
const schemaVersion = 19
const schemaVersion = 18
func runMigrations(db *sql.DB) error {
version, err := userVersion(db)
@@ -178,28 +178,9 @@ func runMigrations(db *sql.DB) error {
}
}
if version < 19 {
if err := migrateToV19(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 19"); err != nil {
return fmt.Errorf("setting sqlite user_version 19: %w", err)
}
}
return tx.Commit()
}
// migrateToV19 adds the per-profile collection sort override table. An empty
// sort_field is a real choice ("keep this collection's own source order") and
// is distinct from having no row.
func migrateToV19(tx *sql.Tx) error {
if _, err := tx.Exec(collectionSortPreferencesSchema); err != nil {
return fmt.Errorf("creating collection_sort_preferences: %w", err)
}
return nil
}
// migrateToV18 seeds the family-neutral navigation shortcut catalog from the
// existing web sidebar pins. InitSchema has already rebuilt
// user_setting_values with the profile_client identity before this versioned
-42
View File
@@ -1,42 +0,0 @@
package userdb
import (
"database/sql"
"testing"
)
// An existing v18 store from main must gain the collection sort preferences
// table without replaying or replacing any of main's earlier migrations.
func TestMigrateToV19AddsCollectionSortPreferences(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
if _, err := db.Exec(`DROP TABLE IF EXISTS collection_sort_preferences`); err != nil {
t.Fatalf("drop table: %v", err)
}
if _, err := db.Exec("PRAGMA user_version = 18"); err != nil {
t.Fatalf("set user_version: %v", err)
}
if err := runMigrations(db); err != nil {
t.Fatalf("runMigrations: %v", err)
}
version, err := userVersion(db)
if err != nil {
t.Fatalf("userVersion: %v", err)
}
if version != schemaVersion {
t.Fatalf("user_version = %d, want %d", version, schemaVersion)
}
var tableName string
if err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'collection_sort_preferences'`).Scan(&tableName); err != nil {
t.Fatalf("collection_sort_preferences table: %v", err)
}
}
-12
View File
@@ -292,17 +292,6 @@ func DeleteProfile(db *sql.DB, id string) error {
}
defer tx.Rollback() //nolint:errcheck
// Preferences belong to viewers, not creators, so deleting only this
// profile's rows below would leave other profiles' overrides pointing at
// personal collections that are about to be removed.
if _, err := tx.Exec(`
DELETE FROM collection_sort_preferences
WHERE collection_kind = 'user' AND collection_id IN (
SELECT id FROM personal_collections WHERE creator_profile_id = ?
)`, id); err != nil {
return fmt.Errorf("deleting collection sort preferences for profile %s: %w", id, err)
}
// Delete personal_collection_items for collections owned by this profile.
_, err = tx.Exec(`
DELETE FROM personal_collection_items
@@ -323,7 +312,6 @@ func DeleteProfile(db *sql.DB, id string) error {
"favorites",
"watchlist",
"watch_progress",
"collection_sort_preferences",
"personal_collections",
"profile_allowed_libraries",
"series_playback_preferences",
+19 -136
View File
@@ -1,7 +1,6 @@
package userdb
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -172,9 +171,14 @@ func SetProgressIfNewer(db *sql.DB, profileID, mediaItemID string, position, dur
return rows > 0, nil
}
// markWatchedProgressSQL upserts one completed progress row, honoring the
// hidden-history watermark.
const markWatchedProgressSQL = `
// MarkWatched creates or replaces progress with a completed entry.
func MarkWatched(db *sql.DB, profileID, mediaItemID string, duration float64) error {
if duration < 0 {
duration = 0
}
now := nowUTC()
query := `
INSERT INTO watch_progress (profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT ?, ?, 0, ?, 1, ` + visibleTimestampSQL + `
FROM (SELECT 1) seed
@@ -189,133 +193,7 @@ const markWatchedProgressSQL = `
updated_at = excluded.updated_at,
event_at = excluded.updated_at
`
// markWatchedBatchProgressSQL is markWatchedProgressSQL with one difference: a
// zero duration leaves any known duration in place. The batch path now also
// serves jellycompat's series mark-played, which supplies no durations and
// previously ran through MarkProgressBatch without touching the column.
const markWatchedBatchProgressSQL = `
INSERT INTO watch_progress (profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT ?, ?, 0, ?, 1, ` + visibleTimestampSQL + `
FROM (SELECT 1) seed
LEFT JOIN hidden_history_items hhi
ON hhi.profile_id = ?
AND hhi.media_item_id = ?
WHERE true
ON CONFLICT(profile_id, media_item_id) DO UPDATE SET
position_seconds = 0,
duration_seconds = CASE
WHEN excluded.duration_seconds > 0 THEN excluded.duration_seconds
ELSE watch_progress.duration_seconds
END,
completed = 1,
updated_at = excluded.updated_at,
event_at = excluded.updated_at
`
// addVisibleHistorySQL inserts one history row at a watermark-adjusted
// timestamp and returns the timestamp actually stored.
const addVisibleHistorySQL = `
INSERT INTO watch_history (id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
SELECT ?, ?, ?, ` + visibleTimestampSQL + `, ?, ?, ?, ?
FROM (SELECT 1) seed
LEFT JOIN hidden_history_items hhi
ON hhi.profile_id = ?
AND hhi.media_item_id = ?
WHERE true
RETURNING watched_at
`
// MarkWatchedBatch marks every target watched and records its history entry
// inside a single transaction, so a canceled series mark rolls back to
// "nothing marked" rather than stranding a partial subset. SQLite round-trips
// are local and cheap, so this reuses the same per-row statements as the
// single-item path and buys atomicity rather than fewer queries.
//
// Unlike most of this package it honors ctx: cancellation safety is the whole
// point of the transaction, and a caller that disconnects mid-mark must leave
// the series untouched rather than half-watched.
func MarkWatchedBatch(
ctx context.Context,
db *sql.DB,
profileID string,
targets []userstore.MarkWatchedTarget,
entries []userstore.WatchHistoryEntry,
) ([]userstore.WatchHistoryEntry, error) {
if len(targets) == 0 {
return nil, nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin mark watched batch: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op
now := nowUTC()
seen := make(map[string]struct{}, len(targets))
for _, target := range targets {
mediaItemID := strings.TrimSpace(target.MediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seen[mediaItemID]; ok {
continue
}
seen[mediaItemID] = struct{}{}
duration := target.DurationSeconds
if duration < 0 {
duration = 0
}
if _, err := tx.ExecContext(ctx, markWatchedBatchProgressSQL,
profileID, mediaItemID, duration, now, now, profileID, mediaItemID,
); err != nil {
return nil, fmt.Errorf("marking watched batch: %w", err)
}
}
written := make([]userstore.WatchHistoryEntry, 0, len(entries))
for _, entry := range entries {
if strings.TrimSpace(entry.MediaItemID) == "" {
continue
}
if entry.ID == "" {
entry.ID = generateUUID()
}
if entry.WatchedAt == "" {
entry.WatchedAt = now
}
if entry.Source == "" {
entry.Source = userstore.WatchHistorySourceLegacy
}
identityJSON, err := json.Marshal(entry.Identity)
if err != nil {
return nil, fmt.Errorf("marshaling watch identity: %w", err)
}
if err := tx.QueryRowContext(ctx, addVisibleHistorySQL,
entry.ID, entry.ProfileID, entry.MediaItemID, entry.WatchedAt, entry.WatchedAt,
entry.DurationSeconds, entry.Completed, entry.Source, string(identityJSON),
entry.ProfileID, entry.MediaItemID,
).Scan(&entry.WatchedAt); err != nil {
return nil, fmt.Errorf("adding visible history batch: %w", err)
}
written = append(written, entry)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit mark watched batch: %w", err)
}
return written, nil
}
// MarkWatched creates or replaces progress with a completed entry.
func MarkWatched(db *sql.DB, profileID, mediaItemID string, duration float64) error {
if duration < 0 {
duration = 0
}
now := nowUTC()
_, err := db.Exec(markWatchedProgressSQL, profileID, mediaItemID, duration, now, now, profileID, mediaItemID)
_, err := db.Exec(query, profileID, mediaItemID, duration, now, now, profileID, mediaItemID)
if err != nil {
return fmt.Errorf("marking watched: %w", err)
}
@@ -706,11 +584,16 @@ func AddVisibleHistory(db *sql.DB, entry WatchHistoryEntry) (WatchHistoryEntry,
if err != nil {
return entry, fmt.Errorf("marshaling watch identity: %w", err)
}
if err := db.QueryRow(addVisibleHistorySQL,
entry.ID, entry.ProfileID, entry.MediaItemID, entry.WatchedAt, entry.WatchedAt,
entry.DurationSeconds, entry.Completed, entry.Source, string(identityJSON),
entry.ProfileID, entry.MediaItemID,
).Scan(&entry.WatchedAt); err != nil {
if err := db.QueryRow(`
INSERT INTO watch_history (id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
SELECT ?, ?, ?, `+visibleTimestampSQL+`, ?, ?, ?, ?
FROM (SELECT 1) seed
LEFT JOIN hidden_history_items hhi
ON hhi.profile_id = ?
AND hhi.media_item_id = ?
WHERE true
RETURNING watched_at
`, entry.ID, entry.ProfileID, entry.MediaItemID, entry.WatchedAt, entry.WatchedAt, entry.DurationSeconds, entry.Completed, entry.Source, string(identityJSON), entry.ProfileID, entry.MediaItemID).Scan(&entry.WatchedAt); err != nil {
return entry, fmt.Errorf("adding visible history entry: %w", err)
}
return entry, nil
-28
View File
@@ -124,18 +124,6 @@ CREATE TABLE IF NOT EXISTS personal_collections (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS collection_sort_preferences (
profile_id TEXT NOT NULL,
collection_kind TEXT NOT NULL,
collection_id TEXT NOT NULL,
sort_field TEXT NOT NULL DEFAULT '',
sort_order TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, collection_kind, collection_id),
CHECK (collection_kind IN ('library', 'user')),
CHECK (sort_order IN ('', 'asc', 'desc'))
);
CREATE TABLE IF NOT EXISTS personal_collection_items (
collection_id TEXT NOT NULL,
media_item_id TEXT NOT NULL,
@@ -507,22 +495,6 @@ CREATE INDEX user_setting_values_library_idx
return nil
}
// collectionSortPreferencesSchema is kept as its own const (rather than only
// inlined in Schema) so migrateToV19 can create the table on databases that
// predate it. collection_kind separates the 'library' and 'user' id spaces.
const collectionSortPreferencesSchema = `
CREATE TABLE IF NOT EXISTS collection_sort_preferences (
profile_id TEXT NOT NULL,
collection_kind TEXT NOT NULL,
collection_id TEXT NOT NULL,
sort_field TEXT NOT NULL DEFAULT '',
sort_order TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, collection_kind, collection_id),
CHECK (collection_kind IN ('library', 'user')),
CHECK (sort_order IN ('', 'asc', 'desc'))
);`
// watchProgressSyncTriggers stamps the server-owned cursor (synced_seq) on every
// watch_progress write and owns the LWW key: event_at defaults to the write
// time, and an UPDATE that changed updated_at without explicitly changing
-19
View File
@@ -24,7 +24,6 @@ func NewSQLiteUserStore(db *sql.DB) *SQLiteUserStore {
// Compile-time interface check.
var _ userstore.UserStore = (*SQLiteUserStore)(nil)
var _ userstore.DeviceRegistry = (*SQLiteUserStore)(nil)
var _ userstore.WatchedBatchWriter = (*SQLiteUserStore)(nil)
// --- Profiles ---
@@ -82,12 +81,6 @@ func (s *SQLiteUserStore) ClearProgress(_ context.Context, profileID, mediaItemI
return ClearProgress(s.db, profileID, mediaItemID)
}
// MarkWatchedBatch is one of the few paths here that forwards ctx: the write
// is transactional precisely so a canceled request marks nothing.
func (s *SQLiteUserStore) MarkWatchedBatch(ctx context.Context, profileID string, targets []userstore.MarkWatchedTarget, entries []userstore.WatchHistoryEntry) ([]userstore.WatchHistoryEntry, error) {
return MarkWatchedBatch(ctx, s.db, profileID, targets, entries)
}
func (s *SQLiteUserStore) MarkProgressBatch(_ context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error {
return MarkProgressBatch(s.db, profileID, mediaItemIDs, updatedAt)
}
@@ -429,18 +422,6 @@ func (s *SQLiteUserStore) DeleteSeriesPlaybackPreference(_ context.Context, prof
return DeleteSeriesPlaybackPreference(s.db, profileID, seriesID)
}
func (s *SQLiteUserStore) SetCollectionSortPreference(_ context.Context, pref userstore.CollectionSortPreference) error {
return SetCollectionSortPreference(s.db, pref)
}
func (s *SQLiteUserStore) GetCollectionSortPreference(_ context.Context, profileID, collectionKind, collectionID string) (*userstore.CollectionSortPreference, error) {
return GetCollectionSortPreference(s.db, profileID, collectionKind, collectionID)
}
func (s *SQLiteUserStore) ClearCollectionSortPreference(_ context.Context, profileID, collectionKind, collectionID string) error {
return ClearCollectionSortPreference(s.db, profileID, collectionKind, collectionID)
}
func (s *SQLiteUserStore) GetLibraryPlaybackPreference(_ context.Context, profileID string, libraryID int) (*userstore.LibraryPlaybackPreference, error) {
return GetLibraryPlaybackPreference(s.db, profileID, libraryID)
}
@@ -1,62 +0,0 @@
package userstore
import (
"context"
"log/slog"
"sort"
)
// CollectionSortPreferenceCleaner removes cross-user viewer preferences when
// a shared library collection is deleted. Library collections live in the
// central catalog while preferences may live in either shared PostgreSQL or
// per-user SQLite, so database foreign keys cannot provide this cleanup.
type CollectionSortPreferenceCleaner struct {
users UserLister
stores UserStoreProvider
logger *slog.Logger
}
// NewCollectionSortPreferenceCleaner wires the cross-user cleanup sweep.
func NewCollectionSortPreferenceCleaner(users UserLister, stores UserStoreProvider) *CollectionSortPreferenceCleaner {
return &CollectionSortPreferenceCleaner{
users: users,
stores: stores,
logger: slog.Default().With("component", "userstore.collection_sort_cleanup"),
}
}
// DeleteForCollection clears every profile's preference for one collection.
// Failures are logged per user and do not undo the owning collection delete;
// stale rows are unreachable because preference writes and reads both require
// the collection to remain accessible.
func (c *CollectionSortPreferenceCleaner) DeleteForCollection(ctx context.Context, kind, collectionID string) {
if c == nil || c.users == nil || c.stores == nil {
return
}
users, err := c.users.List(ctx)
if err != nil {
c.logger.WarnContext(ctx, "collection sort cleanup: list users failed", "kind", kind, "collection_id", collectionID, "error", err)
return
}
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
for _, user := range users {
if ctx.Err() != nil {
return
}
store, err := c.stores.ForUser(ctx, user.ID)
if err != nil {
c.logger.WarnContext(ctx, "collection sort cleanup: open user store failed", "kind", kind, "collection_id", collectionID, "user_id", user.ID, "error", err)
continue
}
profiles, err := store.ListProfiles(ctx)
if err != nil {
c.logger.WarnContext(ctx, "collection sort cleanup: list profiles failed", "kind", kind, "collection_id", collectionID, "user_id", user.ID, "error", err)
continue
}
for _, profile := range profiles {
if err := store.ClearCollectionSortPreference(ctx, profile.ID, kind, collectionID); err != nil {
c.logger.WarnContext(ctx, "collection sort cleanup: delete failed", "kind", kind, "collection_id", collectionID, "user_id", user.ID, "profile_id", profile.ID, "error", err)
}
}
}
}
@@ -1,62 +0,0 @@
package userstore_test
import (
"context"
"path/filepath"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type cleanupUserLister struct {
users []*models.User
}
func (l cleanupUserLister) List(context.Context) ([]*models.User, error) { return l.users, nil }
type cleanupStoreProvider struct {
stores map[int]userstore.UserStore
}
func (p cleanupStoreProvider) ForUser(_ context.Context, userID int) (userstore.UserStore, error) {
return p.stores[userID], nil
}
func (cleanupStoreProvider) Close() error { return nil }
func TestCollectionSortPreferenceCleanerDeletesAcrossUsers(t *testing.T) {
ctx := context.Background()
provider := cleanupStoreProvider{stores: make(map[int]userstore.UserStore)}
for _, userID := range []int{1, 2} {
db, err := userdb.NewUserDB(filepath.Join(t.TempDir(), "user.db"), userID)
if err != nil {
t.Fatalf("open user %d database: %v", userID, err)
}
t.Cleanup(func() { _ = db.Close() })
store := userdb.NewSQLiteUserStore(db.DB)
profileID := "profile-" + string(rune('0'+userID))
if err := store.CreateProfile(ctx, userstore.Profile{ID: profileID, Name: profileID}); err != nil {
t.Fatalf("create profile: %v", err)
}
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: profileID, CollectionKind: userstore.CollectionKindLibrary,
CollectionID: "library-collection", SortField: "title", SortOrder: "asc",
}); err != nil {
t.Fatalf("set preference: %v", err)
}
provider.stores[userID] = store
}
cleaner := userstore.NewCollectionSortPreferenceCleaner(cleanupUserLister{users: []*models.User{{ID: 1}, {ID: 2}}}, provider)
cleaner.DeleteForCollection(ctx, userstore.CollectionKindLibrary, "library-collection")
for userID, store := range provider.stores {
profileID := "profile-" + string(rune('0'+userID))
pref, err := store.GetCollectionSortPreference(ctx, profileID, userstore.CollectionKindLibrary, "library-collection")
if err != nil || pref != nil {
t.Fatalf("user %d preference = %+v, err = %v; want nil", userID, pref, err)
}
}
}
@@ -1,85 +0,0 @@
package pgstore
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func (s *PostgresUserStore) SetCollectionSortPreference(ctx context.Context, pref userstore.CollectionSortPreference) error {
if pref.UpdatedAt == "" {
pref.UpdatedAt = nowUTC()
}
_, err := s.pool.Exec(ctx, `
INSERT INTO user_collection_sort_preferences (
user_id, profile_id, collection_kind, collection_id, sort_field, sort_order, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT(user_id, profile_id, collection_kind, collection_id) DO UPDATE SET
sort_field = excluded.sort_field,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at`,
s.userID,
pref.ProfileID,
pref.CollectionKind,
pref.CollectionID,
pref.SortField,
pref.SortOrder,
pref.UpdatedAt,
)
if err != nil {
return fmt.Errorf("setting collection sort preference for profile %q collection %q/%q: %w",
pref.ProfileID, pref.CollectionKind, pref.CollectionID, err)
}
return nil
}
// GetCollectionSortPreference returns (nil, nil) when the profile has never
// changed this collection's sort. A returned preference with an empty SortField
// means the viewer explicitly chose the collection's own source order.
func (s *PostgresUserStore) GetCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) (*userstore.CollectionSortPreference, error) {
var pref userstore.CollectionSortPreference
var updatedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT profile_id, collection_kind, collection_id, sort_field, sort_order, updated_at
FROM user_collection_sort_preferences
WHERE user_id = $1 AND profile_id = $2 AND collection_kind = $3 AND collection_id = $4`,
s.userID, profileID, collectionKind, collectionID,
).Scan(
&pref.ProfileID,
&pref.CollectionKind,
&pref.CollectionID,
&pref.SortField,
&pref.SortOrder,
&updatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting collection sort preference for profile %q collection %q/%q: %w",
profileID, collectionKind, collectionID, err)
}
pref.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
return &pref, nil
}
// ClearCollectionSortPreference drops the override so the collection falls back
// to its creator-configured default. Distinct from storing an empty SortField,
// which pins the viewer to source order.
func (s *PostgresUserStore) ClearCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM user_collection_sort_preferences
WHERE user_id = $1 AND profile_id = $2 AND collection_kind = $3 AND collection_id = $4`,
s.userID, profileID, collectionKind, collectionID,
)
if err != nil {
return fmt.Errorf("clearing collection sort preference for profile %q collection %q/%q: %w",
profileID, collectionKind, collectionID, err)
}
return nil
}
@@ -365,11 +365,6 @@ func (s *PostgresUserStore) DeleteCollection(ctx context.Context, id string) err
if _, err := tx.Exec(ctx, `DELETE FROM user_personal_collection_items WHERE user_id = $1 AND collection_id = $2`, s.userID, id); err != nil {
return fmt.Errorf("deleting collection items: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM user_collection_sort_preferences
WHERE user_id = $1 AND collection_kind = 'user' AND collection_id = $2`, s.userID, id); err != nil {
return fmt.Errorf("deleting collection sort preferences: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM user_personal_collections WHERE user_id = $1 AND id = $2`, s.userID, id); err != nil {
return fmt.Errorf("deleting collection: %w", err)
}
@@ -58,42 +58,6 @@ func TestPostgresProgressSince(t *testing.T) {
})
}
// TestPostgresMarkWatchedBatch runs the batch mark-watched conformance test
// (series/season mark-watched) against the Postgres backend. The per-user
// SQLite backend runs the same suite in internal/userdb, which is what keeps
// the two transactional implementations from drifting. Skips unless
// SILO_TEST_DATABASE_URL is set.
func TestPostgresMarkWatchedBatch(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
storetest.RunMarkWatchedBatch(t, func(t *testing.T) userstore.UserStore {
var userID int
if err := pool.QueryRow(ctx,
`INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`,
fmt.Sprintf("conf-markwatched-%d", time.Now().UnixNano()),
).Scan(&userID); err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM user_watch_history WHERE user_id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM user_history_hidden_items WHERE user_id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM user_watch_progress WHERE user_id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM user_profiles WHERE user_id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
})
return newStore(pool, userID)
})
}
// TestPostgresSettingValues runs the canonical settings-contract storage
// conformance tests against the Postgres backend. The per-user SQLite backend
// runs the same suite in internal/userdb, which is what keeps the two from
@@ -207,40 +171,3 @@ func TestPostgresJellycompatDisplayPrefs(t *testing.T) {
return newStore(pool, userID)
})
}
func TestPostgresCollectionSortPreferences(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx,
`SELECT to_regclass('public.user_collection_sort_preferences')::text`).Scan(&tableName); err != nil {
t.Fatalf("check preference table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("collection sort preference migration has not been applied")
}
storetest.RunCollectionSortPreferences(t, func(t *testing.T) userstore.UserStore {
var userID int
if err := pool.QueryRow(ctx,
`INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`,
fmt.Sprintf("sort-pref-conf-%d", time.Now().UnixNano()),
).Scan(&userID); err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
deleteUserAssertingCascade(t, pool, userID,
"user_collection_sort_preferences", "user_profiles")
})
return newStore(pool, userID)
})
}
-1
View File
@@ -19,7 +19,6 @@ type PostgresUserStore struct {
// Compile-time interface check.
var _ userstore.UserStore = (*PostgresUserStore)(nil)
var _ userstore.DeviceRegistry = (*PostgresUserStore)(nil)
var _ userstore.WatchedBatchWriter = (*PostgresUserStore)(nil)
// newStore creates a PostgresUserStore scoped to a user.
func newStore(pool *pgxpool.Pool, userID int) *PostgresUserStore {
-11
View File
@@ -282,16 +282,6 @@ func (s *PostgresUserStore) DeleteProfile(ctx context.Context, id string) error
}
defer tx.Rollback(ctx) //nolint:errcheck
// Preferences belong to viewers, not creators, so remove every override
// for collections owned by the profile before those collections disappear.
if _, err := tx.Exec(ctx, `
DELETE FROM user_collection_sort_preferences
WHERE user_id = $1 AND collection_kind = 'user' AND collection_id IN (
SELECT id FROM user_personal_collections WHERE user_id = $1 AND profile_id = $2
)`, s.userID, id); err != nil {
return fmt.Errorf("deleting collection sort preferences for profile %s: %w", id, err)
}
_, err = tx.Exec(ctx, `
DELETE FROM user_personal_collection_items
WHERE user_id = $1 AND collection_id IN (
@@ -309,7 +299,6 @@ func (s *PostgresUserStore) DeleteProfile(ctx context.Context, id string) error
"user_favorites",
"user_watchlist",
"user_watch_progress",
"user_collection_sort_preferences",
"user_personal_collections",
"user_series_playback_preferences",
"user_library_playback_preferences",
-203
View File
@@ -316,209 +316,6 @@ func (s *PostgresUserStore) ClearProgress(ctx context.Context, profileID, mediaI
return nil
}
// MarkWatchedBatch marks every target watched and inserts its history row in
// one transaction, so a series mark either lands whole or not at all. The
// prior per-episode loop committed each episode separately, which left a large
// series half-marked whenever the client disconnected mid-request.
//
// Two statements rather than a reuse of MarkProgressBatch: that one hardcodes
// duration_seconds = 0, and the manual mark-watched path must preserve each
// episode's real duration. Both statements carry the same
// user_history_hidden_items watermark logic as their single-row counterparts
// (MarkWatched, AddVisibleHistory) so a mark after a history removal stays
// visible.
func (s *PostgresUserStore) MarkWatchedBatch(
ctx context.Context,
profileID string,
targets []userstore.MarkWatchedTarget,
entries []userstore.WatchHistoryEntry,
) ([]userstore.WatchHistoryEntry, error) {
if len(targets) == 0 {
return nil, nil
}
mediaItemIDs := make([]string, 0, len(targets))
durations := make([]float64, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
for _, target := range targets {
mediaItemID := strings.TrimSpace(target.MediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seen[mediaItemID]; ok {
continue
}
seen[mediaItemID] = struct{}{}
duration := target.DurationSeconds
if duration < 0 {
duration = 0
}
mediaItemIDs = append(mediaItemIDs, mediaItemID)
durations = append(durations, duration)
}
if len(mediaItemIDs) == 0 {
return nil, nil
}
now := time.Now().UTC()
historyIDs := make([]string, 0, len(entries))
historyItemIDs := make([]string, 0, len(entries))
historyWatchedAt := make([]time.Time, 0, len(entries))
historyDurations := make([]float64, 0, len(entries))
historyCompleted := make([]bool, 0, len(entries))
historySources := make([]string, 0, len(entries))
historyIdentities := make([]string, 0, len(entries))
// Entries with a blank media ID are dropped, so keep an explicit map back
// to the caller's slice rather than assuming positional alignment.
historySourceIndex := make([]int, 0, len(entries))
for entryIndex, entry := range entries {
mediaItemID := strings.TrimSpace(entry.MediaItemID)
if mediaItemID == "" {
continue
}
id := entry.ID
if id == "" {
id = generateUUID()
}
watchedAt := now
if entry.WatchedAt != "" {
parsed, err := time.Parse(time.RFC3339, entry.WatchedAt)
if err != nil {
return nil, fmt.Errorf("parsing watched_at for %s: %w", mediaItemID, err)
}
watchedAt = parsed.UTC()
}
source := entry.Source
if source == "" {
source = userstore.WatchHistorySourceLegacy
}
identityJSON, err := json.Marshal(entry.Identity)
if err != nil {
return nil, fmt.Errorf("marshaling watch identity: %w", err)
}
historyIDs = append(historyIDs, id)
historyItemIDs = append(historyItemIDs, mediaItemID)
historyWatchedAt = append(historyWatchedAt, watchedAt)
historyDurations = append(historyDurations, entry.DurationSeconds)
historyCompleted = append(historyCompleted, entry.Completed)
historySources = append(historySources, string(source))
historyIdentities = append(historyIdentities, string(identityJSON))
historySourceIndex = append(historySourceIndex, entryIndex)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin mark watched batch: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `
WITH target(media_item_id, duration_seconds) AS (
SELECT * FROM unnest($3::text[], $4::double precision[])
),
visible AS (
SELECT
t.media_item_id,
t.duration_seconds,
CASE
WHEN hhi.hidden_before IS NOT NULL AND $5::timestamptz <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE $5::timestamptz
END AS updated_at
FROM target t
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = t.media_item_id
)
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
SELECT $1, $2, media_item_id, 0, duration_seconds, TRUE, updated_at
FROM visible
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = 0,
-- Keep a known duration when the caller has none: jellycompat's
-- series mark-played passes 0 for every target, and its previous
-- MarkProgressBatch path left duration_seconds untouched.
duration_seconds = CASE
WHEN EXCLUDED.duration_seconds > 0 THEN EXCLUDED.duration_seconds
ELSE user_watch_progress.duration_seconds
END,
completed = TRUE,
updated_at = EXCLUDED.updated_at,
event_at = EXCLUDED.updated_at`,
s.userID, profileID, mediaItemIDs, durations, now,
); err != nil {
return nil, fmt.Errorf("marking watched batch: %w", err)
}
written := make([]userstore.WatchHistoryEntry, 0, len(historyIDs))
if len(historyIDs) > 0 {
rows, err := tx.Query(ctx, `
WITH entry(id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity, ord) AS (
SELECT * FROM unnest(
$3::text[], $4::text[], $5::timestamptz[], $6::double precision[],
$7::boolean[], $8::text[], $9::jsonb[]
) WITH ORDINALITY
)
INSERT INTO user_watch_history
(id, user_id, profile_id, media_item_id, watched_at, duration_seconds, completed, source, watch_identity)
SELECT
e.id, $1, $2, e.media_item_id,
CASE
WHEN hhi.hidden_before IS NOT NULL AND e.watched_at <= hhi.hidden_before
THEN hhi.hidden_before + interval '1 second'
ELSE e.watched_at
END,
e.duration_seconds, e.completed, e.source, e.watch_identity
FROM entry e
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $1
AND hhi.profile_id = $2
AND hhi.media_item_id = e.media_item_id
ORDER BY e.ord
RETURNING id, media_item_id, watched_at`,
s.userID, profileID, historyIDs, historyItemIDs, historyWatchedAt,
historyDurations, historyCompleted, historySources, historyIdentities,
)
if err != nil {
return nil, fmt.Errorf("adding visible history batch: %w", err)
}
resolved := make(map[string]time.Time, len(historyIDs))
for rows.Next() {
var id, mediaItemID string
var watchedAt time.Time
if err := rows.Scan(&id, &mediaItemID, &watchedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning inserted history: %w", err)
}
resolved[id] = watchedAt
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating inserted history: %w", err)
}
for i, id := range historyIDs {
entry := entries[historySourceIndex[i]]
entry.ID = id
entry.MediaItemID = historyItemIDs[i]
if entry.Source == "" {
entry.Source = userstore.WatchHistorySource(historySources[i])
}
if watchedAt, ok := resolved[id]; ok {
entry.WatchedAt = timeToString(watchedAt)
}
written = append(written, entry)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit mark watched batch: %w", err)
}
return written, nil
}
// MarkProgressBatch upserts a `completed = TRUE` progress row for every entry
// in mediaItemIDs in a single statement. Used by the jellycompat series
// mark-played path so a 200-episode mark collapses to one INSERT.
-50
View File
@@ -31,56 +31,6 @@ func AddVisibleHistory(ctx context.Context, store UserStore, entry WatchHistoryE
return entry, nil
}
// MarkWatchedTarget is one leaf item to mark watched, carrying the duration
// that belongs on its progress row.
type MarkWatchedTarget struct {
MediaItemID string
DurationSeconds float64
}
// WatchedBatchWriter is an optional store capability: mark every target
// watched and insert its history row in a single transaction, so a canceled
// request leaves nothing marked instead of a partial subset. Marking a large
// series is the motivating case — the per-target fallback below commits each
// episode independently, so losing the request mid-loop strands the series
// half-watched.
//
// Semantics must match the fallback: per-target duration lands on the progress
// row, each entry gets exactly one history row, and both respect the
// hidden-history watermark the single-row MarkWatched/AddVisibleHistory paths
// apply. The returned entries carry the resolved (possibly watermark-adjusted)
// WatchedAt, in the order given.
type WatchedBatchWriter interface {
MarkWatchedBatch(ctx context.Context, profileID string, targets []MarkWatchedTarget, entries []WatchHistoryEntry) ([]WatchHistoryEntry, error)
}
// MarkWatchedBatch marks every target watched and records its history entry,
// atomically where the store supports it. targets and entries are parallel:
// entries[i] is the history row for targets[i].
func MarkWatchedBatch(ctx context.Context, store UserStore, profileID string, targets []MarkWatchedTarget, entries []WatchHistoryEntry) ([]WatchHistoryEntry, error) {
if len(targets) == 0 {
return nil, nil
}
if writer, ok := store.(WatchedBatchWriter); ok {
return writer.MarkWatchedBatch(ctx, profileID, targets, entries)
}
written := make([]WatchHistoryEntry, 0, len(entries))
for i, target := range targets {
if err := store.MarkWatched(ctx, profileID, target.MediaItemID, target.DurationSeconds); err != nil {
return written, err
}
if i >= len(entries) {
continue
}
entry, err := AddVisibleHistory(ctx, store, entries[i])
if err != nil {
return written, err
}
written = append(written, entry)
}
return written, nil
}
func VisibleHistoryTimestamps(ctx context.Context, store UserStore, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
mediaItemIDs = compactHistoryMediaItemIDs(mediaItemIDs)
result := make(map[string]string, len(mediaItemIDs))
-5
View File
@@ -162,11 +162,6 @@ type UserStore interface {
DeleteAudioPreference(ctx context.Context, profileID, seriesID string) error
SetSeriesPlaybackPreference(ctx context.Context, pref SeriesPlaybackPreference) error
GetSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) (*SeriesPlaybackPreference, error)
// Collection sort preferences
SetCollectionSortPreference(ctx context.Context, pref CollectionSortPreference) error
GetCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) (*CollectionSortPreference, error)
ClearCollectionSortPreference(ctx context.Context, profileID, collectionKind, collectionID string) error
DeleteSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) error
GetLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) (*LibraryPlaybackPreference, error)
ListLibraryPlaybackPreferences(ctx context.Context, profileID string) ([]LibraryPlaybackPreference, error)
-296
View File
@@ -11,15 +11,6 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// RunMarkWatchedBatch runs only the batch mark-watched conformance test. It is
// exposed separately, like RunProgressSince, so each backend can pin the
// series/season mark-watched path without the full suite.
func RunMarkWatchedBatch(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
t.Run("MarkWatchedBatch", func(t *testing.T) {
testMarkWatchedBatch(t, newStore)
})
}
// RunProgressSince runs only the offline-sync progress-reconciliation
// conformance test (invariant 1). It is exposed separately so a backend can
// exercise the offline-sync behavior without the full suite.
@@ -35,134 +26,6 @@ func RunProgressSince(t *testing.T, newStore func(t *testing.T) userstore.UserSt
})
}
// RunCollectionSortPreferences runs the preference timestamp and profile
// lifecycle conformance checks against a UserStore implementation.
func RunCollectionSortPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
t.Run("TimestampAndProfileLifecycle", func(t *testing.T) {
testCollectionSortPreferences(t, newStore)
})
}
func testCollectionSortPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
const (
profileID = "sort-pref-profile"
viewerProfileID = "sort-pref-viewer"
titleSortField = "title"
ascendingOrder = "asc"
)
if err := store.CreateProfile(ctx, userstore.Profile{ID: profileID, Name: "Sort Pref"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if err := store.CreateProfile(ctx, userstore.Profile{ID: viewerProfileID, Name: "Sort Pref Viewer"}); err != nil {
t.Fatalf("CreateProfile(viewer): %v", err)
}
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: profileID,
CollectionKind: userstore.CollectionKindLibrary,
CollectionID: "collection-1",
SortField: titleSortField,
SortOrder: ascendingOrder,
}); err != nil {
t.Fatalf("SetCollectionSortPreference: %v", err)
}
pref, err := store.GetCollectionSortPreference(ctx, profileID, userstore.CollectionKindLibrary, "collection-1")
if err != nil || pref == nil {
t.Fatalf("GetCollectionSortPreference = %v, err = %v", pref, err)
}
if _, err := time.Parse(time.RFC3339, pref.UpdatedAt); err != nil {
t.Fatalf("UpdatedAt = %q, want RFC3339 timestamp: %v", pref.UpdatedAt, err)
}
for _, invalid := range []userstore.CollectionSortPreference{
{
ProfileID: profileID,
CollectionKind: "invalid",
CollectionID: "collection-invalid-kind",
SortField: titleSortField,
SortOrder: ascendingOrder,
},
{
ProfileID: profileID,
CollectionKind: userstore.CollectionKindLibrary,
CollectionID: "collection-invalid-order",
SortField: titleSortField,
SortOrder: "sideways",
},
} {
if err := store.SetCollectionSortPreference(ctx, invalid); err == nil {
t.Fatalf("SetCollectionSortPreference accepted invalid preference: %+v", invalid)
}
}
deletedCollection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: "Deleted sort preference target",
})
if err != nil {
t.Fatalf("CreateCollection(delete target): %v", err)
}
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: viewerProfileID,
CollectionKind: userstore.CollectionKindUser,
CollectionID: deletedCollection.ID,
SortField: "year",
SortOrder: "desc",
}); err != nil {
t.Fatalf("SetCollectionSortPreference(delete target): %v", err)
}
if err := store.DeleteCollection(ctx, deletedCollection.ID); err != nil {
t.Fatalf("DeleteCollection: %v", err)
}
deletedPref, err := store.GetCollectionSortPreference(ctx, viewerProfileID, userstore.CollectionKindUser, deletedCollection.ID)
if err != nil {
t.Fatalf("GetCollectionSortPreference(deleted collection): %v", err)
}
if deletedPref != nil {
t.Fatalf("preference survived collection deletion: %+v", deletedPref)
}
profileDeletedCollection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: "Profile-deleted sort preference target",
})
if err != nil {
t.Fatalf("CreateCollection(profile delete target): %v", err)
}
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: viewerProfileID,
CollectionKind: userstore.CollectionKindUser,
CollectionID: profileDeletedCollection.ID,
SortField: titleSortField,
SortOrder: ascendingOrder,
}); err != nil {
t.Fatalf("SetCollectionSortPreference(profile delete target): %v", err)
}
if err := store.DeleteProfile(ctx, profileID); err != nil {
t.Fatalf("DeleteProfile: %v", err)
}
deletedPref, err = store.GetCollectionSortPreference(ctx, viewerProfileID, userstore.CollectionKindUser, profileDeletedCollection.ID)
if err != nil {
t.Fatalf("GetCollectionSortPreference(profile-deleted collection): %v", err)
}
if deletedPref != nil {
t.Fatalf("preference survived creator profile deletion: %+v", deletedPref)
}
if err := store.CreateProfile(ctx, userstore.Profile{ID: profileID, Name: "Recreated"}); err != nil {
t.Fatalf("CreateProfile(recreate): %v", err)
}
pref, err = store.GetCollectionSortPreference(ctx, profileID, userstore.CollectionKindLibrary, "collection-1")
if err != nil {
t.Fatalf("GetCollectionSortPreference(recreated profile): %v", err)
}
if pref != nil {
t.Fatalf("stale preference survived profile recreation: %+v", pref)
}
}
// RunSuite runs all conformance tests against a UserStore implementation.
// The newStore function should return a fresh, empty store for each test.
func RunSuite(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
@@ -181,9 +44,6 @@ func RunSuite(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
t.Run("BatchWritesAdvanceEventAt", func(t *testing.T) {
testBatchWritesAdvanceEventAt(t, newStore)
})
t.Run("MarkWatchedBatch", func(t *testing.T) {
testMarkWatchedBatch(t, newStore)
})
t.Run("Favorites", func(t *testing.T) {
testFavorites(t, newStore)
})
@@ -1144,162 +1004,6 @@ func testOnlineWriteAdvancesEventAt(t *testing.T, newStore func(t *testing.T) us
}
}
// testMarkWatchedBatch pins the batch mark-watched path used by the series and
// season mark-watched handlers. It must be equivalent to a MarkWatched +
// AddVisibleHistory loop: per-target durations land on progress rows, exactly
// one history row appears per target, the hidden-history watermark still
// pushes a mark after a removal back into visibility, and a zero duration
// leaves a known duration alone (jellycompat's mark-played supplies none).
func testMarkWatchedBatch(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
watchedAt := time.Date(2026, 5, 2, 9, 0, 0, 0, time.UTC)
targets := []userstore.MarkWatchedTarget{
{MediaItemID: "ep-1", DurationSeconds: 1200},
{MediaItemID: "ep-2", DurationSeconds: 1500},
}
entries := []userstore.WatchHistoryEntry{
{
ProfileID: "p1",
MediaItemID: "ep-1",
WatchedAt: watchedAt.Format(time.RFC3339),
DurationSeconds: 1200,
Completed: true,
Source: userstore.WatchHistorySourceManual,
},
{
ProfileID: "p1",
MediaItemID: "ep-2",
WatchedAt: watchedAt.Format(time.RFC3339),
DurationSeconds: 1500,
Completed: true,
Source: userstore.WatchHistorySourceManual,
},
}
written, err := userstore.MarkWatchedBatch(ctx, store, "p1", targets, entries)
if err != nil {
t.Fatalf("MarkWatchedBatch: %v", err)
}
if len(written) != 2 {
t.Fatalf("MarkWatchedBatch returned %d entries, want 2", len(written))
}
for _, entry := range written {
if entry.ID == "" {
t.Fatalf("returned entry %s has no ID; callers relay these to watch providers", entry.MediaItemID)
}
if entry.WatchedAt == "" {
t.Fatalf("returned entry %s has no WatchedAt", entry.MediaItemID)
}
}
for _, want := range targets {
progress, err := store.GetProgress(ctx, "p1", want.MediaItemID)
if err != nil {
t.Fatalf("GetProgress(%s): %v", want.MediaItemID, err)
}
if progress == nil || !progress.Completed {
t.Fatalf("GetProgress(%s) = %+v, want completed", want.MediaItemID, progress)
}
if progress.DurationSeconds != want.DurationSeconds {
t.Fatalf("GetProgress(%s) duration = %v, want %v — per-target durations must survive the batch",
want.MediaItemID, progress.DurationSeconds, want.DurationSeconds)
}
if progress.PositionSeconds != 0 {
t.Fatalf("GetProgress(%s) position = %v, want 0", want.MediaItemID, progress.PositionSeconds)
}
}
history, err := store.ListHistory(ctx, "p1", 50, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
counts := map[string]int{}
for _, entry := range history {
counts[entry.MediaItemID]++
}
if counts["ep-1"] != 1 || counts["ep-2"] != 1 {
t.Fatalf("history counts = %v, want exactly one row per target", counts)
}
// A zero duration must not erase a duration the store already knows.
if _, err := userstore.MarkWatchedBatch(ctx, store, "p1",
[]userstore.MarkWatchedTarget{{MediaItemID: "ep-1"}},
[]userstore.WatchHistoryEntry{{
ProfileID: "p1",
MediaItemID: "ep-1",
WatchedAt: watchedAt.Add(time.Hour).Format(time.RFC3339),
Completed: true,
Source: userstore.WatchHistorySourceJellycompat,
}},
); err != nil {
t.Fatalf("MarkWatchedBatch(zero duration): %v", err)
}
progress, err := store.GetProgress(ctx, "p1", "ep-1")
if err != nil || progress == nil {
t.Fatalf("GetProgress(ep-1 after zero-duration mark) = %+v (%v)", progress, err)
}
if progress.DurationSeconds != 1200 {
t.Fatalf("duration = %v, want 1200 — a zero-duration mark must not clear a known duration",
progress.DurationSeconds)
}
// Marking watched after a history removal must land after the hidden
// watermark, otherwise the new mark is invisible.
removedAt := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
if err := store.RemoveHistoryItems(ctx, "p1", []string{"ep-2"}, removedAt); err != nil {
t.Fatalf("RemoveHistoryItems(ep-2): %v", err)
}
remarked, err := userstore.MarkWatchedBatch(ctx, store, "p1",
[]userstore.MarkWatchedTarget{{MediaItemID: "ep-2", DurationSeconds: 1500}},
[]userstore.WatchHistoryEntry{{
ProfileID: "p1",
MediaItemID: "ep-2",
WatchedAt: watchedAt.Format(time.RFC3339), // older than the removal
DurationSeconds: 1500,
Completed: true,
Source: userstore.WatchHistorySourceManual,
}},
)
if err != nil {
t.Fatalf("MarkWatchedBatch(after removal): %v", err)
}
if len(remarked) != 1 {
t.Fatalf("re-mark returned %d entries, want 1", len(remarked))
}
if remarked[0].WatchedAt <= removedAt.Format(time.RFC3339) {
t.Fatalf("re-marked WatchedAt = %q, want later than the %q removal watermark",
remarked[0].WatchedAt, removedAt.Format(time.RFC3339))
}
completed, err := store.ListCompletedHistoryItems(ctx, userstore.CompletedHistoryItemQuery{
ProfileID: "p1",
MediaItemIDs: []string{"ep-2"},
})
if err != nil {
t.Fatalf("ListCompletedHistoryItems(ep-2): %v", err)
}
if len(completed) != 1 {
t.Fatalf("ep-2 completed history = %v, want the re-mark to be visible", completed)
}
// Blank IDs are compacted out rather than reaching SQL.
if _, err := userstore.MarkWatchedBatch(ctx, store, "p1",
[]userstore.MarkWatchedTarget{{MediaItemID: " "}, {MediaItemID: ""}},
nil,
); err != nil {
t.Fatalf("MarkWatchedBatch(blank IDs): %v", err)
}
if blank, err := store.GetProgress(ctx, "p1", ""); err != nil {
t.Fatalf("GetProgress(blank): %v", err)
} else if blank != nil {
t.Fatalf("GetProgress(blank) = %+v, want nil", blank)
}
}
// testBatchWritesAdvanceEventAt extends the LWW guarantee to the batch write
// paths (jellycompat series mark-played and mark-unplayed): they advance
// updated_at without hand-setting event_at, so the stamping trigger must
-21
View File
@@ -376,27 +376,6 @@ type SeriesPlaybackPreference struct {
UpdatedAt string `json:"updated_at"`
}
// Collection kinds for CollectionSortPreference. Collection ids are unique
// within a kind but not across the two id spaces, so the kind is part of the
// preference's identity.
const (
CollectionKindLibrary = "library"
CollectionKindUser = "user"
)
// CollectionSortPreference records that a profile changed the sort order while
// browsing a collection, overriding whatever default the collection's creator
// configured. An empty SortField is a real choice — "show me this collection in
// its own source order" — and is distinct from having no preference row at all.
type CollectionSortPreference struct {
ProfileID string `json:"profile_id"`
CollectionKind string `json:"collection_kind"`
CollectionID string `json:"collection_id"`
SortField string `json:"sort_field"`
SortOrder string `json:"sort_order"`
UpdatedAt string `json:"updated_at"`
}
// LibraryPlaybackPreference stores per-library playback settings.
type LibraryPlaybackPreference struct {
ProfileID string `json:"profile_id"`
-159
View File
@@ -86,165 +86,6 @@ func (r *StableIdentityResolver) ResolveHistoryIdentity(ctx context.Context, med
}
}
// batchEpisodeLookup is an optional capability on the episode lookup: fetch
// many episodes in one query. catalog.EpisodeRepository implements it.
type batchEpisodeLookup interface {
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.Episode, error)
}
// batchProviderIDLookup is the same idea for provider IDs, letting a series
// mark resolve every distinct series in one query instead of one per episode.
type batchProviderIDLookup interface {
GetByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaItemProviderID, error)
}
// ResolveHistoryIdentities resolves stable identities for many media items at
// once. Marking a series watched previously resolved each episode on its own,
// costing an episode lookup plus a series provider-ID lookup per episode; this
// collapses that to one episode query plus one provider-ID query per distinct
// series. Items that resolve to nothing are absent from the map, matching
// ResolveHistoryIdentity's zero-value return.
func (r *StableIdentityResolver) ResolveHistoryIdentities(ctx context.Context, mediaItemIDs []string) map[string]userstore.WatchIdentity {
result := make(map[string]userstore.WatchIdentity, len(mediaItemIDs))
if r == nil || r.providerIDs == nil || len(mediaItemIDs) == 0 {
return result
}
ids := make([]string, 0, len(mediaItemIDs))
seen := make(map[string]struct{}, len(mediaItemIDs))
for _, mediaItemID := range mediaItemIDs {
mediaItemID = strings.TrimSpace(mediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seen[mediaItemID]; ok {
continue
}
seen[mediaItemID] = struct{}{}
ids = append(ids, mediaItemID)
}
if len(ids) == 0 {
return result
}
episodes := r.loadEpisodes(ctx, ids)
// One provider-ID lookup per distinct series covers every episode of it.
seriesIDs := make([]string, 0, len(episodes))
seenSeries := make(map[string]struct{}, len(episodes))
for _, episode := range episodes {
seriesID := strings.TrimSpace(episode.SeriesID)
if seriesID == "" {
continue
}
if _, ok := seenSeries[seriesID]; ok {
continue
}
seenSeries[seriesID] = struct{}{}
seriesIDs = append(seriesIDs, seriesID)
}
// Anything that is not an episode may still be a movie; resolve those
// provider IDs in the same batch.
movieCandidates := make([]string, 0, len(ids))
for _, mediaItemID := range ids {
if _, ok := episodes[mediaItemID]; !ok {
movieCandidates = append(movieCandidates, mediaItemID)
}
}
providerIDsByContent := r.loadProviderIDsBatch(ctx, append(append([]string{}, seriesIDs...), movieCandidates...))
for _, mediaItemID := range ids {
if episode, ok := episodes[mediaItemID]; ok {
episodeIDs := episodeProviderIDs(episode)
seriesProviderIDs := providerIDMap(providerIDsByContent[episode.SeriesID])
// Mirrors ResolveHistoryIdentity: an episode carrying its own IDs
// is addressable without the nested show fallback.
if len(episodeIDs) == 0 && len(seriesProviderIDs) == 0 {
continue
}
seasonNumber := episode.SeasonNumber
episodeNumber := episode.EpisodeNumber
result[mediaItemID] = userstore.WatchIdentity{
StableType: "episode",
ProviderIDs: episodeIDs,
SeriesProviderIDs: seriesProviderIDs,
Season: &seasonNumber,
Episode: &episodeNumber,
}
continue
}
if r.items == nil {
continue
}
item, err := r.items.GetByID(ctx, mediaItemID)
if err != nil || item == nil || item.Type != "movie" {
continue
}
itemIDs := providerIDMap(providerIDsByContent[mediaItemID])
if len(itemIDs) == 0 {
continue
}
result[mediaItemID] = userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: itemIDs,
}
}
return result
}
// loadEpisodes returns the subset of ids that are episodes, batching when the
// lookup supports it and falling back to per-ID reads otherwise.
func (r *StableIdentityResolver) loadEpisodes(ctx context.Context, ids []string) map[string]*models.Episode {
episodes := make(map[string]*models.Episode, len(ids))
if r.episodes == nil {
return episodes
}
if batch, ok := r.episodes.(batchEpisodeLookup); ok {
found, err := batch.GetByIDs(ctx, ids)
if err == nil {
for _, episode := range found {
if episode != nil {
episodes[episode.ContentID] = episode
}
}
return episodes
}
}
for _, mediaItemID := range ids {
episode, err := r.episodes.GetByID(ctx, mediaItemID)
if err == nil && episode != nil {
episodes[mediaItemID] = episode
}
}
return episodes
}
// loadProviderIDsBatch groups provider IDs by content ID, batching when the
// lookup supports it and falling back to per-ID reads otherwise.
func (r *StableIdentityResolver) loadProviderIDsBatch(ctx context.Context, contentIDs []string) map[string][]*models.MediaItemProviderID {
result := make(map[string][]*models.MediaItemProviderID, len(contentIDs))
if len(contentIDs) == 0 {
return result
}
if batch, ok := r.providerIDs.(batchProviderIDLookup); ok {
found, err := batch.GetByContentIDs(ctx, contentIDs)
if err == nil {
return found
}
}
for _, contentID := range contentIDs {
if _, ok := result[contentID]; ok {
continue
}
result[contentID] = r.loadProviderIDs(ctx, contentID)
}
return result
}
func (r *StableIdentityResolver) ResolveMovieContentID(ctx context.Context, providerIDs map[string]string) (string, error) {
if r == nil || r.providerIDs == nil {
return "", nil
+27 -49
View File
@@ -325,22 +325,12 @@ func (s *Service) recordMarkWatched(
if watchedAt.IsZero() {
watchedAt = time.Now().UTC()
}
// Resolve every identity up front: one episode query plus one provider-ID
// query per distinct series, instead of two lookups per episode.
completedIDs := make([]string, 0, len(targets))
result := ManualMarkResult{Entries: make([]userstore.WatchHistoryEntry, 0, len(targets))}
for _, target := range targets {
completedIDs = append(completedIDs, target.MediaItemID)
}
identities := s.resolveStableIdentities(ctx, completedIDs)
batchTargets := make([]userstore.MarkWatchedTarget, 0, len(targets))
entries := make([]userstore.WatchHistoryEntry, 0, len(targets))
for _, target := range targets {
batchTargets = append(batchTargets, userstore.MarkWatchedTarget{
MediaItemID: target.MediaItemID,
DurationSeconds: target.DurationSeconds,
})
entries = append(entries, userstore.WatchHistoryEntry{
if err := store.MarkWatched(ctx, profileID, target.MediaItemID, target.DurationSeconds); err != nil {
return result, err
}
histEntry := userstore.WatchHistoryEntry{
ID: uuid.NewString(),
ProfileID: profileID,
MediaItemID: target.MediaItemID,
@@ -348,16 +338,17 @@ func (s *Service) recordMarkWatched(
DurationSeconds: target.DurationSeconds,
Completed: true,
Source: source,
Identity: identities[target.MediaItemID],
})
}
s.applyStableIdentity(ctx, &histEntry)
histEntry, err = userstore.AddVisibleHistory(ctx, store, histEntry)
if err != nil {
return result, err
}
result.Entries = append(result.Entries, histEntry)
}
// One transaction for the whole set: a canceled request rolls back to
// "nothing marked" rather than leaving a series partially watched.
written, err := userstore.MarkWatchedBatch(ctx, store, profileID, batchTargets, entries)
result := ManualMarkResult{Entries: written}
if err != nil {
return result, err
completedIDs := make([]string, 0, len(targets))
for _, target := range targets {
completedIDs = append(completedIDs, target.MediaItemID)
}
s.notifyWatchedCompleted(ctx, userID, profileID, completedIDs)
return result, nil
@@ -454,28 +445,24 @@ func (s *Service) recordMarkWatchedBatch(
if watchedAt.IsZero() {
watchedAt = time.Now().UTC()
}
// Shares recordMarkWatched's transactional store path (audit 2026-05-01
// §2.7 kept the progress upsert batched; this extends the same treatment
// to history so the whole mark commits or rolls back as one). Identities
// still resolve per episode, now in a single bulk lookup. No durations are
// available here, and the store leaves a known duration in place when a
// target supplies zero.
identities := s.resolveStableIdentities(ctx, targetIDs)
batchTargets := make([]userstore.MarkWatchedTarget, 0, len(targetIDs))
entries := make([]userstore.WatchHistoryEntry, 0, len(targetIDs))
if err := store.MarkProgressBatch(ctx, profileID, targetIDs, watchedAt); err != nil {
return err
}
// Strategy A (audit 2026-05-01 §2.7): batch the progress upsert because it
// powers hot Continue-Watching queries. History inserts stay per-target so
// per-episode stable-identity resolution still applies.
for _, targetID := range targetIDs {
batchTargets = append(batchTargets, userstore.MarkWatchedTarget{MediaItemID: targetID})
entries = append(entries, userstore.WatchHistoryEntry{
histEntry := userstore.WatchHistoryEntry{
ProfileID: profileID,
MediaItemID: targetID,
WatchedAt: formatWatchedAt(watchedAt),
Completed: true,
Source: source,
Identity: identities[targetID],
})
}
if _, err := userstore.MarkWatchedBatch(ctx, store, profileID, batchTargets, entries); err != nil {
return err
}
s.applyStableIdentity(ctx, &histEntry)
if _, err := userstore.AddVisibleHistory(ctx, store, histEntry); err != nil {
return err
}
}
s.notifyWatchedCompleted(ctx, userID, profileID, targetIDs)
return nil
@@ -549,15 +536,6 @@ func (s *Service) addImportedHistoryIfMissingWithSource(
return store.AddHistoryIfMissing(ctx, entry)
}
// resolveStableIdentities resolves identities for many media items in one go,
// returning an empty map when no resolver is configured.
func (s *Service) resolveStableIdentities(ctx context.Context, mediaItemIDs []string) map[string]userstore.WatchIdentity {
if s == nil || s.identity == nil || len(mediaItemIDs) == 0 {
return nil
}
return s.identity.ResolveHistoryIdentities(ctx, mediaItemIDs)
}
func (s *Service) applyStableIdentity(ctx context.Context, entry *userstore.WatchHistoryEntry) {
if s == nil || s.identity == nil || entry == nil {
return
-157
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
@@ -677,159 +676,3 @@ func createWatchstateProfile(t *testing.T, store userstore.UserStore) {
t.Fatalf("CreateProfile: %v", err)
}
}
// batchTestEpisodeRepo is testEpisodeRepo plus the GetByIDs batch capability,
// so the bulk identity path is what gets exercised. It counts calls to prove
// a series mark no longer issues one lookup per episode.
type batchTestEpisodeRepo struct {
testEpisodeRepo
batchCalls int
}
func (r *batchTestEpisodeRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.Episode, error) {
r.batchCalls++
found := make([]*models.Episode, 0, len(contentIDs))
for _, contentID := range contentIDs {
if episode, ok := r.episodes[contentID]; ok {
found = append(found, episode)
}
}
return found, nil
}
// batchTestProviderIDRepo adds the GetByContentIDs batch capability and counts
// calls, pinning that a whole series resolves in one provider-ID query.
type batchTestProviderIDRepo struct {
testProviderIDRepo
batchCalls int
}
func (r *batchTestProviderIDRepo) GetByContentIDs(_ context.Context, contentIDs []string) (map[string][]*models.MediaItemProviderID, error) {
r.batchCalls++
out := make(map[string][]*models.MediaItemProviderID, len(contentIDs))
for _, contentID := range contentIDs {
if rows, ok := r.ids[contentID]; ok {
out[contentID] = rows
}
}
return out, nil
}
// TestManualMarkWatchedSeriesResolvesIdentitiesInBulk marks a whole series and
// pins both halves of the fix: every episode gets its own history row with a
// resolved identity, and the catalog lookups are batched rather than repeated
// per episode.
func TestManualMarkWatchedSeriesResolvesIdentitiesInBulk(t *testing.T) {
store, db := newTestUserStore(t)
t.Cleanup(func() { _ = db.Close() })
const episodeCount = 25
episodes := make(map[string]*models.Episode, episodeCount)
targets := make([]LeafWatchTarget, 0, episodeCount)
for i := 1; i <= episodeCount; i++ {
contentID := fmt.Sprintf("episode-%d", i)
episodes[contentID] = &models.Episode{
ContentID: contentID,
SeriesID: "series-1",
SeasonNumber: 1,
EpisodeNumber: i,
}
targets = append(targets, LeafWatchTarget{MediaItemID: contentID, DurationSeconds: 1800})
}
episodeRepo := &batchTestEpisodeRepo{testEpisodeRepo: testEpisodeRepo{episodes: episodes}}
providerRepo := &batchTestProviderIDRepo{testProviderIDRepo: testProviderIDRepo{
ids: map[string][]*models.MediaItemProviderID{
"series-1": {{ContentID: "series-1", ItemType: "series", Provider: "tvdb", ProviderID: "765"}},
},
}}
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(
NewStableIdentityResolver(testItemRepo{}, episodeRepo, providerRepo),
)
result, err := service.RecordManualMarkWatchedWithResult(
context.Background(), 1, "profile-1", targets,
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatchedWithResult: %v", err)
}
if len(result.Entries) != episodeCount {
t.Fatalf("result entries = %d, want %d; watch-provider dispatch reads these",
len(result.Entries), episodeCount)
}
history, err := store.ListHistory(context.Background(), "profile-1", episodeCount*2, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != episodeCount {
t.Fatalf("history len = %d, want %d", len(history), episodeCount)
}
for _, entry := range history {
if entry.Identity.StableType != "episode" {
t.Fatalf("%s identity = %q, want episode", entry.MediaItemID, entry.Identity.StableType)
}
if got := entry.Identity.SeriesProviderIDs["tvdb"]; got != "765" {
t.Fatalf("%s SeriesProviderIDs[tvdb] = %q, want 765", entry.MediaItemID, got)
}
}
for _, target := range targets {
progress, err := store.GetProgress(context.Background(), "profile-1", target.MediaItemID)
if err != nil || progress == nil || !progress.Completed {
t.Fatalf("GetProgress(%s) = %+v (%v), want completed", target.MediaItemID, progress, err)
}
}
// The whole point of the bulk path: constant lookups, not O(episodes).
if episodeRepo.batchCalls != 1 {
t.Fatalf("episode batch lookups = %d, want 1", episodeRepo.batchCalls)
}
if providerRepo.batchCalls != 1 {
t.Fatalf("provider-ID batch lookups = %d, want 1", providerRepo.batchCalls)
}
}
// TestManualMarkWatchedSeriesIsAtomicOnCancel is the regression test for the
// reported bug: marking a large series while the client disconnects used to
// leave the series partially watched, because each episode committed on its
// own. The mark must now be all-or-nothing.
func TestManualMarkWatchedSeriesIsAtomicOnCancel(t *testing.T) {
store, db := newTestUserStore(t)
t.Cleanup(func() { _ = db.Close() })
targets := make([]LeafWatchTarget, 0, 40)
for i := 1; i <= 40; i++ {
targets = append(targets, LeafWatchTarget{
MediaItemID: fmt.Sprintf("episode-%d", i),
DurationSeconds: 1800,
})
}
service := NewService(testStoreProvider{store: store})
ctx, cancel := context.WithCancel(context.Background())
cancel() // the tab closed before the write could run
if _, err := service.RecordManualMarkWatchedWithResult(
ctx, 1, "profile-1", targets, time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
); err == nil {
t.Fatal("RecordManualMarkWatchedWithResult on a canceled context returned nil error")
}
history, err := store.ListHistory(context.Background(), "profile-1", 100, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 0 {
t.Fatalf("history len = %d, want 0 — a canceled series mark must roll back, not leave a partial mark", len(history))
}
for _, target := range targets {
progress, err := store.GetProgress(context.Background(), "profile-1", target.MediaItemID)
if err != nil {
t.Fatalf("GetProgress(%s): %v", target.MediaItemID, err)
}
if progress != nil && progress.Completed {
t.Fatalf("%s is completed after a canceled mark; the write must be all-or-nothing", target.MediaItemID)
}
}
}
@@ -130,15 +130,13 @@ func (p *Provider) FetchWatched(ctx context.Context, _ watchsync.ServerConfig, c
page := mdblistPageState{}
for {
var payload mdblistWatchedResponse
// Request concrete play history. MDBList documents plays=all as returning
// movie and episode leaves only, avoiding ambiguous show/season rollups.
path := page.path("/sync/watched") + "&plays=all"
path := page.path("/sync/watched")
if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
pageRows := watchedRowsFromPayload(p.Key(), payload)
rows = append(rows, pageRows...)
fetched := len(payload.Movies) + len(payload.Shows) + len(payload.Seasons) + len(payload.Episodes)
fetched := len(payload.Movies) + len(payload.Episodes)
done, err := page.advance(payload.Pagination, fetched)
if err != nil {
return nil, fmt.Errorf("mdblist watched pagination: %w", err)
@@ -151,13 +149,9 @@ func (p *Provider) FetchWatched(ctx context.Context, _ watchsync.ServerConfig, c
}
func watchedRowsFromPayload(providerKey string, payload mdblistWatchedResponse) []watchsync.RemoteWatch {
// The shows and seasons arrays are rollups of episode watched state, not
// independent proof that every episode in that scope was watched. Import
// only the playable leaves; Silo derives season and series completion from
// their episode state.
rows := make([]watchsync.RemoteWatch, 0, len(payload.Movies)+len(payload.Episodes))
for _, movie := range payload.Movies {
watchedAt := movie.LastWatchedAt
watchedAt := movie.WatchedAt
if watchedAt == nil {
continue
}
@@ -179,7 +173,7 @@ func watchedRowsFromPayload(providerKey string, payload mdblistWatchedResponse)
})
}
for _, episode := range payload.Episodes {
watchedAt := episode.LastWatchedAt
watchedAt := episode.WatchedAt
if watchedAt == nil {
continue
}
@@ -278,8 +272,8 @@ func progressFromPlayback(providerKey string, item mdblistPlaybackItem) (watchsy
}, true
}
// FetchWatched requests MDBList's per-play history, so preserve each returned
// movie or episode play and its watched timestamp for exact export reconciliation.
// MDBList exposes a watched snapshot rather than a per-play history, so each
// watched record surfaces once with its last_watched_at as the play timestamp.
func (p *Provider) FetchHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemotePlay, error) {
rows, err := p.FetchWatched(ctx, cfg, conn)
if err != nil {
@@ -709,76 +703,42 @@ type mdblistShow struct {
}
type mdblistEpisode struct {
Title string `json:"title"`
Name string `json:"name"`
Season int `json:"season"`
Number int `json:"number"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
Title string `json:"title"`
Season int `json:"season"`
Number int `json:"number"`
IDs mdblistIDs `json:"ids"`
}
type mdblistWatchedMovie struct {
LastWatchedAt *time.Time `json:"last_watched_at"`
Movie mdblistMovie `json:"movie"`
}
func (m *mdblistWatchedMovie) UnmarshalJSON(data []byte) error {
var raw struct {
LastWatchedAt *time.Time `json:"last_watched_at"`
WatchedAt *time.Time `json:"watched_at"`
Movie mdblistMovie `json:"movie"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
m.LastWatchedAt = firstTimestamp(raw.LastWatchedAt, raw.WatchedAt)
m.Movie = raw.Movie
return nil
}
type mdblistWatchedShow struct {
LastWatchedAt *time.Time `json:"last_watched_at"`
Show mdblistShow `json:"show"`
}
type mdblistSeason struct {
Number int `json:"number"`
Name string `json:"name"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
}
type mdblistWatchedSeason struct {
LastWatchedAt *time.Time `json:"last_watched_at"`
Season mdblistSeason `json:"season"`
WatchedAt *time.Time `json:"watched_at"`
Movie mdblistMovie `json:"movie"`
}
// mdblistWatchedEpisode tolerates both shapes MDBList uses for episode rows:
// season/number inlined on the row, or nested under an `episode` object.
type mdblistWatchedEpisode struct {
LastWatchedAt *time.Time
Season int
Number int
Title string
IDs mdblistIDs
Show mdblistShow
WatchedAt *time.Time `json:"watched_at"`
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
}
func (e *mdblistWatchedEpisode) UnmarshalJSON(data []byte) error {
var raw struct {
LastWatchedAt *time.Time `json:"last_watched_at"`
WatchedAt *time.Time `json:"watched_at"`
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
Episode *mdblistEpisode `json:"episode"`
WatchedAt *time.Time `json:"watched_at"`
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
Episode *mdblistEpisode `json:"episode"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
e.LastWatchedAt = firstTimestamp(raw.LastWatchedAt, raw.WatchedAt)
e.WatchedAt = raw.WatchedAt
e.Season = raw.Season
e.Number = raw.Number
e.Title = raw.Title
@@ -793,31 +753,16 @@ func (e *mdblistWatchedEpisode) UnmarshalJSON(data []byte) error {
}
if e.Title == "" {
e.Title = raw.Episode.Title
if e.Title == "" {
e.Title = raw.Episode.Name
}
}
if e.IDs == (mdblistIDs{}) {
e.IDs = raw.Episode.IDs
}
if e.Show == (mdblistShow{}) {
e.Show = raw.Episode.Show
}
}
return nil
}
func firstTimestamp(preferred, fallback *time.Time) *time.Time {
if preferred != nil {
return preferred
}
return fallback
}
type mdblistWatchedResponse struct {
Movies []mdblistWatchedMovie `json:"movies"`
Shows []mdblistWatchedShow `json:"shows"`
Seasons []mdblistWatchedSeason `json:"seasons"`
Episodes []mdblistWatchedEpisode `json:"episodes"`
Pagination *mdblistPagination `json:"pagination"`
}
@@ -75,58 +75,31 @@ func TestConnectWithAPIKeyRejectsEmpty(t *testing.T) {
}
}
func TestFetchWatchedImportsPlayableLeavesWithoutExpandingAggregateRows(t *testing.T) {
func TestFetchWatchedParsesMoviesAndEpisodes(t *testing.T) {
watched := time.Date(2025, time.October, 21, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("plays"); got != "all" {
t.Fatalf("plays query = %q, want all", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"movies": []map[string]any{{
"last_watched_at": watched.Format(time.RFC3339),
"watched_at": watched.Format(time.RFC3339),
"movie": map[string]any{
"title": "Beetlejuice Beetlejuice",
"year": 2024,
"ids": map[string]any{"imdb": "tt2049403", "tmdb": 917496},
},
}},
"shows": []map[string]any{{
"last_watched_at": watched.Format(time.RFC3339),
"episodes": []map[string]any{{
"watched_at": watched.Format(time.RFC3339),
"season": 1,
"number": 2,
"title": "Cat's in the Bag...",
"ids": map[string]any{"tvdb": 349231},
"show": map[string]any{
"title": "Breaking Bad",
"year": 2008,
"ids": map[string]any{"tvdb": 81189, "tmdb": 1396},
"ids": map[string]any{"tvdb": 81189, "imdb": "tt0903747"},
},
}},
"seasons": []map[string]any{{
"last_watched_at": watched.Format(time.RFC3339),
"season": map[string]any{
"number": 2,
"name": "Season 2",
"ids": map[string]any{"tmdb": 8160},
"show": map[string]any{
"title": "Breaking Bad",
"year": 2008,
"ids": map[string]any{"imdb": "tt0903747", "tmdb": 1396},
},
},
}},
"episodes": []map[string]any{{
"last_watched_at": watched.Format(time.RFC3339),
"episode": map[string]any{
"season": 1,
"number": 2,
"name": "Cat's in the Bag...",
"ids": map[string]any{"tmdb": 62086},
"show": map[string]any{
"title": "Breaking Bad",
"year": 2008,
"ids": map[string]any{"tvdb": 81189, "imdb": "tt0903747"},
},
},
}},
"pagination": map[string]any{"has_more": false},
})
}))
defer server.Close()
@@ -145,36 +118,8 @@ func TestFetchWatchedImportsPlayableLeavesWithoutExpandingAggregateRows(t *testi
if rows[1].Kind != historyimport.KindEpisode || rows[1].SeasonNumber != 1 || rows[1].EpisodeNumber != 2 {
t.Fatalf("unexpected episode row: %#v", rows[1])
}
if rows[1].Title != "Cat's in the Bag..." || rows[1].SeriesIMDbID != "tt0903747" {
t.Fatalf("expected nested episode/show fields propagated, got %#v", rows[1])
}
for i, row := range rows {
if row.LastWatchedAt == nil || !row.LastWatchedAt.Equal(watched) {
t.Fatalf("row %d watched timestamp = %v, want %v", i, row.LastWatchedAt, watched)
}
}
}
func TestFetchWatchedAcceptsLegacyWatchedAtAndFlatEpisode(t *testing.T) {
watched := time.Date(2025, time.November, 1, 8, 30, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"movies":[{"watched_at":"2025-11-01T08:30:00Z","movie":{"ids":{"imdb":"tt0111161"}}}],
"episodes":[{"watched_at":"2025-11-01T08:30:00Z","season":1,"number":2,"show":{"ids":{"tvdb":81189}}}]
}`))
}))
defer server.Close()
rows, err := NewProvider(server.Client(), server.URL).FetchWatched(
context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"},
)
if err != nil {
t.Fatalf("fetch watched: %v", err)
}
if len(rows) != 2 || rows[0].LastWatchedAt == nil || !rows[0].LastWatchedAt.Equal(watched) ||
rows[1].LastWatchedAt == nil || !rows[1].LastWatchedAt.Equal(watched) {
t.Fatalf("unexpected legacy rows: %#v", rows)
if rows[1].SeriesIMDbID != "tt0903747" {
t.Fatalf("expected series imdb propagated, got %q", rows[1].SeriesIMDbID)
}
}
@@ -229,37 +174,6 @@ func TestFetchWatchedContinuesOffsetPaginationWhenHasMore(t *testing.T) {
}
}
func TestFetchWatchedOffsetCountsAggregateRows(t *testing.T) {
var offsets []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
offsets = append(offsets, r.URL.Query().Get("offset"))
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("offset") == "2" {
_, _ = w.Write([]byte(`{"movies":[],"shows":[],"seasons":[],"episodes":[],"pagination":{"has_more":false}}`))
return
}
_, _ = w.Write([]byte(`{
"shows":[{"last_watched_at":"2025-11-01T08:30:00Z","show":{"ids":{"tmdb":1396}}}],
"seasons":[{"last_watched_at":"2025-11-01T08:30:00Z","season":{"number":1,"show":{"ids":{"tmdb":1396}}}}],
"pagination":{"has_more":true}
}`))
}))
defer server.Close()
rows, err := NewProvider(server.Client(), server.URL).FetchWatched(
context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"},
)
if err != nil {
t.Fatalf("fetch watched: %v", err)
}
if len(rows) != 0 {
t.Fatalf("got %d rows, want aggregate rows excluded", len(rows))
}
if len(offsets) != 2 || offsets[0] != "" || offsets[1] != "2" {
t.Fatalf("unexpected offsets: %#v", offsets)
}
}
func TestFetchProgressParsesFlatArray(t *testing.T) {
pausedAt := time.Date(2025, time.November, 1, 8, 30, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+6 -50
View File
@@ -42,10 +42,6 @@ type mediaMatcher interface {
Match(ctx context.Context, record historyimport.Record) (*historyimport.Match, string, error)
}
type watchedLeafMatcher interface {
MatchLeaves(ctx context.Context, record historyimport.Record) ([]historyimport.Match, string, error)
}
type watchStateImporter interface {
RecordImportedWatchIfNewerWithSource(ctx context.Context, userID int, profileID, targetID string, duration, position float64, completed bool, updatedAt time.Time, watchedAt *time.Time, source userstore.WatchHistorySource) (bool, error)
}
@@ -1024,11 +1020,6 @@ type ImportWatchedResult struct {
Warnings []string
}
type matchedWatchedLeaf struct {
match historyimport.Match
watchedAt time.Time
}
func (s *Service) ImportWatched(
ctx context.Context,
conn Connection,
@@ -1047,13 +1038,12 @@ func (s *Service) ImportWatched(
}
rows := batch.Rows
result := ImportWatchedResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
matchedLeaves := make(map[string]matchedWatchedLeaf)
for _, row := range rows {
matches, reason, err := s.matchWatchedLeaves(ctx, row)
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
if err != nil {
return result, err
}
if len(matches) == 0 {
if match == nil {
result.Unmatched++
if reason != "" {
result.Warnings = append(result.Warnings, reason)
@@ -1063,35 +1053,17 @@ func (s *Service) ImportWatched(
if row.LastWatchedAt == nil {
continue
}
for _, match := range matches {
existing, ok := matchedLeaves[match.MediaItemID]
if !ok || row.LastWatchedAt.After(existing.watchedAt) {
matchedLeaves[match.MediaItemID] = matchedWatchedLeaf{
match: match,
watchedAt: *row.LastWatchedAt,
}
}
}
}
mediaItemIDs := make([]string, 0, len(matchedLeaves))
for mediaItemID := range matchedLeaves {
mediaItemIDs = append(mediaItemIDs, mediaItemID)
}
sort.Strings(mediaItemIDs)
for _, mediaItemID := range mediaItemIDs {
leaf := matchedLeaves[mediaItemID]
duration, _ := s.mediaDuration(ctx, leaf.match.MediaItemID)
watchedAt := leaf.watchedAt
duration, _ := s.mediaDuration(ctx, match.MediaItemID)
created, err := s.watchState.RecordImportedWatchIfNewerWithSource(
ctx,
conn.UserID,
conn.ProfileID,
leaf.match.MediaItemID,
match.MediaItemID,
duration,
0,
true,
watchedAt,
&watchedAt,
*row.LastWatchedAt,
row.LastWatchedAt,
historySourceForProvider(importer),
)
if err != nil {
@@ -1111,22 +1083,6 @@ func (s *Service) ImportWatched(
return result, nil
}
func (s *Service) matchWatchedLeaves(ctx context.Context, row RemoteWatch) ([]historyimport.Match, string, error) {
record := row.HistoryRecord()
if row.Kind == historyimport.KindSeries || row.Kind == historyimport.KindSeason {
matcher, ok := s.matcher.(watchedLeafMatcher)
if !ok {
return nil, "watch provider matcher cannot expand aggregate watched state", nil
}
return matcher.MatchLeaves(ctx, record)
}
match, reason, err := s.matcher.Match(ctx, record)
if err != nil || match == nil {
return nil, reason, err
}
return []historyimport.Match{*match}, "", nil
}
func fetchWatchedImportBatch(
ctx context.Context,
cfg ServerConfig,
-85
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"reflect"
"strconv"
"strings"
"sync"
@@ -1061,18 +1060,6 @@ func (m matchedMatcherStub) Match(context.Context, historyimport.Record) (*histo
return &historyimport.Match{MediaItemID: m.mediaItemID}, "", nil
}
type watchedLeafMatcherStub struct {
matches []historyimport.Match
}
func (m watchedLeafMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) {
return nil, "unexpected single-item match", nil
}
func (m watchedLeafMatcherStub) MatchLeaves(context.Context, historyimport.Record) ([]historyimport.Match, string, error) {
return m.matches, "", nil
}
type noOpWatchState struct{}
func (noOpWatchState) RecordImportedHistoryWithSource(
@@ -1695,78 +1682,6 @@ func TestServiceImportWatchedUsesProviderHistorySource(t *testing.T) {
}
}
func TestServiceImportWatchedExpandsAggregateMarkerToEpisodeLeaves(t *testing.T) {
repo := newServiceFakeRepo()
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
provider := watchedImporterStub{
key: "mdblist",
source: userstore.WatchHistorySourceMDBList,
rows: []RemoteWatch{{
Provider: "mdblist", Kind: historyimport.KindSeries, Title: "Breaking Bad", LastWatchedAt: &watchedAt,
}},
}
watchState := &recordingWatchState{}
service := NewService(repo, NewRegistry()).
WithMatcher(watchedLeafMatcherStub{matches: []historyimport.Match{
{MediaItemID: "episode-1", Kind: historyimport.KindEpisode},
{MediaItemID: "episode-2", Kind: historyimport.KindEpisode},
}}).
WithWatchState(watchState)
result, err := service.ImportWatched(context.Background(), Connection{
ID: "conn-1", Provider: "mdblist", UserID: 7, ProfileID: "profile-1",
}, ServerConfig{}, provider)
if err != nil {
t.Fatalf("ImportWatched: %v", err)
}
if result.Found != 1 || result.Imported != 2 || result.Unmatched != 0 {
t.Fatalf("result = %+v, want one aggregate found and two leaves imported", result)
}
if !reflect.DeepEqual(watchState.targetIDs, []string{"episode-1", "episode-2"}) {
t.Fatalf("recorded target ids = %+v", watchState.targetIDs)
}
if !reflect.DeepEqual(watchState.sources, []userstore.WatchHistorySource{
userstore.WatchHistorySourceMDBList,
userstore.WatchHistorySourceMDBList,
}) {
t.Fatalf("recorded sources = %+v", watchState.sources)
}
}
func TestServiceImportWatchedDeduplicatesResolvedLeavesAtNewestTimestamp(t *testing.T) {
repo := newServiceFakeRepo()
olderWatchedAt := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
newerWatchedAt := olderWatchedAt.Add(24 * time.Hour)
provider := watchedImporterStub{
key: "mdblist",
source: userstore.WatchHistorySourceMDBList,
rows: []RemoteWatch{
{Provider: "mdblist", Kind: historyimport.KindMovie, Title: "Inception", LastWatchedAt: &olderWatchedAt},
{Provider: "mdblist", Kind: historyimport.KindMovie, Title: "Inception", LastWatchedAt: &newerWatchedAt},
},
}
watchState := &recordingWatchState{}
service := NewService(repo, NewRegistry()).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithWatchState(watchState)
result, err := service.ImportWatched(context.Background(), Connection{
ID: "conn-1", Provider: "mdblist", UserID: 7, ProfileID: "profile-1",
}, ServerConfig{}, provider)
if err != nil {
t.Fatalf("ImportWatched: %v", err)
}
if result.Found != 2 || result.Imported != 1 || result.Unmatched != 0 {
t.Fatalf("result = %+v, want two provider markers and one imported leaf", result)
}
if !reflect.DeepEqual(watchState.targetIDs, []string{testMovieMediaID}) {
t.Fatalf("recorded target ids = %+v", watchState.targetIDs)
}
if len(watchState.watchedAt) != 1 || watchState.watchedAt[0] == nil || !watchState.watchedAt[0].Equal(newerWatchedAt) {
t.Fatalf("recorded watched_at = %+v, want %v", watchState.watchedAt, newerWatchedAt)
}
}
func TestServiceImportWatchedSkipsRowsWithoutLastWatchedAt(t *testing.T) {
repo := newServiceFakeRepo()
provider := watchedImporterStub{
+3 -14
View File
@@ -29,8 +29,6 @@ type SessionSync struct {
ClientIP string
ClientName string
ClientVersion string
ClientBuild string
ClientChannel string
ClientUserAgent string
AudioTrackIndex int
TranscodeAudio bool
@@ -153,11 +151,11 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
INSERT INTO playback_sessions_sync
(session_id, user_id, profile_id, media_file_id, requested_media_file_id, play_method,
reporting_node, started_at, updated_at, last_sync_at, client_ip,
client_name, client_version, client_build, client_channel, client_user_agent,
client_name, client_version, client_user_agent,
audio_track_index, transcode_audio, stream_bitrate_kbps, transcode_node_url,
target_resolution, target_video_codec, target_audio_codec, target_bitrate_kbps,
transcode_hw_accel, position_seconds, is_paused, has_websocket, compat_origin)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
ON CONFLICT (session_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
profile_id = EXCLUDED.profile_id,
@@ -170,8 +168,6 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
client_ip = EXCLUDED.client_ip,
client_name = EXCLUDED.client_name,
client_version = EXCLUDED.client_version,
client_build = EXCLUDED.client_build,
client_channel = EXCLUDED.client_channel,
client_user_agent = EXCLUDED.client_user_agent,
audio_track_index = EXCLUDED.audio_track_index,
transcode_audio = EXCLUDED.transcode_audio,
@@ -189,8 +185,7 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
last_sync_at = NOW()
`, s.SessionID, s.UserID, s.ProfileID, s.MediaFileID, nullableInt(s.RequestedMediaFileID), s.PlayMethod,
sessionNode, s.StartedAt, s.UpdatedAt, nullableIP(s.ClientIP),
nullableString(s.ClientName), nullableString(s.ClientVersion), nullableString(s.ClientBuild),
nullableString(s.ClientChannel), nullableString(s.ClientUserAgent),
nullableString(s.ClientName), nullableString(s.ClientVersion), nullableString(s.ClientUserAgent),
s.AudioTrackIndex, s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL),
nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec),
nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps),
@@ -256,8 +251,6 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri
COALESCE(HOST(client_ip), ''),
COALESCE(client_name, ''),
COALESCE(client_version, ''),
COALESCE(client_build, ''),
COALESCE(client_channel, ''),
COALESCE(client_user_agent, ''),
COALESCE(audio_track_index, 0),
COALESCE(transcode_audio, FALSE),
@@ -297,8 +290,6 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri
&s.ClientIP,
&s.ClientName,
&s.ClientVersion,
&s.ClientBuild,
&s.ClientChannel,
&s.ClientUserAgent,
&s.AudioTrackIndex,
&s.TranscodeAudio,
@@ -356,8 +347,6 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool {
left[i].ClientIP != right[i].ClientIP ||
left[i].ClientName != right[i].ClientName ||
left[i].ClientVersion != right[i].ClientVersion ||
left[i].ClientBuild != right[i].ClientBuild ||
left[i].ClientChannel != right[i].ClientChannel ||
left[i].ClientUserAgent != right[i].ClientUserAgent ||
left[i].AudioTrackIndex != right[i].AudioTrackIndex ||
left[i].TranscodeAudio != right[i].TranscodeAudio ||
@@ -1,38 +0,0 @@
-- +goose Up
-- +goose StatementBegin
-- Per-profile sort override for a collection's browse view. A collection's own
-- sort_config supplies the default order its creator chose; a row here records
-- that a viewer changed it, and wins over that default for this profile only.
--
-- sort_field = '' is meaningful: it records "this viewer explicitly chose the
-- collection's own source order", which must survive even when the creator
-- later sets a default sort. Absence of a row means "no choice made yet".
--
-- collection_kind separates the two id spaces ('library' = admin/server-wide
-- collections, 'user' = personal ones); ids are unique within a kind but not
-- across them.
CREATE TABLE IF NOT EXISTS public.user_collection_sort_preferences (
user_id INTEGER NOT NULL,
profile_id TEXT NOT NULL,
collection_kind TEXT NOT NULL,
collection_id TEXT NOT NULL,
sort_field TEXT NOT NULL DEFAULT '',
sort_order TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, profile_id, collection_kind, collection_id),
CONSTRAINT user_collection_sort_preferences_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_collection_sort_preferences_profile_fkey
FOREIGN KEY (user_id, profile_id)
REFERENCES user_profiles(user_id, id) ON DELETE CASCADE,
CONSTRAINT user_collection_sort_preferences_kind_check
CHECK (collection_kind IN ('library', 'user')),
CONSTRAINT user_collection_sort_preferences_order_check
CHECK (sort_order IN ('', 'asc', 'desc'))
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS public.user_collection_sort_preferences;
-- +goose StatementEnd
@@ -1,21 +0,0 @@
-- +goose Up
-- Client build number and release channel are reported by the app alongside its
-- version, so admin activity and route diagnostics can name the exact build
-- ("Silo Android TV 1.0.0 (build 5)") instead of a version alone. Both are
-- opaque, per-platform strings the server never parses.
ALTER TABLE public.playback_sessions_sync
ADD COLUMN IF NOT EXISTS client_build text,
ADD COLUMN IF NOT EXISTS client_channel text;
ALTER TABLE public.playback_route_events
ADD COLUMN IF NOT EXISTS client_build text,
ADD COLUMN IF NOT EXISTS client_channel text;
-- +goose Down
ALTER TABLE public.playback_route_events
DROP COLUMN IF EXISTS client_channel,
DROP COLUMN IF EXISTS client_build;
ALTER TABLE public.playback_sessions_sync
DROP COLUMN IF EXISTS client_channel,
DROP COLUMN IF EXISTS client_build;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Some files were not shown because too many files have changed in this diff Show More