Skip to content

"Play in external player" (M3U playlist mode) silently downloads playlist, never launches a player #51

@jmann345

Description

@jmann345

Heads up before you read this: I vibe-coded a local monkey-patch to fix this for myself. I'm aware GitHub is getting flooded with AI slop and I don't want to waste your time with a useless issue. If the bug description or monkey patch I vibe-coded is useful, great. If neither are useful, please just close this so it doesn't waste any more time.

The following text is AI generated, but I fact checked it and it contains all information that was useful to me in understanding and fixing this bug.

What I saw

Stremio Settings → "Play in external player" only offers two options for me: Disabled and M3U playlist. With M3U playlist selected, clicking the external-player button on a stream:

  • shows the toast "Stream opened in external player" with a green checkmark
  • but no media player ever launches
  • a file playlist (N).m3u keeps appearing in ~/Downloads

So the green check is misleading — the UI thinks it succeeded, but the user gets nothing playing.

Environment

  • Distro: Arch Linux (Hyprland / Wayland)
  • Package: stremio-linux-shell 1.0.0-beta.13-1 (AUR, CEF-based build)
  • Upstream URL in pacman metadata: https://github.com/Stremio/stremio-linux-shell
  • mpv installed and registered for video MIME types and application/x-mpegurl / application/vnd.apple.mpegurl

I realize main has since been refactored from CEF to GTK4 + WebKit (#31), so this exact code path no longer exists upstream. I'm filing it anyway because:

  1. The released package most users on AUR/Arch are running is still the CEF tree.
  2. The underlying UX bug — "M3U mode emits a download, not a player launch" — likely has a sibling on the WebKit branch worth verifying. The web app sends a data: M3U via download regardless of which native shell is wrapping it.

Where the bug actually lives (CEF tree)

Three handoff paths exist, none of them launch a player:

  1. WebViewEvent::Open(url) (popup) → app.open_url(url) → xdg-desktop-portal OpenFileRequest::send_uri. For an http://127.0.0.1:1147x/... stream URL, the portal hands it to the default x-scheme-handler/http, i.e. the browser, not mpv.
  2. IpcEvent::OpenExternal(url) → same app.open_url(url) portal path.
  3. M3U playlist mode never hits either of those. The web app issues a download of a base64 data:application/x-mpegurl;... blob. With no DownloadHandler installed on the CEF client, CEF's default behavior just saves it to ~/Downloads. The "opened in external player" toast fires from web-app code before any handoff actually happens.

I confirmed this empirically: instrumented both native handlers, clicked external-player, no log lines fired, but ~/Downloads/playlist (N).m3u appeared.

What my local monkey-patch does (CEF tree)

For my own use I patched the shell to:

  • Add a CEF DownloadHandler to the client. looks_like_playlist checks suggested name / item name / URL / MIME type for .m3u, .m3u8, mpegurl, x-mpegurl, vnd.apple.mpegurl.
  • On match: redirect the file path to $XDG_RUNTIME_DIR/stremio-external-player/playlist-<id>.m3u and let CEF complete the download there.
  • On on_download_updated with is_complete(), spawn /usr/bin/mpv -- <path> and reap the child in a detached thread so it doesn't zombie.
  • Also rewire WebViewEvent::Open and IpcEvent::OpenExternal to spawn mpv directly instead of going through the portal.

It works end-to-end on my system: real mpv window, HDR10 PQ output, E-AC3 passthrough, no Downloads spam.

This is not PR-readympv path is hardcoded, no config, no opt-out, no Windows/Mac story, and obviously the whole CEF module path is gone on main. Treating it strictly as a reference for the bug class.

What might actually be worth doing upstream (WebKit tree)

I haven't built or tested the WebKit branch, so this is speculation, but if the symptom reproduces there:

  • The web app currently emits a data: M3U download for the "M3U playlist" external-player mode.
  • WebKit's equivalent intercept point would be webkit_web_view's download-started signal, similar idea: detect M3U MIME / extension, redirect destination, spawn the configured external player on completion.
  • The popup / IPC open_uri path (webview.connect_open_externalwindow.open_uri) probably also wants a configurable "external player" command instead of the default GTK URI launcher when the URI is a local stream.

Repro

  1. Install stremio-linux-shell (AUR) or any current CEF build.
  2. Settings → Play in external player → M3U playlist.
  3. Pick any stream, click the external-player button.
  4. Observe: green checkmark + toast, no player launches, new playlist (N).m3u in ~/Downloads.
Local monkey-patch diff (CEF tree, ~250 lines, reference only)
diff -ruN a/src/external_player.rs b/src/external_player.rs
--- a/src/external_player.rs
+++ b/src/external_player.rs
@@ -0,0 +1,16 @@
+use std::process::Command;
+
+pub fn launch_mpv(source: &str, target: &str) {
+    tracing::info!("Launching external mpv from {source}: {target}");
+
+    match Command::new("/usr/bin/mpv").arg("--").arg(target).spawn() {
+        Ok(mut child) => {
+            std::thread::spawn(move || {
+                let _ = child.wait();
+            });
+        }
+        Err(error) => {
+            tracing::error!("Failed to launch external mpv player: {error}");
+        }
+    }
+}
diff -ruN a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
 mod app;
 mod config;
 mod constants;
+mod external_player;
 mod instance;
 mod ipc;
 mod player;
@@ -13,6 +14,7 @@
 use clap::Parser;
 use config::Config;
 use constants::{STARTUP_URL, URI_SCHEME};
+use external_player::launch_mpv;
 use glutin::{display::GetGlDisplay, surface::GlSurface};
 use instance::{Instance, InstanceEvent};
 use ipc::{IpcEvent, IpcEventMpv};
@@ -230,7 +232,7 @@
                 app.set_cursor(cursor);
             }
             WebViewEvent::Open(url) => {
-                futures::executor::block_on(app.open_url(url));
+                launch_mpv("WebViewEvent::Open", url.as_str());
             }
             WebViewEvent::Ipc(data) => ipc::parse_request(data, |event| match event {
                 IpcEvent::Init(id) => {
@@ -241,7 +243,7 @@
                     app.set_fullscreen(state);
                 }
                 IpcEvent::OpenExternal(url) => {
-                    futures::executor::block_on(app.open_url(url));
+                    launch_mpv("IpcEvent::OpenExternal", &url);
                 }
                 IpcEvent::Quit => {
                     event_loop_proxy.send_event(UserEvent::Quit).ok();
diff -ruN a/src/webview/app/client/download_handler.rs b/src/webview/app/client/download_handler.rs
--- a/src/webview/app/client/download_handler.rs
+++ b/src/webview/app/client/download_handler.rs
@@ -0,0 +1,161 @@
+use std::{
+    collections::HashSet,
+    fs::create_dir_all,
+    path::{Path, PathBuf},
+    sync::{Mutex, OnceLock},
+};
+
+use crate::{cef_impl, external_player::launch_mpv};
+
+static LAUNCHED_DOWNLOADS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
+
+fn launched_downloads() -> &'static Mutex<HashSet<u32>> {
+    LAUNCHED_DOWNLOADS.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn playlist_dir() -> PathBuf {
+    std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir)
+        .join("stremio-external-player")
+}
+
+fn cef_userfree_to_string(value: &CefStringUserfree) -> String {
+    CefString::from(value).to_string()
+}
+
+fn looks_like_playlist(
+    download_item: Option<&mut DownloadItem>,
+    suggested_name: Option<&CefString>,
+) -> bool {
+    let suggested_name = suggested_name
+        .map(ToString::to_string)
+        .unwrap_or_default()
+        .to_lowercase();
+
+    let Some(download_item) = download_item else {
+        return suggested_name.ends_with(".m3u") || suggested_name.ends_with(".m3u8");
+    };
+
+    let mime_type = cef_userfree_to_string(&download_item.mime_type()).to_lowercase();
+    let item_name = cef_userfree_to_string(&download_item.suggested_file_name()).to_lowercase();
+    let url = cef_userfree_to_string(&download_item.url()).to_lowercase();
+
+    suggested_name.ends_with(".m3u")
+        || suggested_name.ends_with(".m3u8")
+        || item_name.ends_with(".m3u")
+        || item_name.ends_with(".m3u8")
+        || url.ends_with(".m3u")
+        || url.ends_with(".m3u8")
+        || mime_type.contains("mpegurl")
+        || mime_type.contains("x-mpegurl")
+        || mime_type.contains("vnd.apple.mpegurl")
+}
+
+fn playlist_path(download_item: &mut DownloadItem, suggested_name: Option<&CefString>) -> PathBuf {
+    let mut name = suggested_name
+        .map(ToString::to_string)
+        .filter(|name| !name.trim().is_empty())
+        .unwrap_or_else(|| cef_userfree_to_string(&download_item.suggested_file_name()));
+
+    if name.trim().is_empty() {
+        name = "playlist.m3u".to_string();
+    }
+
+    let safe_name = name
+        .chars()
+        .map(|ch| match ch {
+            '/' | '\\' | ':' | '\0' => '_',
+            _ => ch,
+        })
+        .collect::<String>();
+
+    let dir = playlist_dir();
+    if let Err(error) = create_dir_all(&dir) {
+        tracing::error!("Failed to create playlist dir {}: {error}", dir.display());
+    }
+
+    let id = download_item.id();
+    let path = Path::new(&safe_name);
+    let stem = path
+        .file_stem()
+        .and_then(|value| value.to_str())
+        .unwrap_or("playlist");
+    let extension = path
+        .extension()
+        .and_then(|value| value.to_str())
+        .unwrap_or("m3u");
+
+    dir.join(format!("{stem}-{id}.{extension}"))
+}
+
+cef_impl!(
+    prefix = "WebView",
+    name = DownloadHandler,
+    sys_type = cef_dll_sys::cef_download_handler_t,
+    {
+        fn can_download(
+            &self,
+            _browser: Option<&mut Browser>,
+            _url: Option<&CefString>,
+            _request_method: Option<&CefString>,
+        ) -> ::std::os::raw::c_int {
+            true.into()
+        }
+
+        fn on_before_download(
+            &self,
+            _browser: Option<&mut Browser>,
+            mut download_item: Option<&mut DownloadItem>,
+            suggested_name: Option<&CefString>,
+            callback: Option<&mut BeforeDownloadCallback>,
+        ) -> ::std::os::raw::c_int {
+            if looks_like_playlist(download_item.as_mut().map(|item| &mut **item), suggested_name)
+                && let (Some(download_item), Some(callback)) = (download_item, callback)
+            {
+                let path = playlist_path(download_item, suggested_name);
+                let path_string = path.to_string_lossy().to_string();
+                let cef_path = CefString::from(path_string.as_str());
+
+                callback.cont(Some(&cef_path), false.into());
+                return true.into();
+            }
+
+            false.into()
+        }
+
+        fn on_download_updated(
+            &self,
+            _browser: Option<&mut Browser>,
+            download_item: Option<&mut DownloadItem>,
+            _callback: Option<&mut DownloadItemCallback>,
+        ) {
+            let Some(download_item) = download_item else {
+                return;
+            };
+
+            if download_item.is_complete() == 0
+                || !looks_like_playlist(Some(&mut *download_item), None)
+            {
+                return;
+            }
+
+            let id = download_item.id();
+            let already_launched = launched_downloads()
+                .lock()
+                .map(|mut launched| !launched.insert(id))
+                .unwrap_or(true);
+
+            if already_launched {
+                return;
+            }
+
+            let path = cef_userfree_to_string(&download_item.full_path());
+            if path.trim().is_empty() {
+                return;
+            }
+
+            launch_mpv("DownloadHandler::on_download_updated", &path);
+        }
+    }
+);
diff -ruN a/src/webview/app/client/mod.rs b/src/webview/app/client/mod.rs
--- a/src/webview/app/client/mod.rs
+++ b/src/webview/app/client/mod.rs
@@ -1,3 +1,4 @@
+mod download_handler;
 mod display_handler;
 mod keyboard_handler;
 mod lifespan_handler;
@@ -6,6 +7,7 @@
 
 use std::os::raw::c_int;
 
+use download_handler::WebViewDownloadHandler;
 use display_handler::WebViewDisplayHandler;
 use lifespan_handler::WebViewLifeSpanHandler;
 use load_handler::WebViewLoadHandler;
@@ -29,6 +31,10 @@
             Some(WebViewDisplayHandler::new())
         }
 
+        fn download_handler(&self) -> Option<DownloadHandler> {
+            Some(WebViewDownloadHandler::new())
+        }
+
         fn render_handler(&self) -> Option<RenderHandler> {
             Some(WebViewRenderHandler::new())
         }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions