From 541bfc40020d3cc8121f86ffa9492edbaf826787 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:35:48 +0700 Subject: [PATCH] perf(media): shrink FFmpeg while preserving HTTPS motion artwork --- .github/workflows/release.yml | 2 +- README.md | 2 + android/app/build.gradle.kts | 2 +- android/app/src/androidTest/assets/README.md | 17 + .../androidTest/assets/motion-probe-h264.mp4 | Bin 0 -> 2114 bytes .../androidTest/assets/motion-probe-hevc.mp4 | Bin 0 -> 3784 bytes .../zarz/spotiflac/FFmpegCapabilitiesTest.kt | 75 ++++ lib/services/automix_analyzer.dart | 4 +- lib/services/ffmpeg_service.dart | 6 +- lib/services/motion_artwork_proxy.dart | 293 +++++++++++++ lib/services/motion_artwork_store.dart | 41 +- lib/widgets/audio_analysis_widget.dart | 8 +- pubspec.lock | 6 +- pubspec.yaml | 2 +- scripts/README_ios_ffmpeg_capabilities.md | 58 +++ scripts/ios_ffmpeg_capabilities.m | 118 ++++++ test/motion_artwork_proxy_test.dart | 385 ++++++++++++++++++ 17 files changed, 999 insertions(+), 20 deletions(-) create mode 100644 android/app/src/androidTest/assets/motion-probe-h264.mp4 create mode 100644 android/app/src/androidTest/assets/motion-probe-hevc.mp4 create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/FFmpegCapabilitiesTest.kt create mode 100644 lib/services/motion_artwork_proxy.dart create mode 100644 scripts/README_ios_ffmpeg_capabilities.md create mode 100644 scripts/ios_ffmpeg_capabilities.m create mode 100644 test/motion_artwork_proxy_test.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd7c88d2..e7d957a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -271,7 +271,7 @@ jobs: - name: Normalize ffmpeg plugin shell scripts (strip CRLF) run: | - find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 | + find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_audio*/scripts/*.sh" -type f -print0 | while IFS= read -r -d '' f; do perl -pi -e 's/\r$//' "$f" chmod +x "$f" diff --git a/README.md b/README.md index 28de033c..d39fb6d6 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Gradle builds the Rust native artifacts automatically. Tool versions are pinned Start with the [Contributing Guide](CONTRIBUTING.md) for the development setup, project boundaries, validation commands, and pull request checklist. +For native iOS codec checks, see the [FFmpeg capability probe](scripts/README_ios_ffmpeg_capabilities.md). + --- ## Related Projects diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 267263b3..f40c4f8a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -183,7 +183,7 @@ dependencies { implementation("androidx.activity:activity-ktx:1.13.0") // NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter // plugin owns the runtime AAR; compileOnly avoids packaging it twice here. - compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1") + compileOnly("com.antonkarpenko:ffmpeg-kit-audio:2.2.2") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") diff --git a/android/app/src/androidTest/assets/README.md b/android/app/src/androidTest/assets/README.md index 923ac327..2c0ab47e 100644 --- a/android/app/src/androidTest/assets/README.md +++ b/android/app/src/androidTest/assets/README.md @@ -8,3 +8,20 @@ The DSF input stores those bytes with reversed bits (LSB-first). This tests lossless WavPack DSD decoding, channel order, seek alignment, native LE/BE packing and DoP packing against known bytes. It is packaged only in the instrumentation test APK. + +## Synthetic motion artwork fixtures + +`motion-probe-h264.mp4` and `motion-probe-hevc.mp4` contain one second of the +FFmpeg `testsrc2` pattern at 32x32 pixels and two frames per second. They are +original generated test data and are packaged only in the instrumentation APK. +The capability test validates decoding and MP4 stream-copy after reducing the +shipped FFmpeg dependency. Generate with `-f lavfi -i testsrc2=size=32x32:rate=2 +-t 1 -an -pix_fmt yuv420p -movflags +faststart`, using `-c:v libx264` or +`-c:v libx265 -x265-params log-level=error:pools=1 -tag:v hvc1` respectively. + +For a live encrypted HLS gate through `MotionArtworkProxy`, forward its local +port with `adb reverse tcp:PORT tcp:PORT` and pass +`-e motionProxyUrl http://127.0.0.1:PORT/TOKEN/0.m3u8` to the instrumentation +runner for `com.zarz.spotiflac.FFmpegCapabilitiesTest`. The optional gate decodes +the input, remuxes it, then decodes the resulting MP4 using the shipped native +library. Normal fixture tests run without a server or this argument. diff --git a/android/app/src/androidTest/assets/motion-probe-h264.mp4 b/android/app/src/androidTest/assets/motion-probe-h264.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..830d4573c5e43bbff3c0fe43984365d940f08a82 GIT binary patch literal 2114 zcmZux3rrM87~TUUVAL2Tii+(}sal0&_YSDVYy~ZPii%p%YS3eDcaGb#d%Nr|9H16_ zqOsa0YK_*Iw#HhFXic>g6oiHntgTkD@m*_B(fXiKtJFv6KX;(i_~&N+Z~oW(kGW+S z2D!~5O)^x4VS*USVcBqjz$sb+$1uU1qN)W9!^ld3M+DxDf^UHS24i99?*;wu0OFU< zDK9Kvg@PcDEK|q1pv*FJ{dWd;Gwh5;?|zA1y62VmBJiP$VPc4A8o;B7Y?ywDKhw^* z{#cgNiYQ^wAw_(-_7o7OFqsX0&p3}L>wW}Vfh3YHO!*u*s}AfHWkPK%C5q&A1BfV4 z{6V{?9U}&GQKC+$4JE4Ym9NVvfGy(;(G-O_oDaVDL~r6Kn}? zSVKxJOsMBp%dt1=p0&4A=e`;?G~hH1x*}Yr-r3_3VQ{7tX_)^0Q%r4O?ugsG#7~=%HYqLvrOufEmLd^A zF+tUeWa2iFjb-g|HrAF1LXT-`$?@?%pU+C4pDJUoRn>Fi=`B`|smNfXYNn)mjbtQX z7Z!L9!4u={NF**<6>@nlnN4O9_F}onAUs=WPi6}_&W;qqdq^QN@?C)5Mw(FsuCP0G zk>{)|IKhS#sgQ_H8j%AJr;fcjgy)iw;L%kDJHeGhrcPv8G60P!93u)Q03lD|Sx5nk z#j2O^HZGRqkQ*DOQ!{cUjamc(&#I#&<2DWxaN`~h|Sv)*-q$$ zZvw~-fq03KZ}JH&%~Hp-qdGA>;H?YJ=hafZF7SfxMhg732(1bZyv=T9Q68;@XRS#9 zH5%8?aUq}d4hRgBXnX>aG?)>*2N)s5!g(-$I!vY%{wT-D>J^rf({trXXM8)RY)C)R zQqy$x+SZ!c;k98iLPLw9?fs{>N#mDIyyP32=D1b%Z}s{k*$anX&P*>GRR8$AM;_HO z-TrHF)N3`n=7sN%4qCn^a&~U7lB3_1Iobw%-*$0aYJbzLXv%F$JAU)j0#9pfd%|AW~QF0ik>;`{<@i)EIsyB_4{K>%OSoo=g+?9GmGaIjHphFXiPmT58c*! z_Vdb)Tep{29W5X8aFKDC zEIzpTTIr*lx3u@yU1-(k?;h5BOS_q|>+!*`*vJ`=SK3e~( zDQ)l0HS=d(Zry30&D2%4j0|2_&!cbeO`n?4II1M#Zk%U&NqV21qs6AxIzQ#7BhiT| zK~2>$j=xTaJlv3Sb^p=2roUU8`!t0A=qKJ#?Kweh3k z2TIY)A2;9ax1)JzM!%Lh4FhUN+%6l@;jV8lPA=aTy6Q|&eeZQ2);MdoUTpslsk3z1 literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/motion-probe-hevc.mp4 b/android/app/src/androidTest/assets/motion-probe-hevc.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..4248861e69a4a3a06961458d72df712857a051da GIT binary patch literal 3784 zcmZ`*4UiPY6`s9|KL@C&7eRAH8geQEy|=sfcj{)Q=cZ@( zZYj%O5)w&86NAyDQlOzCCPpNcXjvhN5=~<8hbS?M5fL$hKas>jBoZ&*>)Aa{%5?2c zzxTS|o7eAq?@e2l)mDsap{tc|Sxpwr_~-nvD{EQf?(mhWT9)Pc)v|+m)M(yqS@@?c z`W+gdU?4t>pM7ZOr%_FoHTJPM68%_Q68D>THjmm^w?^+iY#jEUPkk?g4*e~wT{^MG z+$KF88;S9>Zr9ivdzSCG0&C274nO;Bh-X_@JZ?03+3_Mn;Zb!R*`R6e1Fz5|Z`qN0YUM#A1PiH!t&SrAVa|=Vcp6+BVc}^T2wyc)hV0CUPbq!><8Tq8W z*1`XN_@QMT_n9^DZQ|_IxYY14ZD^()fU4P=wCc{5yRkTRPRgi`T3Iva=tZ*lNN(d1 zYZz@ck8|(A<|F5&9@AUCdAgNqnQ%$!h~dUKVa7e%TYfd+f|q}N#oCXr-*oU2a+FOi zR9w$tx$K!|FwVNtozt^io>l1W&E{qvp3PWQzh=ns`T<1I*TL2xSa20ZV*;tZtDhKxnRApi)JkQ9i5gOT6}6e3<` zc3eT&jq`SeYj>FhO4t=iJEmFqIGGsYaeWbcK^lm70&2D{fYFA)bA3043Z1}F10#vx z)B+sR<`LqM&Ln;zS->;xmVz=m5WO?j{Vp;rN?axX4GE-;3INqOf*VLD*}=f7pj1DZ z#)lv-yC^5}+m}&EA|(=+aO!#vCp=}aX1+uU=}G}JA|WG`qRe-nc?fL33U^eJhTN$b zrtC{LD5>lWn~+H&1;VQ>lUxJ6(6u#lWWiH*e?Bu4a0x}GFq!16FCk3uQA-a|cdJkp zD&NJVh`bs%!HANOzy|?*4TK|+GBV--pr^F}Z6H?7STnAt~l_z3B`au$3ReQV+;b$1=oW zbSz@c2O~G8x=uK1Fksv;66itsY#KQn78*=vhKT`A5CIlYKv@PQv=MYkyuJcfvSlie zDDeua&Ljsmxi&gXAz}liB%hJ8I0C>ZQPRL8(K0E;tA#4?p?;#OC~jNO zDt#uEX&`aF8wEwVfwwD9MPcOna1FT_=pwQ+mxz4Sa8sL%v-0cBaOxM+BAno=D-iFh=qgsC?KbZf=Y z()mEh!_f3F;s7Q0s5N*dgpDPiJ`p?=S{t1R3XR@TZ$arycf%sm%aL9zV46e*8~St* zQ+A@PiN7Jp7)fy(fw;lBPDE{mVvDSnE_yEtMY|f(ik2f?VnnP3O5y*kc}h@%j@AQU zoqZM+%1Z_GMRB(c%O;FL&Dn-36SA!;M4!UGr zeZ+0tQIix2YgGSu0l_FL)La|xA%#tV8Vzxz(lpS;DJVP?R#~+XXd-phgtX{NTqgjo z`E*d(1UyMfQxzlV3Hs;*seESC_phFPoGMtxnweVz4pdNatb7o-P@vo*lXoaXItE^i zx(19JpWL!(W#2_@U;6fzf8O2H zcjwi^uWZO|dg9)WuLga)UTm6u@=4R*+)=?L2 zJgKr`&cW_kf4#4EseR;sZlCk}wL53u6n*8(@vc7WNu|wwx@UJE&7yOuetsDeGj}b=GyL|;On!R zCcX9Y*B)AN&b1!D;i;{=%1`d;JLmQbN6+dx4xk_Uv=})tJ~grxJ&$W+SBFZ z-(u{p4KrrF_qckkoh=aq+oyg0vdUfaj$Qu1p32>hskb1${*Tk-Cz2hZ8J`Nba& zt^4kY4=y>_{@axgKAavn=Fa{(t?$0@{_2a)=kBbTr#}0^mfF3??7L&nZONv=XP}b3m9I~l-kmA-S|K3{p@ucr!1ZD@GpLJ z>fdq;Tc^Bv$JQNc_~VsF&HUE=+xUlS`1v(!j_-T-kF9$*-Lg0MeD?i4O`A)P-hbfV V3!9!erT4krANTBC^~j%g{0~^DvPS>_ literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/FFmpegCapabilitiesTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/FFmpegCapabilitiesTest.kt new file mode 100644 index 00000000..e6246289 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/FFmpegCapabilitiesTest.kt @@ -0,0 +1,75 @@ +package com.zarz.spotiflac + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercise shipped libraries rather than the host's unrelated FFmpeg build. */ +@RunWith(AndroidJUnit4::class) +class FFmpegCapabilitiesTest { + private fun execute(vararg arguments: String) { + val result = NativeDownloadFinalizer.runFFmpegArguments(arrayOf("-v", "error", "-y", *arguments)) + assertTrue(result.second, result.first) + } + + @Test + fun audioEncodersAnalysisAndCoverFiltersRemainAvailable() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val root = File(context.cacheDir, "ffmpeg-capabilities-${System.nanoTime()}").apply { mkdirs() } + try { + for ((extension, encoder) in listOf( + "flac" to "flac", "m4a" to "alac", "wav" to "pcm_s24le", + "aiff" to "pcm_s24be", "mp3" to "libmp3lame", "opus" to "libopus", "aac" to "aac", + )) { + val output = File(root, "track.$extension") + execute("-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000", "-t", "0.5", "-c:a", encoder, output.path) + execute("-i", output.path, "-map", "0:a:0", "-f", "null", "-") + assertTrue(output.length() > 0) + } + val audio = File(root, "track.flac") + val stats = File(root, "analysis.txt") + execute("-i", audio.path, "-af", "astats=metadata=1:reset=0,ebur128=peak=true:metadata=1,ametadata=print:file='${stats.path}'", "-f", "null", "-") + assertTrue(stats.readText().contains("lavfi.astats")) + val spectrum = File(root, "spectrum.png") + execute("-i", audio.path, "-lavfi", "showspectrumpic=s=64x64:legend=0:mode=combined:color=channel:scale=log:fscale=lin:win_func=hann", "-frames:v", "1", spectrum.path) + val cover = File(root, "cover.jpg") + execute("-i", spectrum.path, "-vf", "scale=32:32", "-frames:v", "1", cover.path) + assertTrue(cover.length() > 0) + } finally { root.deleteRecursively() } + } + + @Test + fun motionArtworkDecodingAndRemuxRemainAvailable() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val root = File(instrumentation.targetContext.cacheDir, "ffmpeg-motion-${System.nanoTime()}").apply { mkdirs() } + try { + for (codec in listOf("h264", "hevc")) { + val input = File(root, "$codec.mp4") + instrumentation.context.assets.open("motion-probe-$codec.mp4").use { source -> + input.outputStream().use { source.copyTo(it) } + } + execute("-xerror", "-err_detect", "explode", "-i", input.path, "-map", "0:v:0", "-an", "-sn", "-dn", "-f", "null", "-") + val output = File(root, "$codec-remux.mp4") + execute("-i", input.path, "-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart", output.path) + assertTrue(output.length() > 0) + } + // Optional live Dart HTTPS/HLS proxy gate, forwarded with adb reverse. + // The local codec fixtures above remain independent of this server. + InstrumentationRegistry.getArguments().getString("motionProxyUrl")?.let { source -> + val output = File(root, "proxy-remux.mp4") + val input = arrayOf( + "-protocol_whitelist", "file,http,https,tcp,tls,crypto", + "-rw_timeout", "10000000", "-http_multiple", "0", + "-http_persistent", "0", "-extension_picky", "0", "-i", source, + ) + execute("-xerror", "-err_detect", "explode", *input, "-map", "0:v:0", "-an", "-f", "null", "-") + execute(*input, "-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart", output.path) + assertTrue(output.length() > 0) + execute("-xerror", "-err_detect", "explode", "-i", output.path, "-map", "0:v:0", "-an", "-f", "null", "-") + } + } finally { root.deleteRecursively() } + } +} diff --git a/lib/services/automix_analyzer.dart b/lib/services/automix_analyzer.dart index c295c9c9..842e503b 100644 --- a/lib/services/automix_analyzer.dart +++ b/lib/services/automix_analyzer.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index f0994c31..488e6556 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; diff --git a/lib/services/motion_artwork_proxy.dart b/lib/services/motion_artwork_proxy.dart new file mode 100644 index 00000000..ce820848 --- /dev/null +++ b/lib/services/motion_artwork_proxy.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +/// Dart handles upstream TLS; FFmpeg sees only a short-lived loopback URL. +/// Only the initial resource and URLs discovered in its HLS playlists can be +/// requested, with a shared transfer budget across playlists, keys and ranges. +class MotionArtworkProxy { + MotionArtworkProxy._(this._server, this._client, this._maxBytes); + + final HttpServer _server; + final HttpClient _client; + final int _maxBytes; + final _resources = {}; + final _paths = {}; + final String _token = List.generate( + 24, + (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + late final Timer _deadline; + late final Uri source; + int _transferred = 0; + bool _closed = false; + bool _hlsDetected = false; + Future? _closing; + + bool get isClosed => _closed; + bool get isHls => _hlsDetected || source.path.endsWith('.m3u8'); + + static Future start( + Uri source, { + HttpClient? client, + int maxBytes = 24 << 20, + Duration lifetime = const Duration(seconds: 30), + }) async { + _checkRemote(source); + final upstream = client ?? HttpClient(); + upstream + ..connectionTimeout = const Duration(seconds: 8) + ..autoUncompress = false; + try { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final proxy = MotionArtworkProxy._(server, upstream, maxBytes); + proxy.source = proxy._register(source); + proxy._deadline = Timer(lifetime, () => unawaited(proxy.close())); + server.listen(proxy._handle, onError: (Object _) {}); + return proxy; + } catch (_) { + upstream.close(force: true); + rethrow; + } + } + + Future close() => _closing ??= _close(); + + Future _close() async { + _closed = true; + _deadline.cancel(); + _client.close(force: true); + await _server.close(force: true); + } + + static void _checkRemote(Uri uri) { + if (!['http', 'https'].contains(uri.scheme) || + uri.host.isEmpty || + uri.toString().length > 8192) { + throw const FormatException('Artwork resource requires HTTP or HTTPS'); + } + } + + Uri _register(Uri remote) { + _checkRemote(remote); + remote = remote.removeFragment(); + final existing = _paths[remote]; + if (existing != null) return _local(existing); + if (_resources.length >= 4096 || remote.toString().length > 8192) { + throw const FormatException('Too many artwork resources'); + } + // HLS validates segment extensions before opening them; retain the + // upstream suffix while hiding its URL/query behind the resource token. + final extension = + RegExp(r'\.[a-z0-9]{1,10}$').stringMatch(remote.path.toLowerCase()) ?? + '.bin'; + final path = '/$_token/${_resources.length}$extension'; + _resources[path] = remote; + _paths[remote] = path; + return _local(path); + } + + Uri _local(String path) => Uri( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: _server.port, + path: path, + ); + + Future<({HttpClientResponse response, Uri uri})> _request( + Uri uri, + HttpRequest incoming, + ) async { + for (var redirects = 0; redirects <= 5; redirects++) { + _checkRemote(uri); + final request = await _client + .openUrl(incoming.method, uri) + .timeout(const Duration(seconds: 8)); + request.followRedirects = false; + request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); + for (final name in [HttpHeaders.rangeHeader, HttpHeaders.ifRangeHeader]) { + final value = incoming.headers.value(name); + if (value != null) request.headers.set(name, value); + } + final response = await request.close().timeout( + const Duration(seconds: 8), + ); + if (!response.isRedirect) return (response: response, uri: uri); + final location = response.headers.value(HttpHeaders.locationHeader); + await response.listen(null).cancel(); + if (location == null) throw const HttpException('Missing redirect'); + uri = uri.resolve(location); + } + throw const HttpException('Too many artwork redirects'); + } + + Stream> _body(HttpClientResponse response) async* { + await for (final chunk in response.timeout(const Duration(seconds: 8))) { + if (_closed || _transferred + chunk.length > _maxBytes) { + unawaited(close()); + throw const HttpException('Artwork transfer budget exceeded'); + } + _transferred += chunk.length; + yield chunk; + } + } + + Future _handle(HttpRequest request) async { + final outgoing = request.response; + outgoing.persistentConnection = false; + outgoing.bufferOutput = false; + try { + final remote = _resources[request.uri.path]; + if (_closed || remote == null || request.uri.hasQuery) { + outgoing.statusCode = HttpStatus.notFound; + return; + } + if (request.method != 'GET' && request.method != 'HEAD') { + outgoing.statusCode = HttpStatus.methodNotAllowed; + return; + } + final upstream = await _request(remote, request); + final response = upstream.response; + outgoing.statusCode = response.statusCode; + for (final name in [ + HttpHeaders.contentTypeHeader, + HttpHeaders.contentRangeHeader, + HttpHeaders.acceptRangesHeader, + HttpHeaders.contentEncodingHeader, + ]) { + final value = response.headers.value(name); + if (value != null) outgoing.headers.set(name, value); + } + if (request.method == 'HEAD') { + if (response.contentLength >= 0) { + outgoing.contentLength = response.contentLength; + } + await response.drain(); + return; + } + final body = StreamIterator(_body(response)); + try { + final prefix = []; + while (prefix.length < 7 && await body.moveNext()) { + prefix.addAll(body.current); + } + final playlist = + response.statusCode >= 200 && + response.statusCode < 300 && + (upstream.uri.path.toLowerCase().endsWith('.m3u8') || + (response.headers.contentType?.mimeType.contains('mpegurl') ?? + false) || + utf8 + .decode(prefix.take(32).toList(), allowMalformed: true) + .trimLeft() + .startsWith('#EXTM3U')); + if (playlist) { + _hlsDetected = true; + final bytes = [...prefix]; + while (await body.moveNext()) { + if (bytes.length + body.current.length > 512 << 10) { + throw const FormatException( + 'Artwork playlist exceeds size limit', + ); + } + bytes.addAll(body.current); + } + if (bytes.length > 512 << 10) { + throw const FormatException('Artwork playlist exceeds size limit'); + } + final text = await _playlistText(bytes, response.headers); + final rewritten = utf8.encode(_rewrite(text, upstream.uri)); + outgoing + ..statusCode = HttpStatus.ok + ..headers.removeAll(HttpHeaders.contentRangeHeader) + ..headers.removeAll(HttpHeaders.contentEncodingHeader) + ..headers.contentType = ContentType( + 'application', + 'vnd.apple.mpegurl', + ) + ..contentLength = rewritten.length + ..add(rewritten); + } else { + if (response.contentLength >= 0) { + outgoing.contentLength = response.contentLength; + } + outgoing.add(prefix); + await outgoing.flush(); + while (await body.moveNext()) { + outgoing.add(body.current); + await outgoing.flush(); + } + } + } finally { + await body.cancel(); + } + } catch (_) { + try { + outgoing.statusCode = HttpStatus.badGateway; + } catch (_) { + // A stream failure can happen after binary response headers were sent. + } + } finally { + try { + await outgoing.close(); + } catch (_) {} + } + } + + Future _playlistText(List bytes, HttpHeaders headers) async { + Stream> stream = Stream.value(bytes); + switch (headers.value(HttpHeaders.contentEncodingHeader)?.toLowerCase()) { + case 'gzip': + stream = gzip.decoder.bind(stream); + case 'deflate': + stream = zlib.decoder.bind(stream); + case null || 'identity': + break; + default: + throw const FormatException('Unsupported artwork playlist encoding'); + } + final decoded = []; + await for (final chunk in stream) { + if (decoded.length + chunk.length > 512 << 10) { + throw const FormatException('Artwork playlist exceeds size limit'); + } + decoded.addAll(chunk); + } + return utf8.decode(decoded); + } + + String _rewrite(String text, Uri base) => const LineSplitter() + .convert(text) + .map((raw) { + final line = raw.trim(); + if (line.isEmpty) return raw; + if (!line.startsWith('#')) { + return _register(base.resolve(line)).toString(); + } + return raw.replaceAllMapped(RegExp(r'([:,])URI="([^"]+)"'), (match) { + return '${match[1]}URI="${_register(base.resolve(match[2]!))}"'; + }); + }) + .join('\n'); +} + +Future withMotionArtworkProxy( + Uri source, + Future Function(Uri source) action, { + HttpClient? client, + int maxBytes = 24 << 20, + Duration lifetime = const Duration(seconds: 30), +}) async { + final proxy = await MotionArtworkProxy.start( + source, + client: client, + maxBytes: maxBytes, + lifetime: lifetime, + ); + try { + return await action(proxy.source); + } finally { + await proxy.close(); + } +} diff --git a/lib/services/motion_artwork_store.dart b/lib/services/motion_artwork_store.dart index 374494f1..9d958518 100644 --- a/lib/services/motion_artwork_store.dart +++ b/lib/services/motion_artwork_store.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/motion_artwork_download.dart'; +import 'package:spotiflac_android/services/motion_artwork_proxy.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -183,13 +184,39 @@ class MotionArtworkStore { '$output.source.mp4', ); try { - return await _remuxVideo(original?.path ?? source, output); + if (original != null) return await _remuxVideo(original.path, output); + final proxy = await MotionArtworkProxy.start(Uri.parse(source)); + try { + final hls = proxy.isHls; + final ratio = await _remuxVideo( + proxy.source.toString(), + output, + hlsInput: hls, + ); + // Opaque or redirected URLs reveal HLS only after the first request. + // Retry failed HLS input with its scoped demuxer options, without + // passing HLS-only flags to ordinary MP4 inputs or buffering video. + if (ratio == null && !hls && proxy.isHls && !proxy.isClosed) { + return await _remuxVideo( + proxy.source.toString(), + output, + hlsInput: true, + ); + } + return ratio; + } finally { + await proxy.close(); + } } finally { if (original != null && await original.exists()) await original.delete(); } } - static Future _remuxVideo(String source, String output) async { + static Future _remuxVideo( + String source, + String output, { + bool hlsInput = false, + }) async { // Remux public video/HLS into a self-contained, silent MP4. Bound both // transfer time and output size; never let optional artwork block audio. final success = await _run([ @@ -200,7 +227,11 @@ class MotionArtworkStore { '10000000', // HLS byte ranges can share one MP4 URL. Reusing/prefetching HTTP // connections can splice the next range into an unfinished segment. - if (Uri.parse(source).path.toLowerCase().endsWith('.m3u8')) ...[ + if (hlsInput) ...[ + // Tokenized URLs retain normal suffixes, but public HLS sources can + // use opaque segment paths. Only discovered HTTP resources are served. + '-extension_picky', + '0', '-http_multiple', '0', '-http_persistent', diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index f6b4158c..b46b5024 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -4,10 +4,10 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; diff --git a/pubspec.lock b/pubspec.lock index c9a8e829..cd92bf89 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -431,11 +431,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" - ffmpeg_kit_flutter_new_full: + ffmpeg_kit_flutter_new_audio: dependency: "direct main" description: - name: ffmpeg_kit_flutter_new_full - sha256: "0f610062616b70b963a9f3b47675243969a044c570db1620f1fd9f4f0b85eb7b" + name: ffmpeg_kit_flutter_new_audio + sha256: "79e51732fa9bb5e88992ffdc84cbb78babbb44a773cd1f75f11290b295959c71" url: "https://pub.dev" source: hosted version: "2.5.2" diff --git a/pubspec.yaml b/pubspec.yaml index 5a919ec9..5a9edd35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,7 +68,7 @@ dependencies: logger: ^2.7.0 # FFmpeg for audio conversion - ffmpeg_kit_flutter_new_full: ^2.5.2 + ffmpeg_kit_flutter_new_audio: 2.5.2 open_filex: ^4.7.0 video_player: ^2.14.0 diff --git a/scripts/README_ios_ffmpeg_capabilities.md b/scripts/README_ios_ffmpeg_capabilities.md new file mode 100644 index 00000000..a75f7fd7 --- /dev/null +++ b/scripts/README_ios_ffmpeg_capabilities.md @@ -0,0 +1,58 @@ +# iOS FFmpeg capability probe + +`scripts/ios_ffmpeg_capabilities.m` runs the shipped simulator frameworks without +building the Flutter app. It checks FLAC, ALAC, 24-bit WAV/AIFF, MP3, Opus and AAC +encoding/decoding; analysis filters; spectrum PNG/JPEG conversion; and H.264/HEVC +motion decoding and stream-copy remuxing. This complements application tests: it +does not prove Flutter routing, download finalization or the Dart TLS proxy. + +Prepare the audio plugin's XCFrameworks with its provided `scripts/setup_ios.sh` +from the plugin's `ios` directory. Boot an iOS simulator, then run the following +from the repository root. Framework copies are signed in a temporary directory; +the downloaded vendor artifacts remain unchanged. + +```bash +probe_plugin="$HOME/.pub-cache/hosted/pub.dev/ffmpeg_kit_flutter_new_audio-2.5.2" +probe_work=$(mktemp -d /tmp/spotiflac-ios-ffmpeg.XXXXXX) +mkdir -p "$probe_work/Frameworks" +for framework in "$probe_plugin"/ios/Frameworks/*.xcframework; do + name=$(basename "$framework" .xcframework) + cp -R "$framework/ios-arm64_x86_64-simulator/$name.framework" "$probe_work/Frameworks/" + codesign --force --sign - "$probe_work/Frameworks/$name.framework" +done +xcrun --sdk iphonesimulator clang -fobjc-arc -target arm64-apple-ios16.0-simulator \ + -isysroot "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -F "$probe_work/Frameworks" -framework Foundation -framework ffmpegkit \ + -Wl,-rpath,"$probe_work/Frameworks" scripts/ios_ffmpeg_capabilities.m \ + -o "$probe_work/probe" +codesign --force --sign - "$probe_work/probe" +xcrun simctl spawn booted "$probe_work/probe" "$probe_work/results" \ + "$PWD/android/app/src/androidTest/assets" +``` + +The audio package exposes HTTP but has no native HTTPS/TLS support. Remote motion +input requires the app's scoped Dart TLS proxy. To exercise remote input, append +a fixture URL as the last probe argument; use the proxy's HTTP loopback URL when +testing the application route. Passing a direct HTTPS URL intentionally reports +a capability failure on this package. Run the proxy's Dart tests and Android/iOS +application checks before claiming that remote motion playback is preserved. + +Remote checks decode the complete fixture, stream-copy it to MP4 with faststart, +and decode the resulting MP4 with strict error handling. The probe also prints +the shipped HLS demuxer's options. For HLS fixtures with opaque segment URLs, +append `--disable-hls-extension-check` after the proxy URL; this passes +`-extension_picky 0` only for that input. The other HLS flags in the usage message +are diagnostic modes for comparing the native extension checks. + +Verified on the shipped iOS simulator audio frameworks: + +| HTTPS-origin fixture through the Dart proxy | Input options | Decode/remux/decode | +| --- | --- | --- | +| Redirected master playlist, AES-128 key, three TS segments | Defaults | Pass | +| Same content with `.bin`, extensionless and `.ts` segment URLs | Defaults | Rejects segment extension | +| Same opaque segment URLs | `-allowed_extensions ALL` | Rejects segment extension | +| Same opaque segment URLs | Both extension lists set to `ALL` | Rejects format/extension mismatch | +| Same opaque segment URLs | `-extension_picky 0` | Pass | + +The app uses the final option for scoped HLS proxy input. The proxy still owns +the upstream URL allowlist, TLS requests, transfer budget and cancellation. diff --git a/scripts/ios_ffmpeg_capabilities.m b/scripts/ios_ffmpeg_capabilities.m new file mode 100644 index 00000000..307e9efa --- /dev/null +++ b/scripts/ios_ffmpeg_capabilities.m @@ -0,0 +1,118 @@ +#import +#import +#import +#import + +// Standalone simulator probe for the shipped audio frameworks. Pass a writable +// output directory, android/app/src/androidTest/assets, and optionally a remote +// motion fixture URL. The audio package requires the app's Dart TLS proxy for +// HTTPS inputs; pass its HTTP loopback URL to verify that application route. +static BOOL execute(NSArray *arguments) { + NSArray *command = [@[@"-v", @"error", @"-y"] arrayByAddingObjectsFromArray:arguments]; + FFmpegSession *session = [FFmpegKit executeWithArguments:command]; + if (![ReturnCode isSuccess:[session getReturnCode]]) { + fprintf(stderr, "FFmpeg failed: %s\n%s\n", [[command description] UTF8String], + [[session getAllLogsAsString] UTF8String]); + return NO; + } + return YES; +} + +static BOOL nonempty(NSString *path) { + NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; + return [attributes[NSFileSize] unsignedLongLongValue] > 0; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + NSString *hlsMode = argc == 5 ? [NSString stringWithUTF8String:argv[4]] : @""; + if (argc < 3 || argc > 5 || (argc == 5 && ![@[@"--allow-hls-extensions", @"--allow-hls-segments", @"--relax-hls-extensions", @"--disable-hls-extension-check"] containsObject:hlsMode])) { + fprintf(stderr, "Usage: ios_ffmpeg_capabilities OUTPUT_DIR MOTION_FIXTURE_DIR [REMOTE_FIXTURE_URL [--allow-hls-extensions|--allow-hls-segments|--relax-hls-extensions|--disable-hls-extension-check]]\n"); + return 2; + } + [FFmpegKitConfig setLogRedirectionStrategy:LogRedirectionStrategyNeverPrintLogs]; + FFmpegSession *protocols = [FFmpegKit executeWithArguments:@[@"-hide_banner", @"-protocols"]]; + NSString *protocolList = [protocols getAllLogsAsString]; + printf("FFmpeg protocols:\n%s\n", [protocolList UTF8String]); + BOOL supportsHTTPS = NO; + BOOL supportsHTTP = NO; + for (NSString *line in [protocolList componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { + NSString *protocol = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([protocol isEqualToString:@"http"]) supportsHTTP = YES; + if ([protocol isEqualToString:@"https"]) { + supportsHTTPS = YES; + } + } + if (!supportsHTTP) { + fprintf(stderr, "FAIL HTTP protocol unavailable: app TLS proxy cannot work\n"); + return 1; + } + printf("PASS HTTP protocol; native HTTPS %s\n", supportsHTTPS ? "available" : "unavailable (requires app TLS proxy)"); + FFmpegSession *hlsOptions = [FFmpegKit executeWithArguments:@[@"-hide_banner", @"-h", @"demuxer=hls"]]; + printf("HLS demuxer options:\n%s\n", [[hlsOptions getAllLogsAsString] UTF8String]); + NSString *root = [NSString stringWithUTF8String:argv[1]]; + NSString *fixtures = [NSString stringWithUTF8String:argv[2]]; + if (![[NSFileManager defaultManager] createDirectoryAtPath:root + withIntermediateDirectories:YES attributes:nil error:nil]) return 2; + for (NSArray *format in @[ + @[@"flac", @"flac"], @[@"m4a", @"alac"], @[@"wav", @"pcm_s24le"], + @[@"aiff", @"pcm_s24be"], @[@"mp3", @"libmp3lame"], @[@"opus", @"libopus"], @[@"aac", @"aac"] + ]) { + NSString *output = [root stringByAppendingPathComponent:[@"track." stringByAppendingString:format[0]]]; + if (!execute(@[@"-f", @"lavfi", @"-i", @"sine=frequency=997:sample_rate=48000", + @"-t", @"0.5", @"-c:a", format[1], output]) || !nonempty(output)) return 1; + if (!execute(@[@"-i", output, @"-map", @"0:a:0", @"-f", @"null", @"-"])) return 1; + printf("PASS encode/decode %s\n", [format[1] UTF8String]); + } + NSString *audio = [root stringByAppendingPathComponent:@"track.flac"]; + NSString *stats = [root stringByAppendingPathComponent:@"analysis.txt"]; + NSString *filter = [NSString stringWithFormat: + @"astats=metadata=1:reset=0,ebur128=peak=true:metadata=1,ametadata=print:file='%@'", stats]; + if (!execute(@[@"-i", audio, @"-af", filter, @"-f", @"null", @"-"])) return 1; + NSString *metadata = [NSString stringWithContentsOfFile:stats encoding:NSUTF8StringEncoding error:nil]; + if (![metadata containsString:@"lavfi.astats"] || ![metadata containsString:@"lavfi.r128.I"]) return 1; + printf("PASS astats/ebur128/ametadata\n"); + NSString *spectrum = [root stringByAppendingPathComponent:@"spectrum.png"]; + if (!execute(@[@"-i", audio, @"-lavfi", + @"showspectrumpic=s=64x64:legend=0:mode=combined:color=channel:scale=log:fscale=lin:win_func=hann", + @"-frames:v", @"1", spectrum]) || !nonempty(spectrum)) return 1; + NSString *cover = [root stringByAppendingPathComponent:@"cover.jpg"]; + if (!execute(@[@"-i", spectrum, @"-vf", @"scale=32:32", @"-frames:v", @"1", cover]) || !nonempty(cover)) return 1; + printf("PASS spectrum PNG/JPEG scale\n"); + for (NSString *codec in @[@"h264", @"hevc"]) { + NSString *input = [fixtures stringByAppendingPathComponent: + [NSString stringWithFormat:@"motion-probe-%@.mp4", codec]]; + if (!execute(@[@"-xerror", @"-err_detect", @"explode", @"-i", input, @"-map", @"0:v:0", + @"-an", @"-sn", @"-dn", @"-f", @"null", @"-"])) return 1; + NSString *output = [root stringByAppendingPathComponent:[codec stringByAppendingString:@"-remux.mp4"]]; + if (!execute(@[@"-i", input, @"-map", @"0:v:0", @"-an", @"-c:v", @"copy", + @"-movflags", @"+faststart", output]) || !nonempty(output)) return 1; + printf("PASS motion decode/remux %s\n", [codec UTF8String]); + } + if (argc >= 4) { + NSString *remote = [NSString stringWithUTF8String:argv[3]]; + NSMutableArray *input = [NSMutableArray arrayWithArray:@[@"-rw_timeout", @"5000000"]]; + if (argc == 5 && ![hlsMode isEqualToString:@"--disable-hls-extension-check"]) { + [input addObjectsFromArray:@[@"-allowed_extensions", @"ALL"]]; + } + if ([@[@"--allow-hls-segments", @"--relax-hls-extensions"] containsObject:hlsMode]) { + [input addObjectsFromArray:@[@"-allowed_segment_extensions", @"ALL"]]; + } + if ([@[@"--relax-hls-extensions", @"--disable-hls-extension-check"] containsObject:hlsMode]) { + [input addObjectsFromArray:@[@"-extension_picky", @"0"]]; + } + [input addObjectsFromArray:@[@"-i", remote]]; + if (!execute([input arrayByAddingObjectsFromArray:@[@"-map", @"0:v:0", + @"-an", @"-f", @"null", @"-"]])) return 1; + NSString *remuxed = [root stringByAppendingPathComponent:@"remote-remux.mp4"]; + if (!execute([input arrayByAddingObjectsFromArray:@[@"-map", @"0:v:0", @"-an", @"-c:v", @"copy", + @"-movflags", @"+faststart", remuxed]]) || !nonempty(remuxed)) return 1; + if (!execute(@[@"-xerror", @"-err_detect", @"explode", @"-i", remuxed, @"-map", @"0:v:0", + @"-an", @"-f", @"null", @"-"])) return 1; + printf("PASS remote motion input %s\n", [remote UTF8String]); + printf("PASS remote stream-copy remux/decode\n"); + } + printf("PASS iOS audio FFmpeg capabilities\n"); + return 0; + } +} diff --git a/test/motion_artwork_proxy_test.dart b/test/motion_artwork_proxy_test.dart new file mode 100644 index 00000000..8525dc6e --- /dev/null +++ b/test/motion_artwork_proxy_test.dart @@ -0,0 +1,385 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/motion_artwork_proxy.dart'; + +void main() { + late HttpClient reader; + setUp(() => reader = HttpClient()); + tearDown(() => reader.close(force: true)); + + Future<({int status, HttpHeaders headers, List bytes})> fetch( + Uri uri, { + String method = 'GET', + String? range, + }) async { + final request = await reader.openUrl(method, uri); + if (range != null) request.headers.set('range', range); + final response = await request.close(); + return ( + status: response.statusCode, + headers: response.headers, + bytes: await response.expand((chunk) => chunk).toList(), + ); + } + + Future tlsServer() async { + final context = SecurityContext() + ..useCertificateChain('test/fixtures/network_tls/cert.pem') + ..usePrivateKey('test/fixtures/network_tls/key.pem'); + final server = await HttpServer.bindSecure( + InternetAddress.loopbackIPv4, + 0, + context, + ); + addTearDown(() => server.close(force: true)); + return server; + } + + HttpClient trustedFixtureClient(HttpServer server) => + HttpClient() + ..badCertificateCallback = (_, host, port) => + host == '127.0.0.1' && port == server.port; + + test('TLS MP4 relays GET, HEAD, ranges, status and content type', () async { + final server = await tlsServer(); + final requests = <(String, String?)>[]; + server.listen((request) async { + requests.add((request.method, request.headers.value('range'))); + request.response.headers.contentType = ContentType('video', 'mp4'); + request.response.headers.set('accept-ranges', 'bytes'); + if (request.headers.value('range') == 'bytes=1-2') { + request.response + ..statusCode = 206 + ..headers.set('content-range', 'bytes 1-2/4') + ..contentLength = 2 + ..add([20, 30]); + } else { + request.response.contentLength = 4; + if (request.method != 'HEAD') request.response.add([10, 20, 30, 40]); + } + await request.response.close(); + }, onError: (Object _) {}); + final proxy = await MotionArtworkProxy.start( + Uri.parse('https://127.0.0.1:${server.port}/cover.mp4'), + client: trustedFixtureClient(server), + ); + addTearDown(proxy.close); + final full = await fetch(proxy.source); + expect(full.bytes, [10, 20, 30, 40]); + expect(full.headers.contentType?.mimeType, 'video/mp4'); + final range = await fetch(proxy.source, range: 'bytes=1-2'); + expect(range.status, 206); + expect(range.bytes, [20, 30]); + expect(range.headers.value('content-range'), 'bytes 1-2/4'); + final head = await fetch(proxy.source, method: 'HEAD'); + expect(head.status, 200); + expect(head.bytes, isEmpty); + expect(head.headers.contentLength, 4); + final count = requests.length; + expect( + (await fetch(proxy.source.replace(query: 'url=http://other'))).status, + 404, + ); + expect( + (await fetch(proxy.source.replace(path: '/unknown/0.bin'))).status, + 404, + ); + expect((await fetch(proxy.source, method: 'POST')).status, 405); + expect(requests.length, count); + expect(requests, [('GET', null), ('GET', 'bytes=1-2'), ('HEAD', null)]); + }); + + test( + 'TLS stays verified unless the test explicitly trusts its fixture', + () async { + final server = await tlsServer(); + var requests = 0; + server.listen((request) async { + requests++; + await request.response.close(); + }, onError: (Object _) {}); + final proxy = await MotionArtworkProxy.start( + Uri.parse('https://127.0.0.1:${server.port}/cover.mp4'), + ); + addTearDown(proxy.close); + expect((await fetch(proxy.source)).status, 502); + expect(requests, 0); + }, + ); + + test( + 'HLS rewrites master, redirected media, key, map and byte ranges', + () async { + final server = await tlsServer(); + final paths = []; + server.listen((request) async { + paths.add(request.uri.toString()); + final response = request.response; + switch (request.uri.path) { + case '/start.m3u8': + response.statusCode = 302; + response.headers.set('location', '/cdn/master.m3u8'); + case '/cdn/master.m3u8': + response.write( + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100,CODECS="avc1.4"\nsub/media.m3u8\n', + ); + case '/cdn/sub/media.m3u8': + response.statusCode = 302; + response.headers.set('location', '/final/media/list.m3u8'); + case '/final/media/list.m3u8': + response.write('''#EXTM3U +#EXT-X-KEY:METHOD=AES-128,URI="../key.bin?token=secret" +#EXT-X-MAP:URI="../init.mp4",BYTERANGE="2@0" +#EXTINF:1, +one.ts +#EXT-X-BYTERANGE:2@1 +../../shared.mp4 +#EXTINF:1, +two.ts +#EXT-X-ENDLIST +'''); + case '/final/key.bin': + response.add(List.filled(16, 42)); + case '/final/init.mp4': + expect(request.headers.value('range'), 'bytes=0-1'); + response + ..statusCode = 206 + ..headers.set('content-range', 'bytes 0-1/4') + ..add([1, 2]); + case '/shared.mp4': + expect(request.headers.value('range'), 'bytes=1-2'); + response + ..statusCode = 206 + ..headers.set('content-range', 'bytes 1-2/4') + ..add([20, 30]); + case '/final/media/one.ts': + response.add([4, 5]); + case '/final/media/two.ts': + response.add([6, 7]); + default: + response.statusCode = 404; + } + await response.close(); + }, onError: (Object _) {}); + final proxy = await MotionArtworkProxy.start( + Uri.parse('https://127.0.0.1:${server.port}/start.m3u8'), + client: trustedFixtureClient(server), + ); + addTearDown(proxy.close); + final master = utf8.decode((await fetch(proxy.source)).bytes); + final child = Uri.parse(master.split('\n').last); + expect(child.scheme, 'http'); + expect(child.host, '127.0.0.1'); + final media = utf8.decode((await fetch(child)).bytes); + expect(media, isNot(contains('https:'))); + expect(media, contains('#EXT-X-BYTERANGE:2@1')); + expect(media, contains('BYTERANGE="2@0"')); + final attributes = RegExp( + r'URI="([^"]+)"', + ).allMatches(media).map((match) => Uri.parse(match[1]!)).toList(); + final segments = const LineSplitter() + .convert(media) + .where((line) => line.isNotEmpty && !line.startsWith('#')) + .map(Uri.parse) + .toList(); + expect((await fetch(attributes[0])).bytes, List.filled(16, 42)); + expect((await fetch(attributes[1], range: 'bytes=0-1')).bytes, [1, 2]); + expect((await fetch(segments[0])).bytes, [4, 5]); + expect((await fetch(segments[1], range: 'bytes=1-2')).bytes, [20, 30]); + expect((await fetch(segments[2])).bytes, [6, 7]); + expect(paths, contains('/final/key.bin?token=secret')); + expect(paths, contains('/cdn/sub/media.m3u8')); + expect(paths, contains('/final/media/list.m3u8')); + }, + ); + + test( + 'binary responses start streaming before the upstream finishes', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final continueBody = Completer(); + server.listen((request) async { + request.response.add(List.filled(16384, 1)); + await request.response.flush(); + await continueBody.future; + request.response.add(List.filled(1024 * 1024, 2)); + await request.response.close(); + }); + final proxy = await MotionArtworkProxy.start( + Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'), + ); + addTearDown(proxy.close); + final response = await (await reader.getUrl(proxy.source)).close(); + var bytes = 0; + await for (final chunk in response.timeout(const Duration(seconds: 2))) { + bytes += chunk.length; + if (!continueBody.isCompleted) continueBody.complete(); + } + expect(bytes, 16384 + 1024 * 1024); + }, + ); + + test( + 'large declared MP4 supports partial reads and consumer cancellation', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final stop = Completer(); + server.listen((request) async { + expect(request.headers.value('range'), 'bytes=0-'); + request.response + ..bufferOutput = false + ..statusCode = 206 + ..headers.set('content-range', 'bytes 0-67108863/67108864') + ..contentLength = 64 << 20 + ..add(List.filled(1024, 1)); + await request.response.flush(); + await stop.future; + try { + await request.response.close(); + } catch (_) { + // FFmpeg stops after reading its clip instead of downloading 64 MiB. + } + }); + final proxy = await MotionArtworkProxy.start( + Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'), + maxBytes: 4096, + ); + addTearDown(proxy.close); + final request = await reader.getUrl(proxy.source); + request.headers.set('range', 'bytes=0-'); + final response = await request.close(); + expect(response.statusCode, 206); + expect(response.contentLength, 64 << 20); + final body = StreamIterator(response); + expect(await body.moveNext(), isTrue); + expect(body.current, everyElement(1)); + expect(proxy.isClosed, isFalse); + await body.cancel(); + stop.complete(); + }, + ); + + test( + 'gzip playlists rewrite URI attributes and preserve unrelated names', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response.headers.contentType = ContentType( + 'application', + 'vnd.apple.mpegurl', + ); + request.response.headers.set('content-encoding', 'gzip'); + request.response.add( + gzip.encode( + utf8.encode('''#EXTM3U +#EXT-X-UNKNOWN:SERVER-URI="urn:ignored",X-FOO-URI="file:///ignored" +#EXT-X-KEY:METHOD=AES-128,URI="key.bin" +#EXTINF:1, +one.ts +#EXT-X-ENDLIST +'''), + ), + ); + await request.response.close(); + }); + final proxy = await MotionArtworkProxy.start( + Uri.parse('http://127.0.0.1:${server.port}/opaque'), + ); + addTearDown(proxy.close); + expect(proxy.isHls, isFalse); + final response = await fetch(proxy.source); + expect(proxy.isHls, isTrue); + expect(response.status, 200); + expect(response.headers.value('content-encoding'), isNull); + final text = utf8.decode(response.bytes); + expect( + text, + contains('SERVER-URI="urn:ignored",X-FOO-URI="file:///ignored"'), + ); + expect(text, contains('METHOD=AES-128,URI="http://127.0.0.1:')); + expect( + text.split('\n').where((line) => line.startsWith('http')).single, + endsWith('.ts'), + ); + }, + ); + + test( + 'cumulative byte limit closes the proxy instead of retaining video', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response + ..contentLength = 5 + ..add([1, 2, 3, 4, 5]); + await request.response.close(); + }); + final proxy = await MotionArtworkProxy.start( + Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'), + maxBytes: 8, + ); + addTearDown(proxy.close); + expect((await fetch(proxy.source)).bytes, [1, 2, 3, 4, 5]); + await expectLater(fetch(proxy.source), throwsA(isA())); + expect(proxy.isClosed, isTrue); + }, + ); + + test( + 'deadline and failed consumers close their client and loopback server', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response.write('video'); + await request.response.close(); + }); + final source = Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'); + Uri? expired; + final client = HttpClient(); + await expectLater( + withMotionArtworkProxy(source, (local) async { + expired = local; + throw StateError('remux failed'); + }, client: client), + throwsStateError, + ); + await expectLater(fetch(expired!), throwsA(isA())); + expect(() => client.getUrl(source), throwsStateError); + final proxy = await MotionArtworkProxy.start( + source, + lifetime: const Duration(milliseconds: 30), + ); + addTearDown(proxy.close); + await Future.delayed(const Duration(milliseconds: 60)); + expect(proxy.isClosed, isTrue); + await expectLater(fetch(proxy.source), throwsA(isA())); + }, + ); + + test( + 'playlist references cannot enable local file or non-HTTP resources', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response.write( + '#EXTM3U\n#EXT-X-KEY:URI="file:///private/key"\n', + ); + await request.response.close(); + }); + final proxy = await MotionArtworkProxy.start( + Uri.parse('http://127.0.0.1:${server.port}/cover.m3u8'), + ); + addTearDown(proxy.close); + expect((await fetch(proxy.source)).status, 502); + }, + ); +}