0.2.57 - Traliers bug fixes

This commit is contained in:
ponzischeme89
2026-08-12 13:08:53 +12:00
parent f2d052dbf6
commit 64f19aeef2
45 changed files with 1215 additions and 681 deletions
+4 -1
View File
@@ -449,7 +449,9 @@ shown, it asks for an Emby household username and password and verifies them dir
against Emby. Credentials are never stored. The temporary Emby access token is logged
out immediately, and the browser receives a secure, HTTP-only, same-site installer cookie
valid for 30 minutes. This browser login does not create a Memby TV session and therefore
does not appear in the user's signed-in device list.
does not appear in the user's signed-in device list. Emby records the password check
under the gateway's own client name (`MEMBY_GATEWAY_CLIENT_NAME`), not a television's, so
its temporary device record is never mistaken for a set in the house.
After authentication, `/updates/latest.apk` redirects to the current immutable, versioned
APK in the persistent `memby-releases` volume. Direct APK requests require either that
@@ -619,6 +621,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
| `MEMBY_CLIENT_NAME` | `MbyATV` | Client name sent to Emby; must match the app's direct path |
| `MEMBY_GATEWAY_CLIENT_NAME` | `MbyGateway` | Client name Emby records for the gateway's own requests, including an admin or installer sign-in |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
+7 -2
View File
@@ -108,7 +108,10 @@ func run(log *slog.Logger, events *logging.Buffer) error {
log.Warn("could not clear interrupted sync runs", "error", err)
}
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
embyClient := emby.New(
cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.GatewayClientName,
cfg.UpstreamTimeout,
)
mdblistClient := mdblist.New(mdblist.DefaultBaseURL, cfg.UpstreamTimeout)
var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" {
@@ -153,6 +156,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync",
Gateway: true,
}, log.With("component", "library"))
var forYouService *foryou.Service
if tracearrClient != nil {
@@ -163,7 +167,8 @@ func run(log *slog.Logger, events *logging.Buffer) error {
forYouService.ConfigureTimeContext(cfg.SonarrLocation)
forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
DeviceID: "memby-for-you-builder", DeviceName: "Memby For You builder",
DeviceID: "memby-for-you-builder", DeviceName: "MbyGateway For You",
Gateway: true,
})
}
+1 -7
View File
@@ -35,7 +35,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type Server struct {
@@ -86,7 +85,6 @@ type Server struct {
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
trailers *trailer.Resolver
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -107,10 +105,6 @@ type Deps struct {
}
func New(cfg config.Config, deps Deps) *Server {
trailerTimeout := cfg.UpstreamTimeout
if trailerTimeout <= 0 || trailerTimeout > 8*time.Second {
trailerTimeout = 8 * time.Second
}
return &Server{
cfg: cfg,
emby: deps.Emby,
@@ -125,7 +119,6 @@ func New(cfg config.Config, deps Deps) *Server {
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
trailers: trailer.New(&http.Client{Timeout: trailerTimeout}),
}
}
@@ -201,6 +194,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
+7 -2
View File
@@ -69,7 +69,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName, clientVersion(r),
r.Context(), req.Username, req.Password,
emby.Credentials{
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
},
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
@@ -250,7 +254,8 @@ func (s *Server) retireEmbyDevice(ctx context.Context, deviceID string) {
}
if err := s.emby.DeleteDevice(ctx, emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(),
Gateway: true,
}, deviceID); err != nil {
s.loggerFor(ctx).Warn("emby device cleanup failed",
"removed_device_id", deviceID, "error", err)
+28 -4
View File
@@ -19,13 +19,30 @@ const (
installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// adminRenewWithin is how close to expiry a session must be before an operator's own
// request re-issues it. Half the TTL avoids rewriting the cookie on every request.
adminRenewWithin = adminSessionTTL / 2
)
// gatewayDeviceName is what Emby records for a device row the gateway creates for itself.
// It follows the gateway's client name so one operator-set word covers both halves of how
// the server identifies itself, and it is deliberately never the product name — Emby's
// device list is read by whoever runs the server, and an entry called "Memby …" there
// reads as one of the household's televisions.
func (s *Server) gatewayDeviceName() string {
if name := strings.TrimSpace(s.cfg.GatewayClientName); name != "" {
return name
}
return emby.DefaultGatewayClientName
}
// installerDeviceName separates the temporary record an admin or installer sign-in
// creates from the gateway's own, so a password check is recognisable while it exists.
func (s *Server) installerDeviceName() string {
return s.gatewayDeviceName() + " Installer"
}
func (s *Server) installerSecret() []byte {
if s.cfg.ReleasePublishToken == "" {
return nil
@@ -192,8 +209,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Gateway, not a television: this sign-in is the admin console or the web installer
// checking a password, so Emby records it under the gateway's own client name.
auth, err := s.emby.Authenticate(
r.Context(), username, password, installerDeviceID, installerDeviceName, "",
r.Context(), username, password,
emby.Credentials{
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true,
},
)
if err != nil {
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
@@ -204,13 +226,15 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
// it succeeded, so retire the upstream session immediately and never persist it.
if err := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName,
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(),
Gateway: true,
}); err != nil {
s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
}
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(),
Gateway: true,
}, installerDeviceID); err != nil {
s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
+28 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -173,7 +174,8 @@ func isPlaybackItemPath(path string) bool {
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
// "search" on its own would claim any future per-item search as playback. A seek
// preview is the same shape: the frame number is the last segment, not the word.
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") {
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") ||
strings.Contains(path, "/trailers") {
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
@@ -182,3 +184,28 @@ func isPlaybackItemPath(path string) bool {
}
return false
}
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
// Forwarded address is the original client when the gateway is behind its normal reverse
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
// never authentication or access control.
func requestClientIP(r *http.Request) string {
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
return ip.String()
}
return "unknown"
}
+11
View File
@@ -48,6 +48,8 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/items/42/related": "details",
"/v1/items/42/playback": "playback",
"/v1/items/42/next": "playback",
"/v1/items/42/trailers/resolve": "playback",
"/v1/items/42/trailers/report": "playback",
"/v1/items/42/subtitles/search": "playback",
"/v1/items/42/trickplay": "playback",
"/v1/items/42/trickplay/12.jpg": "playback",
@@ -69,6 +71,15 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
}
}
func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil)
request.RemoteAddr = "10.0.0.2:41234"
request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2")
if got := requestClientIP(request); got != "203.0.113.9" {
t.Fatalf("client ip = %q", got)
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60")
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStoppedPlaybackReportRemainsRetryableWhenEmbyIsDown(t *testing.T) {
defer upstream.Close()
s := &Server{
emby: emby.New(upstream.URL, upstream.URL, "Memby test", time.Second),
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
req := httptest.NewRequest(
+5
View File
@@ -140,6 +140,11 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Roll into the next episode when one finishes.",
Kind: preferenceToggle, Default: true,
},
{
Key: "playNextEpisodePreview", Name: "Next-episode recap or preview", Area: "Playback",
Description: "With auto-play on, play a matched YouTube recap or preview two minutes before the episode ends.",
Kind: preferenceToggle, Default: false,
},
{
Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
Description: "Show the lower-third when ten minutes are left.",
+1 -1
View File
@@ -282,7 +282,7 @@ func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
ReleasePublishToken: "test-release-secret",
SyncUserID: "service-user", SyncAPIKey: "service-token",
},
emby: emby.New(upstream.URL, upstream.URL, "Memby test", 2*time.Second),
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", 2*time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
form := url.Values{
+43 -18
View File
@@ -14,7 +14,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type remoteTrailer struct {
@@ -55,6 +54,7 @@ type trailerPlaybackResponse struct {
CandidateID string `json:"candidateId"`
Provider string `json:"provider"`
URL string `json:"url"`
SourceURL string `json:"sourceUrl,omitempty"`
Title string `json:"title"`
ItemID string `json:"itemId,omitempty"`
MediaSourceID string `json:"mediaSourceId,omitempty"`
@@ -62,6 +62,13 @@ type trailerPlaybackResponse struct {
PlayMethod string `json:"playMethod,omitempty"`
}
type trailerReportRequest struct {
CandidateID string `json:"candidateId"`
Provider string `json:"provider"`
Phase string `json:"phase"`
Reason string `json:"reason,omitempty"`
}
func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
@@ -107,37 +114,24 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se
s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers")
return
}
resolver := s.trailers
if resolver == nil {
resolver = trailer.New(nil)
}
for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) {
if excluded[candidate.ID] {
if candidate.SourceURL != "" {
resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL})
}
continue
}
if len(candidate.LocalItem) > 0 {
if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil {
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, resolved)
return
}
continue
}
resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{
Provider: candidate.Provider,
URL: candidate.SourceURL,
})
if resolveErr != nil {
continue
}
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
// Remote pages are resolved on the television. YouTube signs direct media URLs for
// the resolving IP, so resolving here can make the URL unusable from the viewer's
// network and makes provider traffic appear to come from the gateway.
writeJSON(w, http.StatusOK, trailerPlaybackResponse{
CandidateID: candidate.ID,
Provider: candidate.Provider,
URL: resolved.URL,
SourceURL: candidate.SourceURL,
Title: trailerTitle(manifest.Title, candidate.Name),
PlayMethod: "DirectPlay",
})
@@ -146,6 +140,37 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se
writeError(w, http.StatusNotFound, "no playable trailer is available")
}
func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
var report trailerReportRequest
if itemID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&report) != nil {
writeError(w, http.StatusBadRequest, "invalid trailer report")
return
}
report.CandidateID = strings.TrimSpace(report.CandidateID)
report.Provider = strings.ToLower(strings.TrimSpace(report.Provider))
report.Phase = strings.ToLower(strings.TrimSpace(report.Phase))
if report.CandidateID == "" || report.Provider == "" ||
(report.Phase != "started" && report.Phase != "failed" && report.Phase != "completed") {
writeError(w, http.StatusBadRequest, "invalid trailer report")
return
}
fields := []any{
"item_id", itemID,
"provider", report.Provider,
"candidate", report.CandidateID,
"source_ip", requestClientIP(r),
}
if reason := strings.TrimSpace(report.Reason); reason != "" {
fields = append(fields, "reason", reason)
}
s.loggerFor(r.Context()).Info("trailer playback "+report.Phase, fields...)
if report.Phase == "started" {
s.rememberTrailerCandidate(r.Context(), sess, itemID, report.CandidateID)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) preferredTrailerCandidates(
ctx context.Context,
sess store.Session,
+40 -36
View File
@@ -14,17 +14,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type trailerRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestResolveTrailerFallsThroughProviders(t *testing.T) {
func TestResolveTrailerSkipsRejectedProviderOnTheClientBehalf(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/LocalTrailers"):
@@ -43,35 +37,14 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) {
}))
defer upstream.Close()
resolverClient := &http.Client{Transport: trailerRoundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusOK
body := ""
headers := http.Header{}
switch request.URL.Host {
case "trailers.apple.com":
status = http.StatusNotFound
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected trailer request: %s", request.URL)
}
return &http.Response{
StatusCode: status, Header: headers,
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})}
server := &Server{
cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second),
trailers: trailer.New(resolverClient),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", bytes.NewBufferString(`{}`))
appleID := trailerCandidateID("apple", "https://trailers.apple.com/missing.mov")
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve",
bytes.NewBufferString(`{"excludedCandidateIds":["`+appleID+`"]}`))
request.SetPathValue("id", "film-1")
recorder := httptest.NewRecorder()
server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"})
@@ -82,11 +55,32 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Provider != "youtube" || response.URL != "https://media.example/trailer.mp4" {
if response.Provider != "youtube" || response.SourceURL != "https://youtu.be/dQw4w9WgXcQ" || response.URL != "" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole)
server := &Server{log: logger}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report",
bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`))
request.SetPathValue("id", "film-1")
request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2")
request, _ = withRequestIdentity(request)
identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"})
recorder := httptest.NewRecorder()
server.handleTrailerReport(recorder, request, store.Session{})
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d", recorder.Code)
}
page := events.Events(0, 10)
if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" ||
page.Events[0].Message != "trailer playback started" {
t.Fatalf("unexpected event: %+v", page.Events)
}
}
func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
candidates := []trailerCandidate{
{ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")},
@@ -99,3 +93,13 @@ func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
t.Fatalf("unexpected order: %+v", candidates)
}
}
func TestNextEpisodePreviewPreferenceIsOptional(t *testing.T) {
definition, ok := preferenceDefinitionFor("playNextEpisodePreview")
if !ok {
t.Fatal("playNextEpisodePreview is missing from the preference catalogue")
}
if definition.Kind != preferenceToggle || definition.Default != false {
t.Fatalf("definition = %+v, want an opt-in toggle", definition)
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.33
0.1.34
+10
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
_ "time/tzdata"
"github.com/ponzischeme89/memby/server/internal/emby"
)
type Config struct {
@@ -30,6 +32,13 @@ type Config struct {
// no business being the thing that identifies a client to a third party.
ClientName string
// GatewayClientName is reported instead for a request the gateway makes on its own
// behalf — the library sync, the health probe, device cleanup, and an operator
// signing into the admin console or the web installer. Those are the server asking,
// and reporting them as a television made Emby's device list claim a set that does
// not exist in the house.
GatewayClientName string
HomeTTL time.Duration
ItemTTL time.Duration
SearchTTL time.Duration
@@ -152,6 +161,7 @@ func Load() (Config, error) {
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"),
GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
+52 -12
View File
@@ -25,6 +25,12 @@ type Client struct {
baseURL string
publicURL string
clientName string
// gatewayClientName identifies the requests the gateway makes on its own behalf, so
// Emby's device list separates a television from the server standing behind it. The
// admin and installer sign-ins are the ones an operator sees: those are the gateway
// asking, not a set in a living room, and reporting them as a television made the
// list claim a device that does not exist.
gatewayClientName string
// gatewayVersion labels the requests the gateway makes on its own behalf — the
// library sync, the health probe, device cleanup — which belong to no television and
// so have no app version to report.
@@ -42,6 +48,12 @@ type Credentials struct {
// as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one
// makes every set in the house look like the same build.
ClientVersion string
// Gateway marks a request the gateway makes for itself — the library sync, the health
// probe, device cleanup, an operator signing into the admin console — rather than on
// behalf of a television. It is stated rather than inferred from a missing token or
// version: an old app reports neither, and misreading one as the server would put a
// television in Emby's list under the wrong name.
Gateway bool
}
type Device struct {
@@ -133,12 +145,24 @@ func (e *APIError) Error() string {
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
// DefaultGatewayClientName is what Emby records for a request the gateway makes for
// itself. It is deliberately not the product name: this travels to whatever Emby does
// with its own logs, and it must never read as one of the household's televisions.
const DefaultGatewayClientName = "MbyGateway"
// New builds a client. clientName identifies a television on the wire to Emby and
// gatewayClientName identifies the gateway itself; a blank gateway name falls back to
// DefaultGatewayClientName rather than borrowing the television's.
func New(baseURL, publicURL, clientName, gatewayClientName string, timeout time.Duration) *Client {
if strings.TrimSpace(gatewayClientName) == "" {
gatewayClientName = DefaultGatewayClientName
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
gatewayVersion: buildinfo.Version(),
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
gatewayClientName: gatewayClientName,
gatewayVersion: buildinfo.Version(),
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
@@ -150,16 +174,21 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
}
}
// Authenticate signs a television in. clientVersion is the app version that set
// reported; it is what Emby stamps on the device record it creates here, so an empty one
// leaves the entry claiming the gateway's own build.
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) {
// Authenticate signs a television in. cred carries the device the record is created for
// and that set's ClientVersion — Emby stamps it on the record, so an empty one leaves the
// entry claiming the gateway's own build. cred.Gateway marks a sign-in the gateway is
// making for itself (the admin console and the web installer), which Emby then records
// under the gateway's own client name rather than as a television.
func (c *Client) Authenticate(ctx context.Context, username, password string, cred Credentials) (*AuthResult, error) {
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil {
return nil, err
}
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion},
Credentials{
DeviceID: cred.DeviceID, DeviceName: cred.DeviceName,
ClientVersion: cred.ClientVersion, Gateway: cred.Gateway,
},
bytes.NewReader(body))
if err != nil {
return nil, err
@@ -610,7 +639,8 @@ func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, ind
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil,
Credentials{Gateway: true}, nil)
if err != nil {
return err
}
@@ -657,7 +687,13 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
}
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
// A gateway request is not a television, so it must not fall back to the
// unnamed-set placeholder: that put the server in Emby's device list wearing a
// name that reads as somebody's TV.
deviceName = "Memby TV"
if cred.Gateway {
deviceName = c.gatewayClientName
}
}
// The version Emby records is the television's app version, not a constant: every
// device in the dashboard read as one build before this, so there was no way to tell
@@ -666,10 +702,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
if version == "" {
version = c.gatewayVersion
}
clientName := c.clientName
if cred.Gateway {
clientName = c.gatewayClientName
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
strings.ReplaceAll(version, `"`, ""),
))
if cred.Token != "" {
@@ -13,7 +13,7 @@ import (
// name is never the product name, and the version is the set's own build rather than a
// constant that made every device look alike.
func TestAuthHeaderCarriesClientVersion(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil,
@@ -30,7 +30,7 @@ func TestAuthHeaderCarriesClientVersion(t *testing.T) {
}
func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, Credentials{}, nil,
@@ -46,3 +46,52 @@ func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) {
t.Fatalf("auth header %q does not carry the gateway build %q", got, client.gatewayVersion)
}
}
// A request the gateway makes for itself — the sync, the health probe, an operator
// signing into the admin console — is not a television, and Emby's device list said it
// was. It reports the gateway's own name and never falls back to the unnamed-set
// placeholder, which is what made the server read as somebody's TV.
func TestAuthHeaderNamesTheGatewayForItsOwnRequests(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, Credentials{Gateway: true}, nil,
)
if err != nil {
t.Fatal(err)
}
got := req.Header.Get("X-Emby-Authorization")
if !strings.Contains(got, `Client="MbyGateway"`) {
t.Fatalf("gateway request did not report the gateway client name: %q", got)
}
if strings.Contains(got, "Memby") {
t.Fatalf("gateway request carried the product name to Emby: %q", got)
}
}
// A television's request must keep reporting the television's client name, whatever the
// gateway calls itself — the two identities are separate rows in Emby's device list.
func TestAuthHeaderKeepsTelevisionClientName(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil,
Credentials{DeviceID: "tv-1", DeviceName: "Living room", ClientVersion: "0.2.57"}, nil,
)
if err != nil {
t.Fatal(err)
}
if got := req.Header.Get("X-Emby-Authorization"); !strings.Contains(got, `Client="MbyATV"`) {
t.Fatalf("television request did not report the app client name: %q", got)
}
}
// A blank gateway name must not silently become the television's, which would put the
// server back in the device list as a set.
func TestGatewayClientNameFallsBackToDefault(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", " ", time.Second)
if client.gatewayClientName != DefaultGatewayClientName {
t.Fatalf("gateway client name = %q, want %q",
client.gatewayClientName, DefaultGatewayClientName)
}
}
@@ -11,6 +11,15 @@ func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) {
}
}
// Emby shows the device profile's name in its playback device list, so it carries the
// client identity — never the product name, which is what it said before.
func TestAndroidTVProfileReportsTheClientIdentityNotTheProductName(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{})
if got := profile["Name"]; got != "MbyATV" {
t.Fatalf("device profile name = %v, want MbyATV", got)
}
}
func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
H264Profiles: []string{"baseline", "main", "high"},
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) {
}))
defer upstream.Close()
client := New(upstream.URL, upstream.URL, "MbyATV", time.Second)
client := New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second)
got, err := client.HideFromResume(
context.Background(),
Credentials{UserID: "user-1", Token: "token"},
@@ -7,7 +7,7 @@ import (
)
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "Memby", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
got := client.SubtitleURL(
Credentials{Token: "a b"},
"item id",
+9 -1
View File
@@ -11,6 +11,14 @@ package emby
import "strconv"
// deviceProfileName is what Emby records against a playback session, and it appears in
// the dashboard's device and playback lists. It is the client identity on the wire, not
// the product name — it read as "Memby Android TV" there, which is exactly the name that
// has no business travelling to somebody else's logs — and it must match the literal the
// television sends on the direct path (DeviceProfile.embyAndroidTv), or one set playing
// both ways appears twice.
const deviceProfileName = "MbyATV"
var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
}
@@ -44,7 +52,7 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities)
return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"Name": deviceProfileName, "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
+11 -2
View File
@@ -501,8 +501,8 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err
EmbyUserID: user.ID,
EmbyToken: s.serviceCred.Token,
Username: user.Name,
DeviceID: "memby-for-you-builder",
DeviceName: "Memby For You builder",
DeviceID: builderDeviceID,
DeviceName: builderDeviceName,
}
}
out := make([]store.Session, 0, len(byID))
@@ -964,9 +964,18 @@ func parsedTime(value string) *time.Time {
return &parsed
}
// builderDeviceID and builderDeviceName stand in for a television when the gateway
// rebuilds a viewer's rows on its own. It is the server asking, so Emby records it under
// the gateway's client name rather than as a set in the house — see Credentials.Gateway.
const (
builderDeviceID = "memby-for-you-builder"
builderDeviceName = "MbyGateway For You"
)
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
Gateway: sess.DeviceID == builderDeviceID,
}
}
+3
View File
@@ -267,6 +267,9 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
UserID: sess.EmbyUserID,
Token: sess.EmbyToken,
DeviceID: "memby-gateway-sync",
// The token is borrowed from a television, but the import is the gateway's own
// work and must not appear in Emby's device list as that set.
Gateway: true,
}, nil
}
-428
View File
@@ -1,428 +0,0 @@
// Package trailer resolves remote trailer pages to native media streams.
package trailer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
maxPageBytes = 2 << 20
cacheTTL = 30 * time.Minute
)
var ErrUnavailable = errors.New("trailer unavailable")
type Source struct {
Provider string
URL string
}
type Result struct {
URL string
MimeType string
}
type Provider interface {
Name() string
Supports(string) bool
Resolve(context.Context, string) (Result, error)
}
type cacheEntry struct {
result Result
expiresAt time.Time
}
// Resolver is an ordered provider chain with a short-lived successful mapping cache.
// The cached value avoids repeating YouTube page resolution on Back → Trailer while its
// signed media URL is still useful; failures are never cached.
type Resolver struct {
providers []Provider
mu sync.Mutex
cache map[string]cacheEntry
}
func New(client *http.Client) *Resolver {
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
return &Resolver{
providers: []Provider{newAppleProvider(client), newYouTubeProvider(client)},
cache: map[string]cacheEntry{},
}
}
func (r *Resolver) Resolve(ctx context.Context, source Source) (Result, error) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
now := time.Now()
r.mu.Lock()
if cached, ok := r.cache[key]; ok && cached.expiresAt.After(now) {
r.mu.Unlock()
return cached.result, nil
}
delete(r.cache, key)
r.mu.Unlock()
for _, provider := range r.providers {
if source.Provider != "" && !strings.EqualFold(source.Provider, provider.Name()) {
continue
}
if !provider.Supports(source.URL) {
continue
}
result, err := provider.Resolve(ctx, source.URL)
if err != nil {
return Result{}, err
}
r.mu.Lock()
r.cache[key] = cacheEntry{result: result, expiresAt: now.Add(cacheTTL)}
if len(r.cache) > 128 {
for candidate, entry := range r.cache {
if entry.expiresAt.Before(now) {
delete(r.cache, candidate)
}
}
}
r.mu.Unlock()
return result, nil
}
return Result{}, ErrUnavailable
}
func (r *Resolver) Invalidate(source Source) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
r.mu.Lock()
delete(r.cache, key)
r.mu.Unlock()
}
type appleProvider struct{ client *http.Client }
func newAppleProvider(client *http.Client) Provider { return &appleProvider{client: client} }
func (*appleProvider) Name() string { return "apple" }
func (*appleProvider) Supports(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && (isHostOrSubdomain(parsed.Hostname(), "apple.com") ||
isHostOrSubdomain(parsed.Hostname(), "apple.co"))
}
func (p *appleProvider) Resolve(ctx context.Context, raw string) (Result, error) {
if looksLikeMediaURL(raw) {
return p.validate(ctx, raw)
}
body, err := fetchLimited(ctx, p.client, raw, maxPageBytes, "text/html")
if err != nil {
return Result{}, err
}
links := mediaLinks(string(body))
if len(links) == 0 {
return Result{}, ErrUnavailable
}
sort.SliceStable(links, func(i, j int) bool { return mediaQuality(links[i]) > mediaQuality(links[j]) })
for _, candidate := range links {
if result, err := p.validate(ctx, candidate); err == nil {
return result, nil
}
}
return Result{}, ErrUnavailable
}
func (p *appleProvider) validate(ctx context.Context, raw string) (Result, error) {
contentType, err := validateMediaURL(ctx, p.client, raw)
if err != nil {
return Result{}, err
}
return Result{URL: raw, MimeType: contentType}, nil
}
var appleMediaURL = regexp.MustCompile(`https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?`)
func mediaLinks(body string) []string {
matches := appleMediaURL.FindAllString(body, -1)
seen := map[string]bool{}
out := make([]string, 0, len(matches))
for _, match := range matches {
candidate := html.UnescapeString(strings.ReplaceAll(match, `\/`, `/`))
if !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
return out
}
func mediaQuality(raw string) int {
lower := strings.ToLower(raw)
for _, quality := range []int{2160, 1440, 1080, 720, 480, 360} {
if strings.Contains(lower, fmt.Sprintf("%d", quality)) {
return quality
}
}
return 0
}
type youTubeProvider struct{ client *http.Client }
func newYouTubeProvider(client *http.Client) Provider { return &youTubeProvider{client: client} }
func (*youTubeProvider) Name() string { return "youtube" }
func (*youTubeProvider) Supports(raw string) bool { return youtubeVideoID(raw) != "" }
func (p *youTubeProvider) Resolve(ctx context.Context, raw string) (Result, error) {
videoID := youtubeVideoID(raw)
if videoID == "" {
return Result{}, ErrUnavailable
}
responses := []func(context.Context, string) (youtubePlayer, error){
p.innerTubePlayer,
p.watchPagePlayer,
}
for _, load := range responses {
player, err := load(ctx, videoID)
if err != nil || !strings.EqualFold(player.PlayabilityStatus.Status, "OK") {
continue
}
// YouTube's formats list contains progressive audio+video streams. AdaptiveFormats
// are separate tracks and would begin silently if handed straight to Media3.
formats := player.StreamingData.Formats
sort.SliceStable(formats, func(i, j int) bool {
return formats[i].Height > formats[j].Height ||
(formats[i].Height == formats[j].Height && formats[i].Bitrate > formats[j].Bitrate)
})
for _, format := range formats {
// Native playback needs one progressive stream carrying both tracks. Adaptive
// video-only formats are deliberately skipped rather than starting silent.
if format.URL == "" || !strings.Contains(format.MimeType, "video/") {
continue
}
contentType, validationErr := validateMediaURL(ctx, p.client, format.URL)
if validationErr == nil {
return Result{URL: format.URL, MimeType: contentType}, nil
}
}
}
return Result{}, ErrUnavailable
}
type youtubePlayer struct {
PlayabilityStatus struct {
Status string `json:"status"`
} `json:"playabilityStatus"`
StreamingData struct {
Formats []youtubeFormat `json:"formats"`
AdaptiveFormats []youtubeFormat `json:"adaptiveFormats"`
} `json:"streamingData"`
}
type youtubeFormat struct {
URL string `json:"url"`
MimeType string `json:"mimeType"`
Height int `json:"height"`
Bitrate int `json:"bitrate"`
}
func (p *youTubeProvider) innerTubePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
payload := map[string]any{
"videoId": videoID, "contentCheckOk": true, "racyCheckOk": true,
"context": map[string]any{"client": map[string]any{
"clientName": "ANDROID", "clientVersion": "20.10.38", "hl": "en", "gl": "NZ",
}},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.youtube.com/youtubei/v1/player", bytes.NewReader(body))
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip")
return p.doPlayer(req)
}
func (p *youTubeProvider) watchPagePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://www.youtube.com/watch?v="+url.QueryEscape(videoID)+"&bpctr=9999999999&has_verified=1", nil)
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36")
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
for _, marker := range []string{"ytInitialPlayerResponse = ", `"playerResponse":`} {
if raw := balancedJSONObject(body, marker); raw != "" {
var player youtubePlayer
if json.Unmarshal([]byte(raw), &player) == nil {
return player, nil
}
}
}
return youtubePlayer{}, ErrUnavailable
}
func (p *youTubeProvider) doPlayer(req *http.Request) (youtubePlayer, error) {
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
var player youtubePlayer
if err := json.Unmarshal([]byte(body), &player); err != nil {
return youtubePlayer{}, err
}
return player, nil
}
func (p *youTubeProvider) do(req *http.Request) (string, error) {
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes+1))
if err != nil || len(body) > maxPageBytes {
return "", ErrUnavailable
}
return string(body), nil
}
func youtubeVideoID(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
var id string
switch {
case host == "youtu.be":
id = strings.Trim(parsed.Path, "/")
case isHostOrSubdomain(host, "youtube.com"), isHostOrSubdomain(host, "youtube-nocookie.com"):
id = parsed.Query().Get("v")
if id == "" {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) == 2 && (parts[0] == "embed" || parts[0] == "shorts") {
id = parts[1]
}
}
}
if len(id) != 11 {
return ""
}
for _, char := range id {
if !(char == '-' || char == '_' || char >= 'a' && char <= 'z' ||
char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return ""
}
}
return id
}
func isHostOrSubdomain(host, root string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
root = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(root), "."))
return host == root || strings.HasSuffix(host, "."+root)
}
func balancedJSONObject(body, marker string) string {
start := strings.Index(body, marker)
if start < 0 {
return ""
}
start += len(marker)
for start < len(body) && body[start] != '{' {
start++
}
if start == len(body) {
return ""
}
depth, quoted, escaped := 0, false, false
for index := start; index < len(body); index++ {
char := body[index]
if quoted {
if escaped {
escaped = false
} else if char == '\\' {
escaped = true
} else if char == '"' {
quoted = false
}
continue
}
switch char {
case '"':
quoted = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return body[start : index+1]
}
}
}
return ""
}
func looksLikeMediaURL(raw string) bool {
path := strings.ToLower(strings.Split(raw, "?")[0])
return strings.HasSuffix(path, ".mov") || strings.HasSuffix(path, ".mp4") || strings.HasSuffix(path, ".m3u8")
}
func validateMediaURL(ctx context.Context, client *http.Client, raw string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return "", err
}
req.Header.Set("Range", "bytes=0-0")
req.Header.Set("User-Agent", "Memby trailer resolver")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return "", ErrUnavailable
}
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
if !strings.HasPrefix(contentType, "video/") && contentType != "application/vnd.apple.mpegurl" &&
contentType != "application/x-mpegurl" && contentType != "application/octet-stream" {
return "", ErrUnavailable
}
return contentType, nil
}
func fetchLimited(ctx context.Context, client *http.Client, raw string, limit int64, accept string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", accept)
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil || int64(len(body)) > limit {
return nil, ErrUnavailable
}
return body, nil
}
-116
View File
@@ -1,116 +0,0 @@
package trailer
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestYouTubeVideoID(t *testing.T) {
for _, raw := range []string{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://youtube.com/shorts/dQw4w9WgXcQ",
} {
if got := youtubeVideoID(raw); got != "dQw4w9WgXcQ" {
t.Fatalf("youtubeVideoID(%q) = %q", raw, got)
}
}
if got := youtubeVideoID("https://example.com/watch?v=dQw4w9WgXcQ"); got != "" {
t.Fatalf("accepted a non-YouTube host: %q", got)
}
}
func TestProviderHostMatchingRejectsLookalikeDomains(t *testing.T) {
if newAppleProvider(http.DefaultClient).Supports("https://notapple.com/trailer.mov") {
t.Fatal("lookalike Apple host was accepted")
}
if youtubeVideoID("https://notyoutube.com/watch?v=dQw4w9WgXcQ") != "" {
t.Fatal("lookalike YouTube host was accepted")
}
}
func TestYouTubeResolverReturnsValidatedProgressiveStream(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
body := ""
status := http.StatusOK
headers := http.Header{}
switch request.URL.Host {
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720,"bitrate":1000}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected request to %s", request.URL)
}
return &http.Response{
StatusCode: status,
Header: headers,
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}, nil
})}
resolver := New(client)
result, err := resolver.Resolve(context.Background(), Source{
Provider: "youtube",
URL: "https://youtu.be/dQw4w9WgXcQ",
})
if err != nil {
t.Fatal(err)
}
if result.URL != "https://media.example/trailer.mp4" || result.MimeType != "video/mp4" {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestApplePageChoosesBestValidatedStream(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
body := ""
headers := http.Header{}
status := http.StatusOK
if request.URL.Path == "/page" {
body = `<a href="https://trailers.apple.com/film_h720p.mov">720</a>` +
`<a href="https://trailers.apple.com/film_h1080p.mov">1080</a>`
headers.Set("Content-Type", "text/html")
} else {
status = http.StatusPartialContent
headers.Set("Content-Type", "video/quicktime")
}
return &http.Response{
StatusCode: status,
Header: headers,
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}, nil
})}
resolver := New(client)
result, err := resolver.Resolve(context.Background(), Source{
Provider: "apple", URL: "https://trailers.apple.com/page",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.URL, "1080") {
t.Fatalf("did not choose the best stream: %+v", result)
}
}
func TestBalancedJSONObjectIgnoresBracesInsideStrings(t *testing.T) {
body := `before marker = {"value":"}" ,"nested":{"ok":true}} after`
if got := balancedJSONObject(body, "marker = "); got != `{"value":"}" ,"nested":{"ok":true}}` {
t.Fatalf("balanced object = %q", got)
}
}