Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class DownloadForegroundService : Service() {
(0 until entries.length()).map { parseHeaders(entries.optJSONObject(it)?.toString() ?: "{}") }
}.getOrDefault(emptyList())
val wifiOnly = intent.getBooleanExtra(EXTRA_WIFI_ONLY, false)
val videoHeight = intent.getIntExtra(EXTRA_VIDEO_HEIGHT, 0)
val segmentRelay = intent.getStringExtra(EXTRA_SEGMENT_RELAY)

if (id.isEmpty() || artefactPath.isEmpty()) return
if (kind == KIND_MANGA) {
Expand Down Expand Up @@ -189,7 +191,7 @@ class DownloadForegroundService : Service() {
updateState(id, title, url, artefactPath, STATUS_DOWNLOADING, 0, 0, 0, null)
val result = when (kind) {
KIND_MANGA -> downloadPages(id, title, artefactPath, pageUrls, headers, imageHeaders, token)
KIND_HLS -> downloadHls(id, title, url, artefactPath, headers, token)
KIND_HLS -> downloadHls(id, title, url, artefactPath, headers, token, videoHeight, segmentRelay)
else -> downloadFile(id, title, url, artefactPath, headers, token)
}
when {
Expand Down Expand Up @@ -371,7 +373,9 @@ class DownloadForegroundService : Service() {
url: String,
artefactPath: String,
headers: Map<String, String>,
token: AtomicBoolean
token: AtomicBoolean,
videoHeight: Int,
segmentRelay: String?
): Transferred {
val target = File(artefactPath)
val folder = target.parentFile ?: throw IOException("no folder for the playlist")
Expand All @@ -382,7 +386,7 @@ class DownloadForegroundService : Service() {
var master: String? = null
var audio: HlsDownloadPlaylist.AudioRendition? = null
if (playlist.contains("#EXT-X-STREAM-INF")) {
playlistUrl = HlsDownloadPlaylist.pickVariantUrl(playlist, HlsDownloadPlaylist.baseUrlOf(url))
playlistUrl = HlsDownloadPlaylist.pickVariantUrl(playlist, HlsDownloadPlaylist.baseUrlOf(url), videoHeight)
?: throw IOException("no variant in the master playlist")
master = playlist
audio = HlsDownloadPlaylist.audioRendition(playlist, HlsDownloadPlaylist.baseUrlOf(url), playlistUrl)
Expand Down Expand Up @@ -459,7 +463,9 @@ class DownloadForegroundService : Service() {
// process was never fetched again and the episode played to that
// point and stopped.
if (!segment.exists() || segment.length() == 0L) {
fetchPart(part.segments[i].url, segment, headers, token, part.segments[i].range)
val original = part.segments[i].url
val relayed = MediaSegmentRelay.rewrite(original, segmentRelay)
fetchPart(relayed ?: original, segment, if (relayed == null) headers else emptyMap(), token, part.segments[i].range)
}
if (token.get()) return@inParallel
val size = bytes.addAndGet(segment.length())
Expand Down Expand Up @@ -1129,11 +1135,13 @@ class DownloadForegroundService : Service() {
const val EXTRA_TITLE = "title"
const val EXTRA_URL = "url"
const val EXTRA_LOCAL_PATH = "local_path"
const val EXTRA_SEGMENT_RELAY = "segment_relay"
const val EXTRA_HEADERS_JSON = "headers_json"
const val EXTRA_KIND = "kind"
const val EXTRA_PAGE_URLS_JSON = "page_urls_json"
const val EXTRA_IMAGE_HEADERS_JSON = "image_headers_json"
const val EXTRA_WIFI_ONLY = "wifi_only"
const val EXTRA_VIDEO_HEIGHT = "video_height"

// Kinds, matching DownloadKind on the Dart side byte for byte.
const val KIND_VIDEO = "video"
Expand Down
27 changes: 22 additions & 5 deletions android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,19 @@ object HlsDownloadPlaylist {
* the packager's own `s1080p` name, else RESOLUTION's height, bitrate to
* break a tie.
*/
fun pickVariantUrl(playlist: String, baseUrl: String): String? {
fun pickVariantUrl(playlist: String, baseUrl: String, preferredHeight: Int = 0): String? {
val lines = playlist.lines()
val heights = lines.mapNotNull { tag ->
val match = Regex("RESOLUTION=(\\d+)x(\\d+)").find(tag) ?: return@mapNotNull null
val w = match.groupValues[1].toInt()
val h = match.groupValues[2].toInt()
displayHeight(w, h)
}
val wanted = when {
preferredHeight < 0 -> heights.minOrNull()
preferredHeight > 0 -> heights.filter { it <= preferredHeight }.maxOrNull() ?: heights.minOrNull()
else -> null
}
var best: String? = null
var bestHeight = 0
var bestBandwidth = 0L
Expand All @@ -30,10 +41,10 @@ object HlsDownloadPlaylist {
if (!tag.startsWith("#EXT-X-STREAM-INF")) continue
val uri = lines[i + 1].trim()
if (uri.isEmpty() || uri.startsWith("#")) continue
val height = NAMED_HEIGHT.find(uri)?.groupValues?.get(1)?.toIntOrNull()
?: RESOLUTION.find(tag)?.groupValues?.get(1)?.toIntOrNull()
?: 0
if (height <= 0) continue
val dimensions = Regex("RESOLUTION=(\\d+)x(\\d+)").find(tag)
val height = dimensions?.let { displayHeight(it.groupValues[1].toInt(), it.groupValues[2].toInt()) }
?: NAMED_HEIGHT.find(uri)?.groupValues?.get(1)?.toIntOrNull() ?: 0
if (height <= 0 || (wanted != null && height != wanted)) continue
val bandwidth = BANDWIDTH.find(tag)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
if (height > bestHeight || (height == bestHeight && bandwidth > bestBandwidth)) {
best = resolveUrl(uri, baseUrl)
Expand Down Expand Up @@ -72,6 +83,12 @@ object HlsDownloadPlaylist {
return null
}

private fun displayHeight(width: Int, height: Int): Int {
val effective = maxOf(minOf(width, height).toDouble(), maxOf(width, height) * 9.0 / 16)
return listOf(2160, 1440, 1080, 720, 480, 360, 240)
.firstOrNull { kotlin.math.abs(effective - it) <= it * 0.08 } ?: effective.toInt()
}

data class ByteRange(val offset: Long, val length: Long)
data class Segment(val url: String, val range: ByteRange?)
data class AuxEntry(val uri: String, val isMap: Boolean, val key: String, val range: ByteRange?)
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ class MainActivity : FlutterFragmentActivity() {
.putExtra(DownloadForegroundService.EXTRA_URL, url)
.putExtra(DownloadForegroundService.EXTRA_LOCAL_PATH, localPath)
.putExtra(DownloadForegroundService.EXTRA_KIND, kind)
.putExtra(DownloadForegroundService.EXTRA_VIDEO_HEIGHT, call.argument<Int>("videoHeight") ?: 0)
.putExtra(DownloadForegroundService.EXTRA_SEGMENT_RELAY, call.argument<String>("segmentRelay"))
.putExtra(
DownloadForegroundService.EXTRA_WIFI_ONLY,
call.argument<Boolean>("wifiOnly") ?: false
Expand Down
19 changes: 19 additions & 0 deletions android/app/src/main/kotlin/com/soplay/sozo/MediaSegmentRelay.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.soplay.sozo

import java.net.URI
import java.net.URLEncoder

internal object MediaSegmentRelay {
fun rewrite(raw: String, endpoint: String?): String? {
if (endpoint.isNullOrBlank()) return null
return try {
val u = URI(raw)
val relay = URI(endpoint)
if (relay.scheme != "https" || relay.userInfo != null ||
u.scheme != "https" || u.userInfo != null || (u.port != -1 && u.port != 443) ||
!Regex("^p(?:16|19)-ad-site-sign-sg\\.tiktokcdn\\.com$").matches(u.host ?: "") ||
!u.path.startsWith("/ad-site-i18n-sg/") || !u.path.endsWith(".image")) null
else "$endpoint?url=${URLEncoder.encode(raw, "UTF-8")}"
} catch (_: Exception) { null }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import org.junit.Test
*/
class HlsDownloadPlaylistTest {

@Test
fun `manual height keeps the selected rendition instead of highest bitrate`() {
val master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=1280x720\n720.m3u8\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=18000000,RESOLUTION=1920x1080\n1080.m3u8\n"
assertEquals("https://cdn.test/720.m3u8", HlsDownloadPlaylist.pickVariantUrl(master, "https://cdn.test/", 720))
}

private val base = "https://cdn.test/v/"

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.soplay.sozo
import org.junit.Assert.*
import org.junit.Test
import java.net.URLDecoder
class MediaSegmentRelayTest {
@Test fun onlyWrappedCdnUsesRelayAndPreservesSignature() {
val raw = "https://p16-ad-site-sign-sg.tiktokcdn.com/ad-site-i18n-sg/a.image?x-signature=a%2Bb"
val result = MediaSegmentRelay.rewrite(raw, "https://api.test/api/contents/media-segment")!!
assertEquals(raw, URLDecoder.decode(result.substringAfter("?url="), "UTF-8"))
assertNull(MediaSegmentRelay.rewrite("https://cdn.test/video.ts", "https://api.test/relay"))
assertNull(MediaSegmentRelay.rewrite(raw.replace(".com/", ".com.evil.test/"), "https://api.test/relay"))
assertNull(MediaSegmentRelay.rewrite(raw, null))
}
}
85 changes: 85 additions & 0 deletions integration_test/android_av1_decoder_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Exercises the actual libmpv -> platform recovery path with AV1 10-bit.
// Emulator routing is intentionally not initialized: this tests the controller
// selection used on physical Android phones, rather than the emulator override.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path_provider/path_provider.dart';
import 'package:soplay/core/constants/app_constants.dart';
import 'package:soplay/core/player/media_controller.dart';
import 'package:soplay/core/player/player_engine.dart';

const fixture = String.fromEnvironment(
'CODEC_FIXTURE',
defaultValue: 'av1-10bit.mp4',
);
const expectedHeight = int.fromEnvironment('CODEC_HEIGHT', defaultValue: 1080);

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
late Directory dir;
late Box<dynamic> settings;
setUpAll(() async {
dir = await Directory(
'${(await getTemporaryDirectory()).path}/av1-decoder-check',
).create(recursive: true);
Hive.init(dir.path);
settings = await Hive.openBox<dynamic>(AppConstants.settingsBox);
await settings.put(AppConstants.playerEngineKey, PlayerEngine.mediaKit.id);
final client = HttpClient();
try {
final response = await (await client.getUrl(
Uri.parse('http://127.0.0.1:8766/$fixture'),
)).close();
expect(response.statusCode, 200);
await response.pipe(File('${dir.path}/av1.mp4').openWrite());
} finally {
client.close();
}
});
tearDownAll(() async {
await Hive.close();
await dir.delete(recursive: true);
});
for (final local in [false, true]) {
testWidgets(
'$fixture ${local ? "local" : "network"} mpv controller recovers or plays',
(tester) async {
final c = local
? PlayerController.file(File('${dir.path}/av1.mp4'))
: PlayerController.networkUrl(
Uri.parse('http://127.0.0.1:8766/$fixture'),
);
expect(
c.runtimeType.toString(),
'_MediaKitController',
reason: 'Exercise mpv, not emulator native shortcut',
);
try {
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: c.buildView())),
);
await c.initialize().timeout(const Duration(seconds: 50));
expect(c.value.size.height, expectedHeight);
await c.play();
final end = DateTime.now().add(const Duration(seconds: 15));
while (c.value.position.inMilliseconds < 1000 &&
DateTime.now().isBefore(end)) {
await Future<void>.delayed(const Duration(milliseconds: 250));
await tester.pump();
}
expect(c.value.hasError, isFalse, reason: c.value.errorDescription);
expect(c.value.position.inMilliseconds, greaterThanOrEqualTo(1000));
debugPrint(
'CODEC_VERIFIED fixture=$fixture local=$local size=${c.value.size}',
);
} finally {
await tester.pumpWidget(const SizedBox());
await c.dispose();
}
},
);
}
}
Loading
Loading