package credits import ( "context" "encoding/json" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/emby" ) // Turning an item id into something scannable. // // One Emby request per candidate, cached, and it answers four questions at once: how long the // file is, what version of it this is, whether Emby has already found the credits itself, and // where the bytes are. Asking them separately would be four round trips on the one path that // runs before every scan — including the scans that are then skipped because the marker // already exists. // resolverTTL is how long a resolution is remembered. Short enough that a file replaced this // afternoon is noticed today, long enough that the refresh cycle checking twenty candidates // every ten minutes does not become twenty Emby requests every ten minutes. const resolverTTL = 30 * time.Minute // EmbyResolver implements MediaResolver. // // It uses the gateway's own sync credentials rather than a viewer's, because a scan is // something the server does on its own behalf: it must work when nobody is signed in, and a // request made as a viewer would put a playback-adjacent lookup in that person's Emby history. type EmbyResolver struct { Client *emby.Client Credentials emby.Credentials mu sync.Mutex cached map[string]cachedResolution } type cachedResolution struct { media ResolvedMedia expires time.Time } func NewEmbyResolver(client *emby.Client, cred emby.Credentials) *EmbyResolver { return &EmbyResolver{ Client: client, Credentials: cred, cached: map[string]cachedResolution{}, } } // embyResolveFields is everything one request has to bring back. // // Chapters is here for the same reason it is on the live path: if Emby has already detected // the credits, this subsystem must do nothing at all, and finding that out after opening a // decoder would be finding it out too late. MediaSources carries the size and version token // the fingerprint is built from — without them a marker could survive the file it describes. const embyResolveFields = "MediaSources,Chapters,ParentIndexNumber,IndexNumber,SeriesId" func (r *EmbyResolver) Resolve(ctx context.Context, itemID string) (ResolvedMedia, error) { if r == nil || r.Client == nil || itemID == "" { return ResolvedMedia{}, nil } r.mu.Lock() entry, found := r.cached[itemID] r.mu.Unlock() if found && time.Now().Before(entry.expires) { return entry.media, nil } raw, err := r.Client.Item(ctx, r.Credentials, itemID, embyResolveFields) if err != nil { return ResolvedMedia{}, err } var parsed struct { ID string `json:"Id"` Etag string `json:"Etag"` SeriesID string `json:"SeriesId"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` RunTimeTicks int64 `json:"RunTimeTicks"` DateModified string `json:"DateModified"` Chapters []struct { StartPositionTicks int64 `json:"StartPositionTicks"` MarkerType string `json:"MarkerType"` Name string `json:"Name"` } `json:"Chapters"` MediaSources []struct { ID string `json:"Id"` Size int64 `json:"Size"` ETag string `json:"ETag"` } `json:"MediaSources"` } if err := json.Unmarshal(raw, &parsed); err != nil { return ResolvedMedia{}, err } media := ResolvedMedia{ URL: r.Client.InternalStreamURL(r.Credentials, itemID), SeriesID: parsed.SeriesID, Season: parsed.ParentIndexNumber, Episode: parsed.IndexNumber, RuntimeMs: parsed.RunTimeTicks / 10_000, Version: MediaVersion{ ItemID: itemID, RuntimeMs: parsed.RunTimeTicks / 10_000, ETag: strings.TrimSpace(parsed.Etag), }, } if len(parsed.MediaSources) > 0 { source := parsed.MediaSources[0] media.Version.SizeBytes = source.Size if media.Version.ETag == "" { media.Version.ETag = strings.TrimSpace(source.ETag) } } if parsed.DateModified != "" { if modified, err := time.Parse(time.RFC3339, parsed.DateModified); err == nil { media.Version.ModifiedAt = modified } } // A CreditsStart marker Emby wrote itself. Rare on 4.10 — a survey of this household's // twenty-thousand-item library found none — but where it exists it is authoritative and // free, and nothing here should spend a decoder on a question already answered. for _, chapter := range parsed.Chapters { if strings.EqualFold(chapter.MarkerType, "CreditsStart") && chapter.StartPositionTicks > 0 { media.EmbyCreditsMs = chapter.StartPositionTicks / 10_000 } } r.mu.Lock() // Bounded rather than unbounded: the working set is the queue plus whatever is playing, // so a cache that grew with the library would be holding resolutions for episodes nobody // has looked at since the container started. if len(r.cached) > 256 { r.cached = map[string]cachedResolution{} } r.cached[itemID] = cachedResolution{media: media, expires: time.Now().Add(resolverTTL)} r.mu.Unlock() return media, nil }