0.2.64 update
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user