Files
memby/server/internal/credits/resolver.go
T

143 lines
4.9 KiB
Go
Raw Normal View History

2026-08-15 09:23:26 +12:00
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
}