* feat(catalog): deterministic cross-server content_id Replace per-server Sonyflake content_id with a structured natural key derived from provider IDs (movie:tmdb:…, series:tvdb:…, episode:…, local:… fallback), so two servers holding the same title share one anchor for artwork, watch history, progress, favorites and ratings. - internal/contentid: derivation core, SeriesIDFromContentID transform, frozen precedence, SchemeVersion=1, embedded-series-anchor invariant. - internal/metadata/service.go: deterministic id at every mint site. - internal/catalog/history_source.go: resolve show via string transform for anchored episode ids; skip the episodes_pkey probe. - migrations/sql/20260612130000: collision-safe value remap across the 65-column reference graph + COLLATE "C", FK/trigger handling, audit map, working down. Benchmarked against an exact-cardinality copy of cprod-postgres (1.93M episodes, 775k history rows): 2.57x faster history page, 1.7x throughput at 100 concurrent users, 2.7x cheaper per content_id probe. * feat(catalog): re-ID untagged items to deterministic content_id at first match Untagged libraries get a path-derived local: content_id at scan time and only learn their provider IDs later, when the match worker confirms a result. Previously that id was never folded back in, so untagged-then-matched items kept a per-server local: placeholder forever and never converged across servers (re-ID was deferred to a migration rerun). mergeAndPersist now promotes a local: skeleton to its deterministic provider-anchored id at the moment of first confirmed match, via a single new gate (canonicalizeLocalContentID): - target id already taken -> merge onto it (existing rebind machinery) - target id free -> rename in place The rename is a single SQL function (silo_rename_content_id); FK children follow via ON UPDATE CASCADE added to the content_id family, so a fresh skeleton moves a handful of rows rather than the full-table remap the bulk migration does. The guard is one IsLocal prefix check, so tagged content and all refreshes pay nothing, and the move is self-healing under retry. Verified: gofmt/vet/build clean; migrate-validate passes; migration applies on the real schema (up/down/up), FKs gain ON UPDATE CASCADE while keeping ON DELETE; functional test confirms series PK move + series_id cascade + provider-id sweep, and movie rename. Follow-ups (noted in docs): recomposeSeriesChildIDs for a series that accumulated episodes before matching; a lockstep test for the soft-ref list. * fix(catalog): harden content_id parsing and merge per review Address review feedback on the deterministic content_id work: - history_source.go: gate the anchored-episode display-id transform on the full five-part episode shape (split_part parts 2-5 non-empty), not just the 'episode:' prefix, so a malformed id can't transform to 'series:broken:' and vanish at the media_items join. Shared anchoredEpisodePredicate drives both the null-poisoned join key and the series-recovery expression. - contentid.go: unexport the provider-precedence slices so no package can mutate the frozen SchemeVersion ordering at runtime. - contentid.go: add parseAnchored to validate the exact per-kind arity and numeric season/episode suffixes; SeriesIDFromContentID and IsProviderAnchored now fail closed on truncated/malformed ids (e.g. "episode:tvdb:296762"). - canonicalize.go: distinguish catalog.ErrItemNotFound from transient lookup errors (a real error no longer masquerades as "target free"), and allow a matched local source to be consolidated onto the canonical row instead of orphaning a duplicate. * refactor(contentid): URL-safe "-" separator in content_id Use "-" instead of ":" to join content_id components (movie-tmdb-228064, episode-tvdb-296762-1-5, local-<hex>). "-" is an RFC 3986 unreserved character, so a content_id is URL-safe verbatim: encodeURIComponent is a no-op and the id is its own tidy path segment (/item/series-tvdb-296762) with no %3A escaping. The stored value equals the URL value, so there is no encode/decode boundary and an operator can grep the id straight out of a URL or log. Every component is [a-z0-9]+ (or "tt"+digits), so "-" is unambiguous. Pre-release format finalization: this branch is unmerged, so no deployed data carries ":" ids — the migration mints the "-" form fresh and no re-migration is needed. Still SchemeVersion 1. - contentid.go: single `sep` constant drives construction and parsing so the two can never drift; all constructors/parsers and doc examples updated. - history_source.go: split_part transform and the anchored-episode predicate use '-'; kept in lockstep with the package via a code comment. - 20260612130000_deterministic_content_id.sql: derivation and season/episode composition emit '-'; LIKE filters match 'series-%'. - docs/architecture/deterministic-content-id.md: format spec + rationale for the separator choice; this is the design doc the change is derived from. Client-side: the web frontend treats content_id as an opaque string (no splitting/regex), so no client changes are required; existing encodeURIComponent call sites simply stop emitting %3A. * docs(contentid): show why hash/bigint rejected in probe-cost table Add Cross-server deterministic / Zero-join show transform / Human-readable columns to the index-probe-cost comparison so the trade-off is legible at a glance: the 128-bit hash and bigint surrogate are faster but each give up a load-bearing property, and the structured key is the only all-checkmark row. * docs(contentid): order probe-cost table to end on the structured key * docs(contentid): label fenced blocks and drop stray EOF tags Per CodeRabbit review: add 'text' language to three fenced code blocks (MD040) and remove accidental </content></invoke> artifacts at EOF. * fix(catalog): remap array-valued content_id soft references in deterministic id migration The value-remap migration (20260612130000) enumerates the reference graph by FK plus a scalar name+type sweep (text/varchar/bpchar). That misses trending_discover_snapshots.content_ids: it is text[] (excluded by the type filter), named content_ids not content_id (excluded by the name list), and cannot carry an FK — so the bulk remap left those arrays holding stale Sonyflake ids that resolve to nothing until the snapshot regenerates. A counterexample to the migration's "self-protecting, cannot orphan" invariant. Remap the array element-wise in both directions (Up old->new, Down new->old), preserving order and leaving collision/unmatched elements untouched; a WHERE EXISTS guard skips empty/unaffected arrays so array_agg never collapses the NOT NULL column to NULL. Mirror the gap in silo_rename_content_id (20260614120000) with array_replace for the single-value runtime rename so the two stay in lockstep. Verified on PG18: mixed/collision/empty arrays remap correctly and round-trip clean; runtime array_replace preserves order. Surfaced reviewing #155. The jellycompat restart-decode regression and the atomicity-wording nit are posted as review comments, not addressed here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(jellycompat): pack content_id into compat UUID reversibly so item ids survive restarts Addresses the restart-decode regression raised in review of #155. With content_id now a structured string instead of a numeric Sonyflake, EncodeStringID sent every item/season id down the one-way SHA1 path, making decode depend on an in-memory reverse map. That map is cold after a process restart (the codec is a process-lifetime singleton), so a client presenting a previously-issued item UUID — resume-from-home, deep link, detail page, image, userdata — got "unknown compat id" until the item was re-listed. Make the encoding reversible instead of stateful: - internal/contentid: add Pack/Unpack, a bit-packed, fixed-budget (<=15 byte) binary form of a structured or local content_id. digitCount preserves provider-id leading zeros (e.g. imdb tt0944947); structured forms are self-delimiting; the local form fills the budget exactly. Provider ids that overflow uint64 return ok=false. - Shrink ForLocal to a 112-bit (sha256(path)[:14]) hash so a local id packs losslessly into the 15-byte UUID payload. 112 bits is far beyond any single server's local-item count. No other code assumed the old width. - internal/jellycompat: EncodeStringID packs item/season content_ids into the UUID (byte 0 = kind, bytes 1..15 = packed, non-zero tag distinguishes it from the numeric encoding); DecodeStringID unpacks first and re-packs to confirm, so an opaque id whose bytes merely parse is rejected and falls through to the map. Numeric ids and arbitrary names (genres, studios) are unchanged. Net: item/season ids decode by pure computation — stable across restarts and across instances — with no lookup table. Only the rare unpackable content_id and non-content names still use the in-memory map. TDD: round-trip property tests in contentid (all kinds, leading zeros, reject cases) and a cross-instance decode test in jellycompat that fails on the old hash+map path. Full contentid + jellycompat suites green; production code golangci-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migrate): make the migration run timeout configurable (SILO_MIGRATE_TIMEOUT) The boot-path migration runner hardcoded a 5-minute context timeout. The deterministic-content-id value-remap (20260612130000) does a full-table COLLATE rewrite + 65-column remap that needs ~20 min on a real dataset (615k items / 2M episodes), so it was cancelled at 5 min. Worse, Postgres keeps the orphaned backend running (holding AccessExclusive locks) until it notices the dead client at a statement boundary, while the goose session advisory lock releases on disconnect — so each 5-min boot retry piled a new attempt behind the previous one's locks. The migration never applied; the server boot-looped. Make the timeout configurable via SILO_MIGRATE_TIMEOUT (a Go duration like "60m"); 0 or negative disables the deadline for a one-off heavy migration. Default stays 5m. All three entry points (migrate-status, --migrate-only, boot) honor it. Required for the deterministic-content-id migration to apply on any real-sized database, not just dev — the 5m cap made the PR undeployable at scale. Follow-up (not here): on cancellation the runner should actively terminate its backend so a future timeout cannot orphan a lock-holding statement. TDD: MigrationTimeout parsing (default/override/zero/invalid) + MigrationContext deadline behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(contentid): require exact length for local ids in Unpack Tighten the tagLocal branch of Unpack from `len(body) < localHashLen` to an equality check. The local form fills the compat-UUID payload exactly (no padding), so a body of any other length is non-canonical; matching it exactly keeps Unpack a strict fail-closed inverse of Pack for the fixed-length branch, which decodes client-supplied UUIDs. Not applied to the structured branch (a review suggestion proposed the same change there): structured ids are self-delimiting and the compat layer pads them with trailing zeros to fill the 15-byte UUID payload, so ignoring trailing bytes is intentional and documented. Rejecting them would make every structured id fail to decode — the jellycompat cross-instance test guards against that. Not a live bug today (the only caller passes u[1:] from a 16-byte UUID, so body is always exactly localHashLen, and idcodec re-packs to verify), but it is the correct contract and zero-risk. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
Deterministic, Cross-Server content_id
Status: Implemented (branch feat/deterministic-content-id)
Scope: catalog / scanner / metadata
Key code: internal/contentid, internal/metadata/service.go,
internal/catalog/history_source.go,
migrations/sql/20260612130000_deterministic_content_id.sql,
migrations/sql/20260614120000_content_id_online_reid.sql
This is the dev-facing reference for why content_id is what it is and how
it works. It merges the original design spec, the implementation notes, and the
PR write-up into one place.
1. The problem
content_id is the anchor almost everything hangs off: artwork, metadata, watch
history, progress, favorites, ratings, collections, credits. Historically it was
a Sonyflake — a locally generated, time-ordered random number minted at scan
time.
That means the same movie matched on two servers (or re-imported into a fresh instance) gets a different id each time. Consequence: two servers holding the identical title share none of that state. Run more than one Silo, or try to sync a profile's history between servers, and the same film shows up as a stranger on the other side — no resume point, no watched flags, no ratings. There was no stable identity to build any cross-server feature on.
We want: the same logical title produces the same content_id on every server,
every time, derived from a globally stable anchor (the provider IDs already in
the library), and stable across file renames/re-encodes so linked state
survives.
2. The decision
Make content_id a deterministic, provider-derived, structured natural key,
stored as text COLLATE "C":
| Item | content_id |
|---|---|
| Movie | movie-tmdb-228064 (fallback movie-imdb-tt2413338) |
| Series | series-tvdb-296762 |
| Season | season-tvdb-296762-1 |
| Episode | episode-tvdb-296762-1-5 |
| Unmatched / local | local-<128-bit path hash, hex> |
Why this shape:
- Collision-free with no hashing. Provider IDs are unique by construction, so this is a natural key, not a surrogate — there is no "how many bits" question and no birthday bound.
- No column-type migration. It stays
text, so the wide soft-reference graph keeps working; only the values are remapped, not the type. - Debuggable. A row's identity is legible in logs and queries, and the
scanner's match step becomes a direct PK lookup ("do I already have
movie-tmdb-228064?") instead of a join throughmedia_item_provider_ids. - Stable across renames/re-encodes — a movie keeps its tmdb id.
Why content_id, not a file-level id
Two distinct identities at different granularities, not to be conflated:
content_id— the logical item (movie/series/season/episode). One id, many files (1080p + 4K + cuts; a series owns thousands of episode files).media_files.id— one physical file; a per-server serial that only answers "which file on disk."
content_id is the high-value target (35 FKs + ~25 unconstrained soft references
point at it). Its stability requirement is the opposite of a file id's: a file
id should change on rename; a content_id must not, or every server would
drop a title's watch history on a routine rename. That's why it's anchored on
rename-invariant provider IDs, never a path or file hash. File-level dedup is a
separate, deferred concern.
3. How the id is derived
Format (SchemeVersion 1)
movie-<provider>-<id> movie-tmdb-228064
series-<provider>-<id> series-tvdb-296762
season-<provider>-<seriesId>-<n> season-tvdb-296762-1
episode-<provider>-<seriesId>-<s>-<e> episode-tvdb-296762-1-5
local-<hex112> local-<sha256(path)[:14]>
The component separator is -, an RFC 3986
unreserved character. Every component is [a-z0-9]+ (or tt + digits for
IMDb), so - is an unambiguous delimiter, and because it never needs
percent-encoding the id doubles as its own tidy URL path segment
(/item/series-tvdb-296762) — identical to what is stored in the database, so
there is no encode/decode boundary and an operator can grep the value straight
out of a URL or log. (: would be legal in a path too, but encodeURIComponent
escapes it to %3A; an unreserved separator sidesteps that entirely.)
- The leading entity-type token domain-separates namespaces, so a movie and an episode can never alias on a coincidental number.
- IDs are normalized: lowercase scheme tokens, whitespace trimmed, IMDb keeps its
ttprefix verbatim.
Frozen provider precedence
Each item anchors on one canonical provider, chosen by fixed precedence so two servers that see the same tags pick the same anchor:
- Movies:
tmdb → imdb → tvdb - Series / Season / Episode:
tvdb → tmdb → imdb(TVDB is episode-canonical)
Precedence and exact string format are frozen behind a code constant
(contentid.SchemeVersion = 1), not a DB column — any scheme change forces a
full remap that normalizes every row at once, so there is never a mixed-version
population. Staleness is recoverable by recomputing the expected id from the
provider columns and comparing.
The load-bearing invariant: the show falls out of the episode id
Season/episode ids compose from the series anchor + season/episode numbers
(both present in filenames and universally available), not their own provider
episode IDs (often missing). Because episode-<provider>-<seriesId>-<s>-<e>
embeds the series anchor, the series id is a pure string transform of the
episode id — no catalog lookup:
episode-tvdb-296762-1-5 → series-tvdb-296762
movie-tmdb-228064 → movie-tmdb-228064 (movies/series: unchanged)
This is relied on by the hot watch-history query (§5). The format must never
break it. (contentid.SeriesIDFromContentID is the canonical transform.)
Where the anchor comes from
For tagged libraries the IDs are in the folder/file name
({imdb-tt2413338} {tmdb-228064}), so both servers derive the same id from the
path string at scan time, with no API call. The denormalized
media_items.{tmdb,imdb,tvdb}_id columns and the media_item_provider_ids link
table are the other sources. content_id is derived from the link table, not a
replacement — keep the full provider-ID set indexed.
4. What was built
| Area | File(s) | Summary |
|---|---|---|
| Derivation core | internal/contentid/contentid.go |
ForMovie/ForSeries/ForSeason/ForEpisode/ForLocal, SeriesIDFromContentID, IsProviderAnchored/IsLocal, SchemeVersion, frozen precedence, normalization. |
| Generation wiring | internal/metadata/service.go |
deriveLogicalContentID/deriveSeasonContentID/deriveEpisodeContentID replace idgen.NextID() at every mint site (skeleton, provider-match, explicit/implicit seasons, episodes, scanner fallback). |
| Re-ID on match | internal/metadata/canonicalize.go |
Promotes a local- placeholder to its deterministic id at first confirmed match. |
| Hot query | internal/catalog/history_source.go |
Watch-history display_id resolves the show via the string transform for anchored episodes; skips the episodes_pkey probe. |
| Value-remap migration | migrations/sql/20260612130000_deterministic_content_id.sql |
Collision-safe Sonyflake → structured remap across the whole reference graph + COLLATE "C", with rollback. |
| Online re-ID migration | migrations/sql/20260614120000_content_id_online_reid.sql |
Adds silo_rename_content_id + ON UPDATE CASCADE to the content-id FK family. |
Generation: determinism at scan time
The scanner already parses provider tags from names, so createOrFindSkeleton
derives movie-tmdb-…/series-tvdb-… immediately — two servers seeing the same
tags mint the same id with no provider API call. This is the primary target
(Radarr/Sonarr/Plex-tagged libraries). Untagged new items get a path-derived
local- id keyed on the file path (not the folder), so distinct pre-merge
skeletons in a shared folder stay separate while being stable across rescans.
Same-server convergence is preserved by the existing dedup
(findExistingByProviderIDs) plus UpsertTx's
ON CONFLICT (content_id) DO UPDATE backstop.
Non-movie/series items (audiobook/ebook/podcast) and anchorless items stay on Sonyflake — there's no stable cross-server anchor to derive from, so changing them gains nothing.
Re-ID on match: untagged items converge later
A tagged file is born with the right id; an untagged file gets a local- id
and only learns its provider IDs later when the match worker confirms a result. A
single gate in mergeAndPersist (canonicalizeLocalContentID) promotes the
placeholder at first confirmed match:
SCAN a new file ← unchanged, still no network call
▼
createOrFindSkeleton ──► tag? YES ► movie-tmdb-123 NO ► local-<hash>
▼
(later) match worker → mergeAndPersist → canonicalizeLocalContentID
│ Is the id still local- AND do we now have provider IDs?
│ no → do nothing
│ yes → Does a row already sit at movie-tmdb-123?
│ yes → MERGE this row into it (rebindItemToExistingItem)
│ no → RENAME local-<hash> → movie-tmdb-123
▼ └─ DB auto-moves child rows (ON UPDATE CASCADE)
rest of mergeAndPersist runs with the corrected id
The guard is a single IsLocal prefix check, so tagged content and all refreshes
pay nothing; the promotion is self-healing (a partial run re-runs on the next
match). The rename primitive silo_rename_content_id reuses the migration's
catalog-driven column enumeration so the two stay in lockstep.
ON UPDATE CASCADE only fires when a content_id actually changes (essentially
never outside this promotion), so there is no steady-state cost.
The migration
The column type does not change (text → text COLLATE "C"); the values
are remapped via add-then-swap, not in-place PK mutation. It dynamically
enumerates the reference graph (the three PKs, every FK child from
pg_constraint, and soft-reference columns by name — 65 columns on the real
schema), drops/recreates FKs and triggers, and retains a content_id_migration_map
audit table for rollback.
Collisions are never merged. If two items derive to the same key, or a key is
already taken, those rows keep their Sonyflake id and are flagged collision in
the map — the migration can never violate a PK or silently drop a row. Collision
status cascades from a series to its seasons/episodes (otherwise a uniquely
numbered episode under a colliding series would get an episode-… key whose
embedded series anchor points at a non-existent row).
COLLATE "C" is applied to every referencing column in the same migration so
join keys match on both type and collation.
5. Why COLLATE "C" is mandatory
Provider-derived IDs are pure ASCII, but content_id inherits the DB default
en_US.utf8 collation. The design regresses without COLLATE "C":
- For equality / hash joins (the watch-history join),
en_US.utf8is deterministic so PostgreSQL already compares raw bytes — collation is irrelevant. - For ordering comparisons (B-tree descent on PK probes,
ORDER BY, range scans),en_US.utf8invokes libcstrcollwhileCis a plainmemcmp. - Structured keys share long prefixes (
episode-tvdb-296762-…), which makesstrcollworse than on 18-digit Sonyflake strings — so a structured key underen_US.utf8is slower than the status quo. UnderCit's ~3–5× faster.
So COLLATE "C" is load-bearing, not a free upgrade.
The hot query
The watch-history page query (history_source.go) resolves each history row's
episode key to its series, then inner-joins media_items, deduped over the
profile's entire history. Two facts:
DISTINCT ONcan't short-circuit onLIMIT— cost is O(history size) with PK probes per row, not O(page).- The structured id lets us delete the
LEFT JOIN episodesthat existed only to fetchseries_id: for anchored episode ids the show is a string transform. The join is null-poisoned for anchored ids (= NULLis an unsatisfiable b-tree scan key) so the planner never descendsepisodes_pkeyfor them. Legacy/local-ids still fall through the join.
The remaining JOIN media_items stays — it's for access/library filtering, not
the show id. Reducing full-history scans to O(page) for the heaviest profiles is
follow-up work (a per-(profile, display_id) summary table), not part of this
change.
6. Performance — measured at production scale
Measured on an isolated, prod-tuned PostgreSQL 18 instance (shared_buffers=4GB)
loaded with an exact-cardinality copy of production: 184k items, 1.93M
episodes, 775k watch-history rows / 1,622 profiles. "Before" reproduces
production today (Sonyflake, en_US.utf8); "after" applies the structured-key
remap + COLLATE "C". Every metric improves.
content_id index-probe cost — 300k forced point lookups over 1.9M keys:
The last three columns are why the faster schemes were rejected: speed alone isn't the goal — the scheme has to carry the properties the whole feature exists for. ✓ = has the property, ✗ = lacks it.
| Key scheme | Index size | µs / probe | vs today | Cross-server deterministic | Zero-join show transform | Human-readable |
|---|---|---|---|---|---|---|
Sonyflake text, en_US.utf8 (today) |
74 MB | 5.05 | 1.00× | ✗ | ✗ | ✗ |
128-bit hash bytea(16) |
74 MB | 1.80 | 2.81× | ✓ | ✗ | ✗ |
bigint surrogate |
41 MB | 1.23 | 4.11× | ✗ | ✗ | ✗ |
Structured + COLLATE "C" (this PR) |
84 MB | 1.86 | 2.71× | ✓ | ✓ | ✓ |
The structured key lands within 3% of a 128-bit hash and is the only all-✓ row. The two faster schemes each give up something load-bearing:
- 128-bit hash is cross-server deterministic (it hashes the same natural
key), but an opaque digest can't be string-transformed, so episode→series
needs a catalog join (no zero-join hot path), and it isn't legible in logs,
URLs, or
psql. bigintsurrogate is just the status quo's narrow integer: fast because it's 8 bytes, but per-server (not reproducible elsewhere), no embedded anchor to transform, and not meaningful to a human.
Index grows ~14% (84 vs 74 MB), immaterial.
Real history-page query — heaviest profile (25,384 rows):
| State | Latency | episodes_pkey probes |
Speedup |
|---|---|---|---|
| Before | 385 ms | 25,348 | 1.00× |
| This PR | 150 ms | 2,775 | 2.57× |
Concurrency (pgbench, 150 real profiles): at 100 concurrent users,
311 ms / 321 tps → 181 ms / 551 tps — 1.7× throughput, 42% lower tail
latency. Comparison cost is pure CPU, so cheaper COLLATE "C" comparisons raise
the throughput ceiling under load.
Caveat: warmed (dataset fits in shared_buffers); the before/after ratios
are the robust result, not absolute tps.
7. Known limitations / follow-ups
- Untagged series children. A series that accumulated season/episode rows
before it matched keeps those children on Sonyflake until a follow-up
recomposeSeriesChildIDspass re-derives them. Rare at first match (children usually don't exist yet); not yet wired. - Cross-provider reconciliation. A series tagged tmdb-only on one server and
tvdb-only on another still diverges — both paths derive from the denormalized
provider columns. Proper fix: consult
media_item_provider_idsbefore walking the precedence list. - O(history) query shape. The watch-history
DISTINCT ONover full history is the real liability at concurrency; the string transform removes a probe but not the full scan. The O(page) summary table is the fix, deferred. - Migration at scale. Runs in one transaction holding
AccessExclusivelocks for the COLLATE rewrite + remap — run off-peak; very large installs (10–50M rows) should use the batched procedure noted in the migration header. local-uses the absolute path, so a same-server remount re-IDs local items. Acceptable for the best-effort local namespace. The hash is 112 bits (sha256(path)[:14]) so alocal-id packs losslessly into a compat UUID (see below).- Client-visible id churn.
jellycompatencodescontent_idinto the client-facing UUID, so item ids seen by Android/Apple/Jellyfin clients change at migration (caches/resume keyed on item id reset once). silo-android and silo-apple should be made aware; server-side state is fully remapped. The encoding is reversible — a structured or localcontent_idis bit-packed into the UUID (contentid.Pack/Unpack), not hashed into a lookup table — so a compat item id decodes by pure computation and is stable across server restarts and across instances. Only arbitrary names (genres, studios) and the rarecontent_idtoo large to pack still use the in-memory hash map.
8. Prior art
- Plex: logical identity is a provider-namespaced GUID (
plex://movie/…,com.plexapp.agents.imdb://tt…) — exactly this shape. (Its per-server integerratingKeyis the non-portable counterexample we avoid.) - Jellyfin / Emby: store namespaced provider IDs and match on them; item GUIDs are deterministic. Same principle — anchor identity on the provider, not a per-server sequence.
Appendix — alternatives rejected
Retained so the decision isn't re-litigated:
- Keep Sonyflake. Non-deterministic per server — the entire problem.
- Hash provider tuple →
uuid(128-bit). Collision resistance is unnecessary (input is already unique), the id becomes opaque, and it forces atext → uuidmigration across 35 FKs + ~25 soft refs whose failure mode is a seq-scan cliff (miss one column →uuid = textper-row cast → index disabled). Pays a large migration cost for an ~8-byte width win that only matters at a 50M-row ceiling. - Hash provider tuple →
texthex. Opaque like the uuid, large like the natural key — no reason to pick it over the legible structured key. - Path or content hash. A title spans many files at many paths (no single path to hash), and the hash changes on rename — the opposite of the stability requirement. Only appropriate for file-level identity.
- Pack provider + numeric id into
bigint. Variable-width provider numbers and S/E packing make the encoding brittle; not worth it over a legible key. - File-level deterministic id (
media_files). Deferred, not rejected — lower value (no metadata/watch state hangs off it), with open problems (server-local mount anchors, same-file-in-two-libraries). A complement, out of scope here.