Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
2.3 KiB
Go
78 lines
2.3 KiB
Go
package api
|
|
|
|
import (
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// rememberedTitles bounds the title memory below. A household plays a handful of things
|
|
// at once; this is generous enough that a report always finds its title and small enough
|
|
// that a hostile client cannot grow it into a leak.
|
|
const rememberedTitles = 256
|
|
|
|
// playbackTitles remembers what each item was called when the player asked to play it.
|
|
//
|
|
// Progress and stop reports carry an item id and nothing else — the client already knows
|
|
// the title and has no reason to send it back — so without this every playback event
|
|
// after the first reads as an opaque GUID. Deliberately in memory and deliberately lossy:
|
|
// a restarted gateway logs an id until the next play, which is a better trade than a
|
|
// round trip to Emby on a report the viewer never sees.
|
|
type playbackTitles struct {
|
|
mu sync.Mutex
|
|
titles map[string]string
|
|
order []string
|
|
}
|
|
|
|
func (p *playbackTitles) remember(itemID, title string) {
|
|
if itemID == "" || title == "" {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.titles == nil {
|
|
p.titles = make(map[string]string, rememberedTitles)
|
|
}
|
|
if _, known := p.titles[itemID]; !known {
|
|
p.order = append(p.order, itemID)
|
|
if len(p.order) > rememberedTitles {
|
|
delete(p.titles, p.order[0])
|
|
p.order = p.order[1:]
|
|
}
|
|
}
|
|
p.titles[itemID] = title
|
|
}
|
|
|
|
// name falls back to the id rather than an empty field: a line that says which item it
|
|
// could not name is still usable.
|
|
func (p *playbackTitles) name(itemID string) string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if title, known := p.titles[itemID]; known {
|
|
return title
|
|
}
|
|
return itemID
|
|
}
|
|
|
|
// millisecondDuration renders a playhead as "1h42m30s" rather than a seven-digit
|
|
// millisecond count, which no one can read at a glance.
|
|
func millisecondDuration(milliseconds int64) time.Duration {
|
|
return (time.Duration(max64(milliseconds, 0)) * time.Millisecond).Round(time.Second)
|
|
}
|
|
|
|
// watchedPercent is how a stop is read at a glance: "did they finish it, or give up?"
|
|
func watchedPercent(positionMs, durationMs int64) string {
|
|
if durationMs <= 0 {
|
|
return "unknown"
|
|
}
|
|
percent := max64(positionMs, 0) * 100 / durationMs
|
|
return strconv.FormatInt(min64(percent, 100), 10) + "%"
|
|
}
|
|
|
|
func min64(v, ceiling int64) int64 {
|
|
if v > ceiling {
|
|
return ceiling
|
|
}
|
|
return v
|
|
}
|