diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..c9a7624be --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +# LFS tracking removed to allow pushing to public fork diff --git a/README.md b/README.md index f90680386..b5ebf3c31 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ MpvRx pushes the mpv-android experience further with deep customization, thermal | **Display Cutout Mode** | Full-bleed on notch devices | | **Remember Brightness** | Persists brightness level set during playback | | **M3U Playlist Support** | Parse and play local M3U playlists | +| **yt-dlp Integration** | High-performance streaming support for YouTube, Twitch, Bilibili, and more via a native Python bridge (SDK 29+ bypass) | @@ -353,6 +354,7 @@ git push origin v1.3.1-preview.1 - [Next Player](https://github.com/anilbeesetti/nextplayer) - [Gramophone](https://github.com/FoedusProgramme/Gramophone) - [hdr-toys](https://github.com/natural-harmonia-gropius/hdr-toys) +- [**SunnyVishnu3**](https://github.com/SunnyVishnu3) for the `yt-dlp` native integration and SDK 29+ bypass logic. --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 672113e4f..6cb085e91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,6 +26,19 @@ android { buildConfigField("String", "GIT_SHA", "\"${getCommitSha()}\"") buildConfigField("int", "GIT_COUNT", getCommitCount()) + + externalNativeBuild { + cmake { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } } flavorDimensions += "distribution" diff --git a/app/libs/mpvlib.aar b/app/libs/mpvlib.aar index ed08e6423..38508beba 100644 Binary files a/app/libs/mpvlib.aar and b/app/libs/mpvlib.aar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2861c3f54..3959fcf22 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + + @@ -26,6 +28,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/ytdl/python313.zip b/app/src/main/assets/ytdl/python313.zip new file mode 100644 index 000000000..8a8650a19 Binary files /dev/null and b/app/src/main/assets/ytdl/python313.zip differ diff --git a/app/src/main/assets/ytdl/setup.py b/app/src/main/assets/ytdl/setup.py new file mode 100644 index 000000000..c43c098f7 --- /dev/null +++ b/app/src/main/assets/ytdl/setup.py @@ -0,0 +1,56 @@ +import sys, os, urllib.request + +# Argument 1: Native Library Directory (passed from Java) +native_lib_dir = sys.argv[1] if len(sys.argv) > 1 else "" + +# scriptdest is the path for a legacy wrapper if needed +scriptdest = "../youtube-dl.sh" +name = "yt-dlp" +url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" + +# Clean up old files first +for path in (scriptdest, "youtube-dl", name): + try: + if os.path.exists(path): os.unlink(path) + except: + pass + +print("Downloading '{}' to '{}'...".format(url, name)) +try: + # Use a real browser user-agent for download + opener = urllib.request.build_opener() + opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')] + urllib.request.install_opener(opener) + + urllib.request.urlretrieve(url, name) + print("Download successful.") +except Exception as e: + print("Download failed: " + str(e)) + sys.exit(1) + +# Ensure executable bit is NOT set on the data directory script +# to avoid SELinux denials on Android 10+. +# We run it via the native libytdl.so -> libpython.so bridge instead. +try: + os.chmod(name, 0o600) +except: + pass + +# Create a .sh wrapper as a reference +try: + with open(scriptdest, "w") as f: + ytdl_dir = os.getcwd() + cacert = os.path.abspath(os.path.join(ytdl_dir, "../cacert.pem")) + + f.write("#!/system/bin/sh\n") + f.write("NATIVE_LIB_DIR=\"{}\"\n".format(native_lib_dir)) + f.write("export PYTHONHOME=\"{}\"\n".format(ytdl_dir)) + f.write("export PYTHONPATH=\"{}/python313.zip\"\n".format(ytdl_dir)) + f.write("export SSL_CERT_FILE=\"{}\"\n".format(cacert)) + f.write("export LD_LIBRARY_PATH=\"$NATIVE_LIB_DIR\"\n") + f.write("exec \"$NATIVE_LIB_DIR/libytdl.so\" \"$@\"\n") + + os.chmod(scriptdest, 0o700) + print("Created reference wrapper at {}".format(scriptdest)) +except: + print("Warning: Could not create wrapper") diff --git a/app/src/main/assets/ytdl/wrapper b/app/src/main/assets/ytdl/wrapper new file mode 100644 index 000000000..eeefebf03 --- /dev/null +++ b/app/src/main/assets/ytdl/wrapper @@ -0,0 +1,9 @@ +#!/system/bin/sh +NATIVE_LIB_DIR=$1 +shift +cd $(dirname "$0") +export PYTHONHOME=. +export PYTHONPATH=./python313.zip +export SSL_CERT_FILE=$PWD/../cacert.pem +export LD_LIBRARY_PATH=$NATIVE_LIB_DIR +exec $NATIVE_LIB_DIR/libytdl.so "$@" diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..7fd068ea8 --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.22.1) + +project("ytdl_bridge") + +# YTDLP execution wrapper for SDK 29+ bypass +# This works on all architectures +add_executable(ytdl_wrapper ytdl_wrapper.c) +set_target_properties(ytdl_wrapper PROPERTIES OUTPUT_NAME "ytdl") +# Rename to libytdl.so so it gets extracted by Android +set_target_properties(ytdl_wrapper PROPERTIES PREFIX "lib" SUFFIX ".so") diff --git a/app/src/main/cpp/ytdl_wrapper.c b/app/src/main/cpp/ytdl_wrapper.c new file mode 100644 index 000000000..ddf7bb1ff --- /dev/null +++ b/app/src/main/cpp/ytdl_wrapper.c @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include +#include +#include + +/* + * libytdl: A native bridge for yt-dlp on Android 10+ + * This executable hosts the Python interpreter by loading libpython.so dynamically. + * It bypasses the "no exec from data directory" restriction by living in lib/. + */ + +typedef int (*Py_BytesMain_t)(int argc, char **argv); +typedef void (*Py_SetProgramName_t)(const wchar_t *); + +int main(int argc, char *argv[]) { + char *python_lib = getenv("YTDL_PYTHON"); + char *script_path = getenv("YTDL_SCRIPT"); + + // Use a hardcoded fallback if env var is missing + if (!python_lib || strlen(python_lib) == 0) { + python_lib = "libpython.so"; + } + + // Load the Python shared library + void *handle = dlopen(python_lib, RTLD_NOW | RTLD_GLOBAL); + if (!handle) { + handle = dlopen("libpython.so", RTLD_NOW | RTLD_GLOBAL); + } + + if (!handle) { + fprintf(stderr, "libytdl: CRITICAL: Could not load libpython.so: %s\n", dlerror()); + return 127; + } + + // Optional: Set program name to the binary path to help Python find its libs + Py_SetProgramName_t Py_SetProgramName = (Py_SetProgramName_t)dlsym(handle, "Py_SetProgramName"); + if (Py_SetProgramName) { + // Simple conversion for the program name + wchar_t wprog[512]; + mbstowcs(wprog, argv[0], 511); + wprog[511] = L'\0'; + Py_SetProgramName(wprog); + } + + // Find Py_BytesMain (Standard entry point for Python 3.8+) + Py_BytesMain_t Py_BytesMain = (Py_BytesMain_t)dlsym(handle, "Py_BytesMain"); + if (!Py_BytesMain) { + // Fallback for older Python 3 versions (Py_Main uses wchar_t**, which is harder to use here) + // But since we expect Python 3.8+, Py_BytesMain should be there. + fprintf(stderr, "libytdl: CRITICAL: Could not find Py_BytesMain in libpython.so\n"); + dlclose(handle); + return 127; + } + + int result; + if (script_path && strlen(script_path) > 0) { + // Correct way to run a script: python script.py arg1 arg2 ... + // We need original_argc + 1 slots for: "python", script_path, argv[1...], and NULL + int new_argc = argc + 1; + char **python_argv = malloc((new_argc + 1) * sizeof(char *)); + if (!python_argv) return 1; + + python_argv[0] = "python"; + python_argv[1] = script_path; + for (int i = 1; i < argc; i++) { + python_argv[i + 1] = argv[i]; + } + python_argv[new_argc] = NULL; + + result = Py_BytesMain(new_argc, python_argv); + free(python_argv); + } else { + // If YTDL_SCRIPT is not set, we act as a transparent python interpreter. + result = Py_BytesMain(argc, argv); + } + + // Note: We don't dlclose(handle) here as Py_BytesMain might have registered + // atexit handlers that need the library to remain loaded until process exit. + return result; +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt index 3597a0144..200408fa8 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt @@ -12,6 +12,7 @@ import app.gyrolet.mpvrx.preferences.GesturePreferences import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.SettingsManager import app.gyrolet.mpvrx.preferences.SubtitlesPreferences +import app.gyrolet.mpvrx.preferences.YtdlPreferences import app.gyrolet.mpvrx.preferences.preference.AndroidPreferenceStore import app.gyrolet.mpvrx.preferences.preference.PreferenceStore import org.koin.android.ext.koin.androidContext @@ -33,6 +34,7 @@ val PreferencesModule = single { BrowserPreferences(get(), androidContext()) } singleOf(::FoldersPreferences) singleOf(::AiPreferences) + singleOf(::YtdlPreferences) singleOf(::SettingsManager) } diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt new file mode 100644 index 000000000..034989439 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt @@ -0,0 +1,11 @@ +package app.gyrolet.mpvrx.preferences + +import app.gyrolet.mpvrx.preferences.preference.PreferenceStore + +class YtdlPreferences( + preferenceStore: PreferenceStore, +) { + val ytdlFormat = preferenceStore.getString("video_ytdl_format", "") + val ytdlQuality = preferenceStore.getInt("ytdl_quality", -1) // -1 for any + val preferH264 = preferenceStore.getBoolean("ytdl_prefer_h264", false) +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt index 4e3fedba3..e5aa95389 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt @@ -237,6 +237,7 @@ object Icons { val ChevronRight = Shared.ChevronRight val Clear = Shared.Clear val Close = Shared.Close + val CloudDownload = Shared.CloudDownload val Code = Shared.Code val ContentCopy = Shared.ContentCopy val CreateNewFolder = Shared.CreateNewFolder diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt index c4dda4f25..ba2b77527 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt @@ -13,9 +13,11 @@ import app.gyrolet.mpvrx.preferences.AudioPreferences import app.gyrolet.mpvrx.preferences.DecoderPreferences import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.SubtitlesPreferences +import app.gyrolet.mpvrx.preferences.YtdlPreferences import app.gyrolet.mpvrx.domain.anime4k.Anime4KManager import app.gyrolet.mpvrx.domain.hdr.HdrToysManager import app.gyrolet.mpvrx.ui.player.PlayerActivity.Companion.TAG +import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager import app.gyrolet.mpvrx.ui.player.controls.components.panels.toColorHexString import app.gyrolet.mpvrx.ui.preferences.VulkanUtils import `is`.xyz.mpv.BaseMPVView @@ -35,6 +37,7 @@ class MPVView( private val decoderPreferences: DecoderPreferences by inject() private val advancedPreferences: AdvancedPreferences by inject() private val subtitlesPreferences: SubtitlesPreferences by inject() + private val ytdlPreferences: YtdlPreferences by inject() private val anime4kManager: Anime4KManager by inject() private val hdrToysManager: HdrToysManager by inject() @@ -189,6 +192,7 @@ class MPVView( setupSubtitlesOptions() setupAudioOptions() + YtdlpManager.setupMpvOptions(context, ytdlPreferences) } override fun observeProperties() { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt index 0c5262401..f838d71dd 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt @@ -64,6 +64,7 @@ import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.SubtitlesPreferences import app.gyrolet.mpvrx.preferences.VideoSortType import app.gyrolet.mpvrx.ui.player.controls.PlayerControls +import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager import app.gyrolet.mpvrx.ui.theme.MpvrxTheme import app.gyrolet.mpvrx.utils.history.RecentlyPlayedOps import app.gyrolet.mpvrx.utils.media.HttpUtils @@ -527,6 +528,14 @@ class PlayerActivity : setHttpHeadersFromExtras(intent.extras) getPlayableUri(intent)?.let { playableUri -> + // Remind user if they forgot to set up yt-dlp + if (playableUri.startsWith("http") && !playableUri.substringAfterLast('/').contains('.')) { + val ytdlDir = YtdlpManager.getYtdlDir(this) + if (!File(ytdlDir, "yt-dlp").exists()) { + viewModel.showToast(getString(R.string.toast_need_ytdl)) + } + } + currentPlayableUri = playableUri isReady = false viewModel.onVideoLoadStarted() @@ -3116,6 +3125,14 @@ class PlayerActivity : // Load the new file getPlayableUri(intent)?.let { uri -> + // Remind user if they forgot to set up yt-dlp + if (uri.startsWith("http") && !uri.substringAfterLast('/').contains('.')) { + val ytdlDir = YtdlpManager.getYtdlDir(this) + if (!File(ytdlDir, "yt-dlp").exists()) { + viewModel.showToast(getString(R.string.toast_need_ytdl)) + } + } + currentPlayableUri = uri isReady = false viewModel.onVideoLoadStarted() diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt index d68022629..8b70ba407 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt @@ -82,4 +82,3 @@ val panelCardsColors: @Composable () -> CardColors = { disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), ) } - diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt new file mode 100644 index 000000000..456cf6bb1 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt @@ -0,0 +1,123 @@ +package app.gyrolet.mpvrx.ui.player.controls.components.panels + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager +import app.gyrolet.mpvrx.ui.theme.spacing +import kotlinx.coroutines.launch +import java.io.File + +@Composable +fun YtdlpPanel( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var logs by remember { mutableStateOf("") } + val scrollState = rememberScrollState() + var isRunning by remember { mutableStateOf(false) } + + val ytdlDir = remember { YtdlpManager.getYtdlDir(context) } + var hasYtdlp by remember { mutableStateOf(File(ytdlDir, "yt-dlp").exists()) } + + LaunchedEffect(isRunning) { + if (!isRunning) { + hasYtdlp = File(ytdlDir, "yt-dlp").exists() + } + } + + LaunchedEffect(logs) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + DraggablePanel( + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) + ) { + Text( + text = "yt-dlp Manager", + style = MaterialTheme.typography.headlineSmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) + ) { + Button( + onClick = { + scope.launch { + isRunning = true + logs = "" + YtdlpManager.runInstall(context) { line -> + logs += line + } + isRunning = false + } + }, + enabled = !isRunning, + modifier = Modifier.weight(1f) + ) { + Text("Install") + } + + Button( + onClick = { + scope.launch { + isRunning = true + logs = "" + YtdlpManager.runUpdate(context) { line -> + logs += line + } + isRunning = false + } + }, + enabled = !isRunning && hasYtdlp, + modifier = Modifier.weight(1f) + ) { + Text("Update") + } + } + + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .padding(MaterialTheme.spacing.small) + .verticalScroll(scrollState) + ) { + Text( + text = logs.ifEmpty { "Ready..." }, + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + + if (isRunning) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Text( + text = "Bypass SDK 29+ active (using libpython.so)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt new file mode 100644 index 000000000..a42b6e0cf --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt @@ -0,0 +1,200 @@ +package app.gyrolet.mpvrx.ui.player.ytdlp + +import android.content.Context +import android.system.Os +import android.util.Log +import app.gyrolet.mpvrx.preferences.YtdlPreferences +import `is`.xyz.mpv.MPVLib +import java.io.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object YtdlpManager { + private const val TAG = "YtdlpManager" + private const val YTDL_DIR = "ytdl" + + fun getYtdlDir(context: Context): File { + return File(context.filesDir, YTDL_DIR).apply { if (!exists()) mkdirs() } + } + + fun getExecutablePath(context: Context): String { + return File(context.applicationInfo.nativeLibraryDir, "libytdl.so").absolutePath + } + + suspend fun copyAssets(context: Context) = withContext(Dispatchers.IO) { + val ytdlDir = getYtdlDir(context) + + // Clean up old potentially problematic scripts from multiple possible locations + listOf("youtube-dl", "youtube-dl.sh").forEach { name -> + File(context.filesDir, name).delete() + File(ytdlDir, name).delete() + } + + // Files to copy from assets/ytdl/ to filesDir/ytdl/ + val ytdlFiles = arrayOf("setup.py", "wrapper", "python313.zip") + for (name in ytdlFiles) { + copyAssetFile(context, "ytdl/$name", File(ytdlDir, name)) + } + + // cacert.pem goes to filesDir/ + copyAssetFile(context, "cacert.pem", File(context.filesDir, "cacert.pem")) + + // Set executable permission on wrapper (just in case it's used) + File(ytdlDir, "wrapper").setExecutable(true) + } + + private fun copyAssetFile(context: Context, assetPath: String, outFile: File): Boolean { + return try { + context.assets.open(assetPath).use { input -> + val size = input.available().toLong() + if (outFile.exists() && outFile.length() == size) { + Log.v(TAG, "Skipping copy: $assetPath (exists same size)") + return true + } + FileOutputStream(outFile).use { output -> + input.copyTo(output) + } + Log.d(TAG, "Copied asset: $assetPath") + true + } + } catch (e: IOException) { + Log.e(TAG, "Failed to copy asset: $assetPath", e) + false + } + } + + fun setupMpvOptions(context: Context, ytdlPreferences: YtdlPreferences) { + val nativeLibDir = context.applicationInfo.nativeLibraryDir + val ytdlBinaryPath = File(nativeLibDir, "libytdl.so").absolutePath + val ytdlDir = getYtdlDir(context).absolutePath + val ytDlpScriptPath = File(ytdlDir, "yt-dlp").absolutePath + val pythonPath = File(nativeLibDir, "libpython.so").absolutePath + + // Set environment variables for the subprocesses started by libmpv + try { + Os.setenv("YTDL_PYTHON", pythonPath, true) + Os.setenv("YTDL_SCRIPT", ytDlpScriptPath, true) + Os.setenv("PYTHONHOME", ytdlDir, true) + // Include both the zip and the directory itself in PYTHONPATH + // Also include nativeLibDir for potential .so modules + Os.setenv("PYTHONPATH", "$ytdlDir/python313.zip:$ytdlDir:$nativeLibDir", true) + Os.setenv("SSL_CERT_FILE", File(context.filesDir, "cacert.pem").absolutePath, true) + + // Add nativeLibDir to PATH so scripts can find our bridge if they search PATH + val currentPath = runCatching { Os.getenv("PATH") }.getOrNull() + val newPath = if (currentPath.isNullOrBlank()) nativeLibDir else "$nativeLibDir:$currentPath" + Os.setenv("PATH", newPath, true) + + // Set LD_LIBRARY_PATH for the subprocess to find libpython.so's dependencies + val currentLd = runCatching { Os.getenv("LD_LIBRARY_PATH") }.getOrNull() + val newLd = if (currentLd.isNullOrBlank()) nativeLibDir else "$nativeLibDir:$currentLd" + Os.setenv("LD_LIBRARY_PATH", newLd, true) + + Log.d(TAG, "Environment variables set for ytdl bridge") + } catch (e: Exception) { + Log.e(TAG, "Failed to set environment variables", e) + } + + // Check if yt-dlp actually exists. If not, log a warning. + val ytDlpFile = File(ytdlDir, "yt-dlp") + if (!ytDlpFile.exists()) { + Log.w(TAG, "yt-dlp not found in ${ytDlpFile.absolutePath}. Subprocess will fail until installed.") + } + + val ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + // Create script-opts/ytdl_hook.conf to ensure the script picks up our bridge + // This is the most reliable way to override ytdl_hook options + try { + val scriptOptsDir = File(context.filesDir, "script-opts") + if (!scriptOptsDir.exists()) scriptOptsDir.mkdirs() + val ytdlConf = File(scriptOptsDir, "ytdl_hook.conf") + val confContent = """ + ytdl_path=$ytdlBinaryPath + all_formats=yes + """.trimIndent() + ytdlConf.writeText(confContent) + Log.d(TAG, "Created ytdl_hook.conf at ${ytdlConf.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to create ytdl_hook.conf", e) + } + + // Apply options to MPV core + MPVLib.setOptionString("ytdl", "yes") + MPVLib.setOptionString("ytdl-path", ytdlBinaryPath) + + // Use script-opts-append for runtime flexibility + MPVLib.setOptionString("script-opts-append", "ytdl_hook-path=$ytdlBinaryPath") + MPVLib.setOptionString("script-opts-append", "ytdl_hook-ytdl_path=$ytdlBinaryPath") + + val ytdlFormat = ytdlPreferences.ytdlFormat.get() + if (!ytdlFormat.isNullOrBlank()) { + MPVLib.setOptionString("ytdl-format", ytdlFormat) + } + + // Global User-Agent to avoid blocks at the network level + MPVLib.setOptionString("user-agent", ua) + MPVLib.setOptionString("ytdl-raw-options", "user-agent=\"$ua\"") + MPVLib.setOptionString("script-opts-append", "ytdl_hook-user_agent=\"$ua\"") + + Log.d(TAG, "MPV ytdl options set. Binary: $ytdlBinaryPath") + } + + suspend fun runInstall(context: Context, onLog: (String) -> Unit): Boolean = withContext(Dispatchers.IO) { + copyAssets(context) + + val ytdlDir = getYtdlDir(context) + val nativeLibDir = context.applicationInfo.nativeLibraryDir + val pythonBinary = getExecutablePath(context) + val setupPy = File(ytdlDir, "setup.py").absolutePath + + // We use the bridge to run setup.py + val command = mutableListOf(pythonBinary, setupPy, nativeLibDir) + + runPythonProcess("Installing yt-dlp...", command, context, onLog) + } + + suspend fun runUpdate(context: Context, onLog: (String) -> Unit): Boolean = withContext(Dispatchers.IO) { + val ytdlDir = getYtdlDir(context) + val pythonBinary = getExecutablePath(context) + val ytDlp = File(ytdlDir, "yt-dlp").absolutePath + + val command = mutableListOf(pythonBinary, ytDlp, "--update") + + runPythonProcess("Updating yt-dlp...", command, context, onLog) + } + + private fun runPythonProcess(title: String, command: List, context: Context, onLog: (String) -> Unit): Boolean { + onLog("$title\n") + return try { + val processBuilder = ProcessBuilder(command) + .directory(getYtdlDir(context)) + .redirectErrorStream(true) + + val env = processBuilder.environment() + val ytdlDir = getYtdlDir(context).absolutePath + val nativeLibDir = context.applicationInfo.nativeLibraryDir + + // Clear YTDL_SCRIPT so the bridge doesn't try to wrap yt-dlp during setup/update + env.remove("YTDL_SCRIPT") + + env["YTDL_PYTHON"] = File(nativeLibDir, "libpython.so").absolutePath + env["PYTHONHOME"] = ytdlDir + env["PYTHONPATH"] = "$ytdlDir/python313.zip" + env["SSL_CERT_FILE"] = File(context.filesDir, "cacert.pem").absolutePath + env["LD_LIBRARY_PATH"] = nativeLibDir + + val process = processBuilder.start() + + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var line: String? + while (reader.readLine().also { line = it } != null) { + onLog(line + "\n") + } + process.waitFor() == 0 + } catch (e: Exception) { + onLog("Error: ${e.message}\n") + false + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt index 976d2a526..6d9b0dc0b 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt @@ -485,6 +485,28 @@ object AdvancedPreferencesScreen : Screen { backStack.add(app.gyrolet.mpvrx.ui.preferences.CustomButtonScreen) }, ) + + PreferenceDivider() + + Preference( + title = { Text("yt-dlp Manager") }, + summary = { + Text( + "Install and update yt-dlp for streaming support", + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { + backStack.add(YtdlpSettingsScreen) + }, + ) } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt new file mode 100644 index 000000000..bf62beffc --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt @@ -0,0 +1,234 @@ +package app.gyrolet.mpvrx.ui.preferences + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.R +import app.gyrolet.mpvrx.preferences.YtdlPreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.presentation.Screen +import app.gyrolet.mpvrx.ui.icons.Icon +import app.gyrolet.mpvrx.ui.icons.Icons +import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager +import app.gyrolet.mpvrx.ui.theme.spacing +import app.gyrolet.mpvrx.ui.utils.LocalBackStack +import app.gyrolet.mpvrx.ui.utils.popSafely +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import me.zhanghai.compose.preference.SwitchPreference +import org.koin.compose.koinInject +import java.io.File + +@Serializable +object YtdlpSettingsScreen : Screen { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val context = LocalContext.current + val backStack = LocalBackStack.current + val scope = rememberCoroutineScope() + var logs by remember { mutableStateOf("") } + val scrollState = rememberScrollState() + var isRunning by remember { mutableStateOf(false) } + + val ytdlPreferences = koinInject() + val ytdlQuality by ytdlPreferences.ytdlQuality.collectAsState() + val preferH264 by ytdlPreferences.preferH264.collectAsState() + + val ytdlDir = remember { YtdlpManager.getYtdlDir(context) } + var hasYtdlp by remember { mutableStateOf(File(ytdlDir, "yt-dlp").exists()) } + + LaunchedEffect(isRunning) { + if (!isRunning) { + hasYtdlp = File(ytdlDir, "yt-dlp").exists() + } + } + + LaunchedEffect(logs) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "yt-dlp Manager", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + ) + }, + navigationIcon = { + IconButton(onClick = { backStack.popSafely() }) { + Icon(Icons.Outlined.ArrowBack, contentDescription = null) + } + } + ) + } + ) { padding -> + ProvidePreferenceLocals { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(MaterialTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) + ) { + Text( + text = "Manage yt-dlp for streaming support. This uses a bypass for SDK 29+ restrictions.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Column(modifier = Modifier.padding(MaterialTheme.spacing.small)) { + Text( + text = "Streaming Quality", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = MaterialTheme.spacing.extraSmall) + ) + + var expanded by remember { mutableStateOf(false) } + val qualityLevels = remember { arrayOf(-1, 2160, 1440, 1080, 720, 480, 360, 240, 144) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = if (ytdlQuality == -1) "Any" else "${ytdlQuality}p", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyMedium + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + qualityLevels.forEach { level -> + DropdownMenuItem( + text = { Text(if (level == -1) "Any" else "${level}p") }, + onClick = { + ytdlPreferences.ytdlQuality.set(level) + updateFormatString(ytdlPreferences, level, preferH264) + expanded = false + } + ) + } + } + } + + SwitchPreference( + value = preferH264, + onValueChange = { newValue -> + ytdlPreferences.preferH264.set(newValue) + updateFormatString(ytdlPreferences, ytdlQuality, newValue) + }, + title = { Text("Prefer H.264 (AVC)") }, + summary = { Text("Better compatibility, but may limit quality") } + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) + ) { + Button( + onClick = { + scope.launch { + isRunning = true + logs = "" + YtdlpManager.runInstall(context) { line -> + logs += line + } + isRunning = false + } + }, + enabled = !isRunning, + modifier = Modifier.weight(1f) + ) { + Text("Install") + } + + Button( + onClick = { + scope.launch { + isRunning = true + logs = "" + YtdlpManager.runUpdate(context) { line -> + logs += line + } + isRunning = false + } + }, + enabled = !isRunning && hasYtdlp, + modifier = Modifier.weight(1f) + ) { + Text("Update") + } + } + + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .padding(MaterialTheme.spacing.small) + .verticalScroll(scrollState) + ) { + Text( + text = logs.ifEmpty { "Ready..." }, + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + + if (isRunning) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Text( + text = "Bypass active: Using libpython.so from native library directory.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + } + + private fun updateFormatString(prefs: YtdlPreferences, quality: Int, preferH264: Boolean) { + var qstr = "" + /* bv = bestvideo, ba = bestaudio, b = best */ + if (quality != -1 && preferH264) { + qstr = "(bv*[vcodec^=?avc]/bv*[vcodec^=?mp4])[height<=?${quality}]+ba/" + + "(b[vcodec^=?avc]/b[vcodec^=?mp4])[height<=?${quality}]" + } else if (quality != -1) { + qstr = "bv[height<=?${quality}]+ba/b[height<=?${quality}]" + } else if (preferH264) { + qstr = "(bv*[vcodec^=?avc]/bv*[vcodec^=?mp4])+ba/(b[vcodec^=?avc]/b[vcodec^=?mp4])" + } + if (qstr.isNotEmpty()) + qstr += "/bv*+ba/b" + + prefs.ytdlFormat.set(qstr) + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libpython.so b/app/src/main/jniLibs/arm64-v8a/libpython.so new file mode 100644 index 000000000..48cc7e0aa Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libpython.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libpython_bin.so b/app/src/main/jniLibs/arm64-v8a/libpython_bin.so new file mode 100644 index 000000000..48cc7e0aa Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libpython_bin.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libpython.so b/app/src/main/jniLibs/armeabi-v7a/libpython.so new file mode 100644 index 000000000..b694473fa Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libpython.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so b/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so new file mode 100644 index 000000000..b694473fa Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so differ diff --git a/app/src/main/jniLibs/x86/libpython.so b/app/src/main/jniLibs/x86/libpython.so new file mode 100644 index 000000000..7465a136f Binary files /dev/null and b/app/src/main/jniLibs/x86/libpython.so differ diff --git a/app/src/main/jniLibs/x86/libpython_bin.so b/app/src/main/jniLibs/x86/libpython_bin.so new file mode 100644 index 000000000..7465a136f Binary files /dev/null and b/app/src/main/jniLibs/x86/libpython_bin.so differ diff --git a/app/src/main/jniLibs/x86_64/libpython.so b/app/src/main/jniLibs/x86_64/libpython.so new file mode 100644 index 000000000..83f5df1e7 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libpython.so differ diff --git a/app/src/main/jniLibs/x86_64/libpython_bin.so b/app/src/main/jniLibs/x86_64/libpython_bin.so new file mode 100644 index 000000000..83f5df1e7 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libpython_bin.so differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bb3e363e..261cd94a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,9 @@ %1$d B %.1f KB %.1f MB + You need to install yt-dlp to play this link (Settings > Advanced > yt-dlp Manager) + Any + Prefer H.264 (AVC) + Increases compatibility on older devices but may limit quality + Preferred Quality