Metadata history¶
Every tracked field write records a row in metadata_changes. A row answers two
independent questions, in two columns.
The two-column model¶
| Column | Question | Example |
|---|---|---|
source |
What TRIGGERED the write? | manual, scan, revert, rule:<id> |
producer |
What SUPPLIED the value? | operator, provider:lastfm, nfo |
The two do not imply each other. A pull from Emby is operator-triggered
(source = manual) and platform-supplied (producer = platform:emby). A provider
refresh also records manual, so before producer existed a provider write that
overwrote an operator's field was indistinguishable from the operator typing it.
Migration 029 (internal/database/migrations/029_metadata_changes_producer.sql)
adds the column and its header gives the full argument for a second column over a
richer source vocabulary. The header's notes on what is stamped describe the
first PR only; history_producer.go is current.
Vocabulary¶
The authoritative list, with the writer behind each token, is the doc block in
internal/artist/history_producer.go. In summary:
| Producer | Meaning |
|---|---|
"" |
The writer did not record it. Makes no claim. |
operator |
A person typed or chose the value. |
provider:<name> |
A metadata provider supplied it. Bare provider: is valid. |
provider:identify_* |
An automated identify tier picked a MusicBrainz ID. A tier, not a provider. |
platform:<type> |
A connected platform supplied it via a pull. |
rule:<id> |
A rule engine pass supplied it; mirrors source. |
restore |
A stored value was put back. Asserts no authorship. |
nfo |
Parsed from an NFO file during a scan. |
filesystem |
Observed on disk (image flags, counts). |
Nothing validates a producer on the write path. Record in
internal/artist/history.go and recordHistoryTx in
internal/artist/lock_restore.go store what the context carries, so a misspelled
token is stored and shown verbatim. validHistoryProducer documents the accepted
shape and is exercised only by its own test. The one untrusted input, the client
claim on a field edit, goes through sanitizeProducerClaim in
internal/api/handlers_field.go: a positive allow-list (operator or
provider:<known provider>), anything else recorded as "" and never rejected.
The empty-string contract¶
"" is the default and it is not operator. It means "the writer did not say",
so it can never be a wrong claim. Defaulting to operator would turn every
historical row, and every future unstamped path, into a guess that launders an
automated write as a human decision.
Consequences:
- A legacy row (written before its path stamped, or before migration 029) holds
""and renders "Value source not recorded". - It is never backfilled. Nothing on the row can supply the answer, and an inferred value would be indistinguishable from a recorded one.
- It is never hidden on a value row. Absence must not read as clean. The
rule_fixaudit pseudo-field carries no value and shows no label at all (historyProducerShown). - A row written after 029 with
""is either an unstamped path or a deliberate empty (a provider-modal merge, an off-allow-list client claim).
Guardrails¶
- Census test.
TestHistoryWriteSitesAreClassifiedininternal/api/history_producer_census_test.goparses the source and fails when a function that can write history is absent fromhistorySiteClasses. Each site isstamped,caller(the producer comes from the caller's context) ornone(deliberately unstamped, with a reason). It scans onlyinternal/api,internal/rule,internal/scannerandinternal/maintenance, and matches a write verb only on a receiver namedartistService,historyService,svcorh. A writer in another package, under another receiver name, or reached through a function value or interface is invisible to it; add the directory to the test when writing history from a new package. - Runtime WARN.
resolveProducerForWritelogshistory: row written with no producer setwhen neither a per-field overlay entry for that field nor a scalar producer is on the context (producerForField). The log carriesartist_id,fieldandsource, never a value. An explicitContextWithProducer(ctx, "")is a deliberate empty and does not warn.
Add a write path¶
- Decide what supplies the value, and stamp it on the context with
artist.ContextWithProducer(one producer for the whole write) orContextWithFieldProducers(per field) before calling the artist service. A per-field overlay alone leaves every field it does not name unset and warning, so pair it with a scalar fallback (asrefreshProducerContextdoes) unless a WARN on unnamed fields is intended. - Add the function to
historySiteClasses(key<dir>/<file>::<func>) with its class and a reason. For a site in the scanned packages the census fails until you do, and it also fails on a stale entry. A site classedstampedpasses only when the write's first argument comes from one ofproducerStampers, so a new stamping helper must be added there. - If no token fits, extend the vocabulary doc block in
history_producer.go(andvalidHistoryProducer, which mirrors it), add the label inweb/templates/history_producer.goandinternal/i18n/locales/en.json, and update theproducerdescription ininternal/api/openapi.yaml. ExtendsanitizeProducerClaimonly if a browser may claim the token. - Never default to
operator. If the writer cannot say, stamp""explicitly. - Do not log the value. Only ids and the field name.
How history displays it¶
web/templates/history_producer.go turns a producer into words. Three surfaces
share it: the Activity feed, the per-field prior-values popover, and the artist
history tab.
- The source badge says what started the change. A "Value" label beside it says what supplied the value.
- The label is hidden only when it would repeat the badge (producer equals
source, or a revert whose value is a
restore) and on the syntheticrule_fixpseudo-field. provider:identify_*render as match tiers, never "from". - A token with no known prefix is shown verbatim. An unknown provider, platform, or rule name is shown raw inside its prefix's phrase. Nothing is mapped to a guessed label.
- Meaning is carried by text, not color. On the two chip surfaces (Activity, history tab) only the not-recorded state is italic; the popover shows plain text.
The lock-damage repair does not read producer; see
Lock damage repair.