package credits import ( "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) // Identifying a media *version*, not an item. // // An Emby item id survives a file being replaced, which is exactly the case this subsystem // must not get wrong: Sonarr swapping a 720p rip for a 1080p one leaves the id alone, and a // marker measured against the old file would put Skip Credits somewhere arbitrary in the new // one. Keying the marker on item *and* fingerprint means a replaced file simply stops // matching — the episode becomes a candidate again, and nothing had to notice the swap or // run an invalidation pass to make that happen. // MediaVersion is what a fingerprint is computed from. Every field is optional except the // item id: a source that can only offer runtime still produces a usable fingerprint, it is // just a coarser one. type MediaVersion struct { ItemID string RuntimeMs int64 SizeBytes int64 ModifiedAt time.Time // ETag is Emby's own version token where it offers one. It is the strongest field here // because it changes for reasons the other three can miss — a remux to the same length // and near-enough the same size. ETag string } // Fingerprint is a short, stable digest of a media version. // // Truncated to sixteen bytes because it is an equality key rather than a security claim: it // is only ever compared against another fingerprint of the same item, and a full digest // would double the width of the busiest index in the schema for no gain. func Fingerprint(version MediaVersion) string { parts := []string{ strings.TrimSpace(version.ItemID), strconv.FormatInt(version.RuntimeMs, 10), strconv.FormatInt(version.SizeBytes, 10), strings.TrimSpace(version.ETag), } if !version.ModifiedAt.IsZero() { // Second resolution. Filesystems and Emby disagree about sub-second timestamps often // enough that finer granularity would invalidate markers on files nothing had // touched, which is the expensive direction to be wrong in. parts = append(parts, strconv.FormatInt(version.ModifiedAt.UTC().Unix(), 10)) } sum := sha256.Sum256([]byte(strings.Join(parts, "|"))) return hex.EncodeToString(sum[:16]) } // Weak reports a fingerprint built from too little to detect a file swap. // // Runtime alone does not distinguish a re-encode of the same episode, so a marker stored // against a weak fingerprint is one that could survive a replacement it should not have. The // service refuses to store those rather than storing a marker it cannot invalidate — the // alternative is a wrong Skip Credits position that nothing will ever correct. func (v MediaVersion) Weak() bool { return v.SizeBytes <= 0 && strings.TrimSpace(v.ETag) == "" && v.ModifiedAt.IsZero() }