Files
silo-server/internal/jellycompat/idcodec.go
T
074d106402 feat(jellycompat): BoxSet collections, genre-by-name, and airtight ABS media exclusion (#115)
* feat(jellycompat): exclude audiobook libraries, add BoxSets and genre-by-name

- Audiobook libraries (type 'audiobooks'/'audiobook') no longer appear in
  Views/VirtualFolders, and all browse/search/genre/detail paths are clamped
  to movie/series/episode so audiobook items cannot leak or stream through
  the Jellyfin compat surface (they are served by the ABS-compat API).
- Library collections are now exposed as Jellyfin BoxSets:
  IncludeItemTypes=BoxSet listing (optionally scoped via ParentId library),
  /Items/{id} BoxSet detail, ParentId children with curated position order
  preserved (explicit SortBy delegates to catalog ordering), poster/backdrop
  presigning, and visibility + library-access filtering.
- /Items with only unexposable IncludeItemTypes (e.g. Playlist) returns an
  empty result instead of falling through to views/browse.
- New GET /Genres/{name} endpoint resolving canonical genre casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): centralize ABS media-type exclusion, fix BoxSet edge cases from review

- Add catalog.AccessFilter.ExcludedMediaTypes, enforced by applyAccessFilter
  and threaded through Search/GetByIDsWithAccess/EnsureAccessible and
  BrowseFavorites. The compat layer stamps audiobook+podcast exclusions onto
  every resolved access filter (one wrap in withDefaults), closing the
  favorites, recommendations, and item-image leak paths that per-call-site
  guards missed.
- Treat podcast libraries like audiobook libraries: hidden from Views, items
  excluded everywhere (they're served by the ABS-compat API).
- HandleItems: BoxSet listing no longer hijacks user-state-filtered queries
  (IncludeItemTypes=BoxSet&Filters=IsFavorite returns empty again),
  IncludeItemTypes=CollectionFolder returns library views as before, and
  Ids=<boxsetId> re-hydrates the BoxSet DTO instead of falling through to
  the views response.
- BoxSet artwork is now durable: stable signed tags seeded from the artwork
  key (no churn on presign rotation) plus a collections fallback in the
  images handler, so posters survive restarts and cache expiry.
- BoxSet listing filters/sorts/pages the lightweight collection rows before
  building DTOs, so a Limit=24 page over 300 collections no longer presigns
  ~600 posters per request; collection children also page before hydrating
  user state.
- Dedupe: shared loadVisibleCollection guard, shared collection-page writer,
  single scoped-types implementation, emptyQueryResult helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): address PR review comments

- withCompatAccessExclusions merges compat exclusions with any the base
  resolver already supplies instead of conditionally skipping them.
- Explicit type filters clamp to a closed allowlist (movie/series/episode/
  season) rather than passing unknown types through to catalog queries.
- Collection artwork on the session path applies the same visibility rules
  as the BoxSet item endpoints (hidden or inaccessible-library collections
  404 instead of serving posters).
- loadVisibleCollection propagates infrastructure errors instead of masking
  transient DB failures as 404/empty; only ErrLibraryCollectionNotFound maps
  to not-found.
- ListFavorites filters ABS-surface favorites before applying the
  limit/offset window (over-fetching the raw rows) so pages don't shrink or
  shift, and presigns artwork only for the returned page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:16:28 -04:00

174 lines
5.4 KiB
Go

package jellycompat
import (
"encoding/binary"
"fmt"
"strconv"
"sync"
"github.com/google/uuid"
)
// EncodedIDType distinguishes packed compat UUIDs.
type EncodedIDType byte
const (
EncodedIDLibrary EncodedIDType = 1
EncodedIDItem EncodedIDType = 2
EncodedIDMediaSource EncodedIDType = 3
EncodedIDSeason EncodedIDType = 4
EncodedIDPlaySession EncodedIDType = 5
EncodedIDGenre EncodedIDType = 6
EncodedIDStudio EncodedIDType = 7
EncodedIDPerson EncodedIDType = 8
EncodedIDImageProxy EncodedIDType = 9
EncodedIDCollection EncodedIDType = 10
)
var (
pseudoUserNamespace = uuid.MustParse("3dfcc388-bf95-5572-bc16-7f1a375992dd")
stringIDNamespaces = map[EncodedIDType]uuid.UUID{
EncodedIDItem: uuid.MustParse("0b6716ca-1f61-5987-b17b-f592f04fd6b3"),
EncodedIDSeason: uuid.MustParse("29831b2b-dad5-5a85-b506-4d1fb2da01ed"),
EncodedIDPlaySession: uuid.MustParse("75a69ca8-f95f-5e9d-ac0a-d34a37b93eb4"),
EncodedIDGenre: uuid.MustParse("c0cbb8ea-8331-52c0-b160-15e7cf899fb0"),
EncodedIDStudio: uuid.MustParse("23712982-b769-592d-9360-b4d3f39654db"),
EncodedIDPerson: uuid.MustParse("a4e7c1d6-3b8f-5a2e-9c01-7d6f4e8b2a13"),
EncodedIDCollection: uuid.MustParse("7f3c2a91-5b64-5c1d-8e07-9a2f4d6b1c35"),
}
)
// DecodedID is a packed compat UUID decoded back to its type and value.
type DecodedID struct {
Type EncodedIDType
Value uint64
}
// ResourceIDCodec encodes numeric IDs directly and keeps reversible mappings
// for opaque string content IDs used by media items and seasons.
type ResourceIDCodec struct {
mu sync.RWMutex
reverse map[string]registeredID
mediaSourceOwners map[int64]string
}
type registeredID struct {
kind EncodedIDType
value string
}
// PseudoUserID deterministically derives the Jellyfin pseudo-user UUID.
func PseudoUserID(userID int, profileID string) uuid.UUID {
return uuid.NewSHA1(pseudoUserNamespace, fmt.Appendf(nil, "%d:%s", userID, profileID))
}
// NewResourceIDCodec creates a new route ID codec.
func NewResourceIDCodec() *ResourceIDCodec {
return &ResourceIDCodec{
reverse: make(map[string]registeredID),
mediaSourceOwners: make(map[int64]string),
}
}
// EncodeNumericID packs a numeric Silo identifier into a UUID.
func EncodeNumericID(kind EncodedIDType, value uint64) uuid.UUID {
var raw [16]byte
raw[0] = byte(kind)
binary.BigEndian.PutUint64(raw[8:], value)
return uuid.UUID(raw)
}
// EncodeStringID encodes a Silo identifier into a Jellyfin UUID string.
func (c *ResourceIDCodec) EncodeStringID(kind EncodedIDType, value string) string {
if numeric, err := strconv.ParseUint(value, 10, 64); err == nil {
return EncodeNumericID(kind, numeric).String()
}
namespace, ok := stringIDNamespaces[kind]
if !ok {
namespace = uuid.NameSpaceURL
}
encoded := uuid.NewSHA1(namespace, []byte(value))
c.mu.Lock()
c.reverse[encoded.String()] = registeredID{kind: kind, value: value}
c.mu.Unlock()
return encoded.String()
}
// EncodeIntID encodes a native integer ID into a Jellyfin UUID string.
func (c *ResourceIDCodec) EncodeIntID(kind EncodedIDType, value int64) string {
return EncodeNumericID(kind, uint64(value)).String()
}
// DecodeStringID decodes a compat UUID back to the original native string ID.
func (c *ResourceIDCodec) DecodeStringID(kind EncodedIDType, raw string) (string, error) {
if decoded, err := DecodeID(raw); err == nil && decoded.Type == kind {
return strconv.FormatUint(decoded.Value, 10), nil
}
c.mu.RLock()
registered, ok := c.reverse[raw]
c.mu.RUnlock()
if !ok || registered.kind != kind {
return "", fmt.Errorf("unknown compat id %q", raw)
}
return registered.value, nil
}
// DecodeIntID decodes a compat UUID back to a native integer ID.
func (c *ResourceIDCodec) DecodeIntID(kind EncodedIDType, raw string) (int64, error) {
decoded, err := DecodeID(raw)
if err != nil {
return 0, err
}
if decoded.Type != kind {
return 0, fmt.Errorf("unexpected compat id type %d", decoded.Type)
}
return int64(decoded.Value), nil
}
// RegisterMediaSourceOwner records which content item owns a media-source/file ID.
func (c *ResourceIDCodec) RegisterMediaSourceOwner(fileID int64, contentID string) {
c.mu.Lock()
c.mediaSourceOwners[fileID] = contentID
c.mu.Unlock()
}
// LookupMediaSourceOwner resolves a media-source/file ID back to its content item.
func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) {
c.mu.RLock()
contentID, ok := c.mediaSourceOwners[fileID]
c.mu.RUnlock()
return contentID, ok
}
// mediaSourceIDsEqual reports whether two media-source IDs refer to the same
// source, tolerating UUID format differences. Silo exposes the canonical
// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some
// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex
// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so
// matching must compare the parsed values rather than the raw strings.
func mediaSourceIDsEqual(a, b string) bool {
if a == b {
return true
}
ua, errA := uuid.Parse(a)
ub, errB := uuid.Parse(b)
return errA == nil && errB == nil && ua == ub
}
// DecodeID unpacks a compat UUID into its original numeric value.
func DecodeID(raw string) (DecodedID, error) {
parsed, err := uuid.Parse(raw)
if err != nil {
return DecodedID{}, fmt.Errorf("parse uuid: %w", err)
}
return DecodedID{
Type: EncodedIDType(parsed[0]),
Value: binary.BigEndian.Uint64(parsed[8:]),
}, nil
}