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
+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)
}
}