143 lines
4.9 KiB
Go
143 lines
4.9 KiB
Go
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
|
||
|
|
}
|