From 338a53a0361c2f67da5904d26188cfdfd09a9fb5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:57:46 +0200 Subject: [PATCH] fix(player): comma-safe http header delivery to mpv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mpv's http-header-fields string-list parser splits on commas inside header values, so X-Plex-Device on Apple hardware (model ids like Mac17,9) produced a colon-less garbage header line that Plex rejects with 400 'Error parsing HTTP request' — first hit by music direct play on macOS, latent in the mpv video path too. Deliver headers via change-list append items (open) and per-entry -clr/-append loadfile options (gapless arming), each item verbatim and %len%-quoted against the outer option-list split. --- lib/mpv/player/player_native.dart | 32 +++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 11f9a0d8..092ee93e 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -110,14 +110,22 @@ class PlayerNative extends PlayerBase { return 'sub-files=${_fixedLengthQuote(escapedUris.join(separator))}'; } - /// Per-entry `http-header-fields` for a `loadfile ... append` options arg. - /// The fixed-length quote shields the whole value from the key=value list - /// parser; mpv then splits the headers on commas, the same separator the - /// `setProperty('http-header-fields', ...)` path in [open] relies on. + /// Per-entry `http-header-fields` options for a `loadfile ... append` + /// options arg. Every header rides its own `-append` entry because mpv's + /// string-LIST parser splits a plain `http-header-fields=a,b` value on + /// commas with no way to escape them — a header value containing a comma + /// (`X-Plex-Device: Mac17,9` on Apple hardware) would be split into a + /// colon-less garbage line that Plex rejects with 400 "Error parsing HTTP + /// request". `-append` takes a single verbatim item; the fixed-length + /// quote shields it from the outer key=value list split. The leading + /// `-clr` stops the file-local list from inheriting (and duplicating) the + /// current track's global headers set by [open]. static String? _httpHeaderFieldsLoadfileOption(Map? headers) { if (headers == null || headers.isEmpty) return null; - final headerList = headers.entries.map((e) => '${e.key}: ${e.value}').join(','); - return 'http-header-fields=${_fixedLengthQuote(headerList)}'; + final appends = headers.entries + .map((e) => 'http-header-fields-append=${_fixedLengthQuote('${e.key}: ${e.value}')}') + .join(','); + return 'http-header-fields-clr=,$appends'; } MediaDisplayCriteria? _effectiveDisplayCriteria(MediaDisplayCriteria? criteria) { @@ -238,9 +246,17 @@ class PlayerNative extends PlayerBase { if (!audioOnly) await setVisible(true); + // Rebuild the header list via `change-list` items — a plain + // `setProperty('http-header-fields', joined)` splits on commas inside + // header VALUES (`X-Plex-Device: Mac17,9` on Apple hardware), producing a + // malformed request Plex rejects with 400. `append` takes each item + // verbatim. Always clear first so a previous open's headers never leak + // into header-less media. + await command(['change-list', 'http-header-fields', 'clr', '']); if (media.headers != null && media.headers!.isNotEmpty) { - final headerList = media.headers!.entries.map((e) => '${e.key}: ${e.value}').toList(); - await setProperty('http-header-fields', headerList.join(',')); + for (final entry in media.headers!.entries) { + await command(['change-list', 'http-header-fields', 'append', '${entry.key}: ${entry.value}']); + } } // 'start' must be set before loadfile.