From b1b9f52510fbea1a312ff535542fd64b30ea01ef Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:27:35 +0100 Subject: [PATCH 1/3] feat: prioritised video resolver registry and https URL normalisation Replace the single resolver delegate on BasisMediaUrlRouter with a priority registry of IBasisVideoResolver, so multiple resolvers can coexist and are tried in order; the legacy delegate is kept as a back-compatible adapter. The yt-dlp integration registers a resolver instead of setting the delegate. Default a missing URL scheme to https at the LoadUrl chokepoint, so a bare page URL ("www.youtube.com/watch?v=...") routes through the resolver instead of being read as a direct source and rejected as non-absolute. The media player panel writes the normalised URL back into the field so the added scheme is visible rather than silent. --- .../Runtime/BasisYtDlpRouterInstaller.cs | 50 +++---- .../Runtime/BasisMediaPlayer.cs | 3 + .../Runtime/BasisMediaUrlRouter.cs | 133 +++++++++++++++--- .../UI/BasisMediaPlayerPanelProvider.cs | 8 +- 4 files changed, 148 insertions(+), 46 deletions(-) diff --git a/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpRouterInstaller.cs b/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpRouterInstaller.cs index 56b24212a..f9a10c030 100644 --- a/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpRouterInstaller.cs +++ b/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpRouterInstaller.cs @@ -4,37 +4,39 @@ namespace Basis.Integration.YtDlp { /// - /// Installs the yt-dlp resolver into at startup, - /// so any player URL field (e.g. BasisMediaPlayerStreaming) steers page URLs - /// through yt-dlp while directly-playable streams load unchanged. The player core - /// holds no reference to this package; removing the package removes the - /// registration (the router falls back to direct loads), with nothing dangling. + /// Registers the yt-dlp resolver with at startup, so any + /// player URL field (e.g. BasisMediaPlayerStreaming) steers page URLs through yt-dlp + /// while directly-playable streams load unchanged. The player core holds no reference to this + /// package; removing the package removes the registration (the router falls back to direct + /// loads, or to another registered resolver), with nothing dangling. /// internal static class BasisYtDlpRouterInstaller { + private static BasisYtDlpVideoResolver installed; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Install() { - // The router holds a single resolver slot. Don't clobber another package's resolver - // if one is already installed — first-come wins, and the collision is surfaced rather - // than silently overwritten. - if (BasisMediaUrlRouter.Resolver != null) - { - BasisDebug.LogWarning( - "A BasisMediaUrlRouter resolver is already installed; the yt-dlp resolver will not replace it.", - BasisDebug.LogTag.Video); - return; - } + if (installed != null) return; // idempotent across domain reloads + installed = new BasisYtDlpVideoResolver(); + BasisMediaUrlRouter.Register(installed); + } + } - BasisMediaUrlRouter.Resolver = (player, url) => - { - // Already directly playable (transport scheme or media-extension URL): - // decline so the caller loads it directly. Otherwise it's a page URL — - // resolve it to its stream(s) and load (async). - if (!BasisYtDlpResolver.NeedsResolution(url)) return false; - BasisYtDlpResolver.ResolveAndPlay(player, url); - return true; - }; + /// + /// Routes page URLs (YouTube, Twitch, …) through the in-process yt-dlp resolver. Directly + /// playable URLs are declined via so the player opens them itself. + /// + internal sealed class BasisYtDlpVideoResolver : IBasisVideoResolver + { + public int Priority => 0; + + public bool CanResolve(string url) => BasisYtDlpResolver.NeedsResolution(url); + + public bool TryResolve(BasisMediaPlayer player, string url) + { + BasisYtDlpResolver.ResolveAndPlay(player, url); + return true; } } } diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs index 327d57529..1668b860a 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs @@ -423,6 +423,9 @@ public void LoadUrl(string url) BasisDebug.LogWarning("BasisMediaPlayer.LoadUrl called with empty URL.", BasisDebug.LogTag.Video); return; } + // Default a missing scheme to https so a bare "www.example.com/…" routes and loads + // as an absolute URL instead of being mis-read as a direct/transport source. + url = BasisMediaUrlRouter.NormalizeUrl(url); LastErrorMessage = null; LoadGeneration++; if (BasisMediaUrlRouter.TryResolveAndLoad(this, url)) return; diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs index 52203aa72..035c2c382 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs @@ -1,40 +1,104 @@ using System; +using System.Collections.Generic; /// -/// Optional URL-resolution seam between the player and a higher-level resolver -/// (e.g. the yt-dlp integration package). The player core has no reference to any -/// integration, so this is how a URL field can steer page URLs through a resolver -/// without the player depending on it. +/// A URL resolver registered with . Several resolvers +/// can be registered at once; the router tries them in descending +/// order (registration order breaks ties) until one takes ownership of the load. A +/// resolver that declines lets the router fall through to the next one, and finally to a +/// direct load. Resolvers route only — they never gate host trust (that's +/// BasisMediaPlayerSecurity). +/// +public interface IBasisVideoResolver +{ + /// Higher runs first; equal priorities run in the order they registered. + int Priority { get; } + + /// + /// Cheap, side-effect-free test of whether this resolver handles . + /// Returning false skips straight to the next resolver. Must not block or load. + /// + bool CanResolve(string url); + + /// + /// Takes ownership of loading into + /// — resolving a page URL to its stream(s) and loading them, possibly async — and + /// returns true; or returns false to let the router try the next resolver, then a + /// direct load. + /// + bool TryResolve(BasisMediaPlayer player, string url); +} + +/// +/// Optional URL-resolution seam between the player and higher-level resolvers (e.g. the +/// yt-dlp integration package). The player core has no reference to any integration, so +/// this is how a URL field can steer page URLs through a resolver without the player +/// depending on it. /// -/// An integration installs at load (e.g. via -/// RuntimeInitializeOnLoadMethod). Callers with a raw URL hand it to -/// : the resolver either takes ownership of the load -/// (resolving a page URL to its stream(s) and loading them, possibly async) and -/// returns true, or returns false to let the caller load the URL directly. When no -/// integration is installed is null and every URL loads -/// directly — identical to having no integration at all. This routes only; it never -/// blocks a URL (host trust is enforced separately by BasisMediaPlayerSecurity). +/// Integrations an at load (e.g. +/// via RuntimeInitializeOnLoadMethod). Callers with a raw URL hand it to +/// , which walks the registered resolvers in priority order +/// until one takes ownership (returns true); if none do, the caller loads the URL directly. +/// With nothing registered every URL loads directly — identical to having no integration at +/// all. This routes only; it never blocks a URL (host trust is enforced separately by +/// BasisMediaPlayerSecurity). /// public static class BasisMediaUrlRouter { + private static readonly List Resolvers = new List(); + private static LegacyResolverAdapter legacy; + + /// + /// Back-compatible single-resolver slot. Assigning a non-null delegate registers it as a + /// priority-0 resolver; assigning null removes it. Prefer with an + /// for new integrations — this is kept so existing + /// callers that set a delegate keep working. + /// + public static Func Resolver + { + get => legacy?.Func; + set + { + if (legacy != null) { Unregister(legacy); legacy = null; } + if (value != null) { legacy = new LegacyResolverAdapter(value); Register(legacy); } + } + } + /// - /// Installed by an optional integration. Returns true if it took ownership of the - /// load for the given URL, false to let the caller load it directly. Null when no - /// integration is present. + /// Registers so consults it, + /// keeping the list ordered by descending + /// (registration order breaks ties). Registering the same instance twice is a no-op. /// - public static Func Resolver; + public static void Register(IBasisVideoResolver resolver) + { + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); + if (Resolvers.Contains(resolver)) return; + int i = 0; + while (i < Resolvers.Count && Resolvers[i].Priority >= resolver.Priority) i++; + Resolvers.Insert(i, resolver); + } + + /// Removes a resolver registered via . Returns false if it wasn't registered. + public static bool Unregister(IBasisVideoResolver resolver) => Resolvers.Remove(resolver); // Delimiters that end the path portion of a URL (query / fragment), hoisted so // IsDirectlyPlayable doesn't allocate a char[] per call. private static readonly char[] PathEnd = { '?', '#' }; /// - /// Routes through the installed resolver, if any. Returns - /// true when the resolver took ownership (the caller should not also load); false - /// when there is no resolver or it declined (the caller loads directly). + /// Routes through the registered resolvers in priority order. + /// Returns true as soon as one takes ownership (the caller should not also load); false + /// when none handle it (the caller loads directly). /// public static bool TryResolveAndLoad(BasisMediaPlayer player, string url) - => Resolver != null && Resolver(player, url); + { + for (int i = 0; i < Resolvers.Count; i++) + { + IBasisVideoResolver resolver = Resolvers[i]; + if (resolver.CanResolve(url) && resolver.TryResolve(player, url)) return true; + } + return false; + } /// /// True if the player can open directly, without a resolver: @@ -67,4 +131,33 @@ public static bool IsDirectlyPlayable(string url) || path.EndsWith(".mts", StringComparison.OrdinalIgnoreCase) // AVCHD-flavour MPEG-TS || path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase); } + + /// + /// Guarantees carries a scheme so it can route and load as an + /// absolute URL. A bare web URL with no scheme (e.g. "www.youtube.com/watch?v=…") gets + /// "https://" prepended; anything that already has a scheme (http(s)/rtsp/rtmp/rist/file/…) + /// or is a local path (leading slash, UNC, or a drive letter like C:\) is returned trimmed + /// but otherwise unchanged. Null/whitespace passes through untouched. + /// + public static string NormalizeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return url; + string trimmed = url.Trim(); + if (trimmed.Contains("://")) return trimmed; // already has a scheme + if (trimmed[0] == '/' || trimmed[0] == '\\') return trimmed; // unix / UNC / rooted path + if (trimmed.Length >= 2 && trimmed[1] == ':') return trimmed; // windows drive path (C:\, C:/) + return "https://" + trimmed; + } + + // Adapts a legacy Resolver delegate to IBasisVideoResolver. The delegate self-selects + // (returns false to decline), so CanResolve is always true and the decision happens in + // TryResolve — preserving the original single-slot semantics. + private sealed class LegacyResolverAdapter : IBasisVideoResolver + { + internal readonly Func Func; + public LegacyResolverAdapter(Func func) => Func = func; + public int Priority => 0; + public bool CanResolve(string url) => true; + public bool TryResolve(BasisMediaPlayer player, string url) => Func(player, url); + } } diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs index b9476dc33..dcf912164 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs @@ -179,8 +179,12 @@ private void BuildControlGroup(RectTransform parent) if (_activePlayer == null || _urlField == null) return; string u = _urlField.Value; if (string.IsNullOrEmpty(u)) return; - if (_activeNetworking != null) _ = _activeNetworking.SetUrl(u); - else _activePlayer.LoadUrl(u); + // Reflect the scheme we add back into the field so a bare "youtube.com/…" + // visibly becomes "https://youtube.com/…" rather than being silently rewritten. + string normalized = BasisMediaUrlRouter.NormalizeUrl(u); + if (normalized != u) _urlField.SetValueWithoutNotify(normalized); + if (_activeNetworking != null) _ = _activeNetworking.SetUrl(normalized); + else _activePlayer.LoadUrl(normalized); }; PanelButton playBtn = PanelButton.CreateNew(actions); From e1f12d7bb583fd72afc2bfabbc9c4a920c88e916 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:28:37 +0100 Subject: [PATCH 2/3] fix(mediaplayer): reject whitespace-only URLs; document router main-thread contract LoadUrl guarded with IsNullOrEmpty but then called NormalizeUrl, which returns whitespace-only input unchanged, so a blank-but-non-empty string slipped through as a bogus URL. Switch LoadUrl, LoadLocalPath and the Media Players panel's load button to IsNullOrWhiteSpace so blank input is rejected at the entry point. Also document that BasisMediaUrlRouter's resolver list is unsynchronised and that Register/Unregister/TryResolveAndLoad must run on the main thread. --- .../com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs | 4 ++-- .../com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs | 5 +++++ .../Runtime/UI/BasisMediaPlayerPanelProvider.cs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs index 1668b860a..5f3cf0b68 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayer.cs @@ -418,7 +418,7 @@ private void Awake() // already-resolved or direct sources, e.g. the resolver's own output). public void LoadUrl(string url) { - if (string.IsNullOrEmpty(url)) + if (string.IsNullOrWhiteSpace(url)) { BasisDebug.LogWarning("BasisMediaPlayer.LoadUrl called with empty URL.", BasisDebug.LogTag.Video); return; @@ -448,7 +448,7 @@ public void LoadUrl(string url) // streaming-assets-relative path and calls LoadSource. public void LoadLocalPath(string path) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrWhiteSpace(path)) { BasisDebug.LogWarning("BasisMediaPlayer.LoadLocalPath called with empty path.", BasisDebug.LogTag.Video); return; diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs index 035c2c382..9f06a91f4 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs @@ -42,6 +42,11 @@ public interface IBasisVideoResolver /// With nothing registered every URL loads directly — identical to having no integration at /// all. This routes only; it never blocks a URL (host trust is enforced separately by /// BasisMediaPlayerSecurity). +/// +/// Not thread-safe. Register, Unregister and TryResolveAndLoad must all run on Unity's main +/// thread — the resolver list is unsynchronised, so registering while a resolve is iterating +/// would corrupt it. Integrations register from RuntimeInitializeOnLoadMethod and the +/// player resolves from its load path, both main-thread, so this holds in practice. /// public static class BasisMediaUrlRouter { diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs index dcf912164..f9f1c9cff 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/UI/BasisMediaPlayerPanelProvider.cs @@ -178,7 +178,7 @@ private void BuildControlGroup(RectTransform parent) { if (_activePlayer == null || _urlField == null) return; string u = _urlField.Value; - if (string.IsNullOrEmpty(u)) return; + if (string.IsNullOrWhiteSpace(u)) return; // Reflect the scheme we add back into the field so a bare "youtube.com/…" // visibly becomes "https://youtube.com/…" rather than being silently rewritten. string normalized = BasisMediaUrlRouter.NormalizeUrl(u); From 830f0569cb49db941adc67f2aec7beb91880a6e7 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:05:44 +0100 Subject: [PATCH 3/3] feat(mediaplayer): set live-vs-VOD delivery hint from resolved stream metadata --- .../Runtime/BasisYtDlpResolver.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpResolver.cs b/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpResolver.cs index 61874e7aa..d64195f5c 100644 --- a/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpResolver.cs +++ b/Basis/Packages/com.basis.integration.ytdlp/Runtime/BasisYtDlpResolver.cs @@ -141,18 +141,36 @@ private static BasisMediaSource SelectSource(VideoInfo info) return new BasisMediaSource { Uri = video.Url, AudioUri = audio.Url, Delivery = BasisMediaDelivery.OnDemand }; Format muxed = BestMuxed(info.Formats); - if (muxed != null) return new BasisMediaSource { Uri = muxed.Url }; + if (muxed != null) return new BasisMediaSource { Uri = muxed.Url, Delivery = DeliveryFor(info) }; // Last resort: yt-dlp's top-level URL — but only if the player can open it // directly. An unvalidated DirectUrl can be an unsupported manifest/codec that // would bypass the avc1/mp4a filtering above, so reject it and let // ResolveSourceAsync fail loudly ("no player-ingestible format") instead. if (!string.IsNullOrEmpty(info.DirectUrl) && BasisMediaUrlRouter.IsDirectlyPlayable(info.DirectUrl)) - return new BasisMediaSource { Uri = info.DirectUrl }; + return new BasisMediaSource { Uri = info.DirectUrl, Delivery = DeliveryFor(info) }; return null; } + // Maps yt-dlp's live-status metadata onto the engine's delivery hint, so the + // live-vs-VOD clock is chosen at open instead of sniffed from the byte stream. + // Absent/unknown status falls back to Auto (the engine detects a seekable, + // finite body as VOD and an open-ended stream as live). The split avc1+mp4a + // path doesn't consult this — that pairing is only ever adaptive VOD. + private static BasisMediaDelivery DeliveryFor(VideoInfo info) + { + if (info.IsLive == true) return BasisMediaDelivery.Live; + switch (info.LiveStatus) + { + case "is_live": + case "is_upcoming": return BasisMediaDelivery.Live; + case "was_live": + case "not_live": return BasisMediaDelivery.OnDemand; + default: return BasisMediaDelivery.Auto; + } + } + // H.264 video-only, no higher than 1080p (avc1 is the player's ceiling — no // VP9/AV1 decode), best height then bitrate, direct byte URL (not a manifest). private static Format BestVideoOnly(List formats)