diff --git a/app/src/main/assets/yt_dlp_plugins/burnsubs/yt_dlp_plugins/postprocessor/burnsubs.py b/app/src/main/assets/yt_dlp_plugins/burnsubs/yt_dlp_plugins/postprocessor/burnsubs.py new file mode 100644 index 00000000..9620cc75 --- /dev/null +++ b/app/src/main/assets/yt_dlp_plugins/burnsubs/yt_dlp_plugins/postprocessor/burnsubs.py @@ -0,0 +1,35 @@ +import os +from yt_dlp.postprocessor.ffmpeg import FFmpegPostProcessor + +class BurnSubsPP(FFmpegPostProcessor): + def run(self, info): + subs = info.get('requested_subtitles') or {} + + # Find subtitle language key and filepath + sub_info = next(((lang, s.get('filepath')) for lang, s in subs.items() if s.get('filepath')), None) + + if not sub_info or not sub_info[1]: + self.to_screen('No subtitle file found; skipping burn-in') + return [], info + + lang, sub = sub_info + path = info['filepath'] + + # Terminal updates + self.to_screen(f'Found subtitles [{lang}]: {os.path.basename(sub)}') + self.to_screen(f'Burning subtitles into {os.path.basename(path)}...') + + temp = f'{path}.burn.mp4' + # libass parses this as a filter-graph arg: escape ':' and '\' + esc = sub.replace('\\', '/').replace(':', r'\:') + + self.run_ffmpeg_multiple_files( + [path], temp, + ['-vf', f"subtitles='{esc}':fontsdir=/system/fonts", '-c:a', 'copy'] + ) + + os.replace(temp, path) + info['filepath'] = path + + self.to_screen(f'Successfully completed subtitle burn-in.') + return [], info \ No newline at end of file diff --git a/app/src/main/assets/yt_dlp_plugins/compatiblerecorder/yt_dlp_plugins/postprocessor/compatiblerecoder.py b/app/src/main/assets/yt_dlp_plugins/compatiblerecorder/yt_dlp_plugins/postprocessor/compatiblerecoder.py new file mode 100644 index 00000000..319e030f --- /dev/null +++ b/app/src/main/assets/yt_dlp_plugins/compatiblerecorder/yt_dlp_plugins/postprocessor/compatiblerecoder.py @@ -0,0 +1,65 @@ +from pathlib import Path + +from yt_dlp.postprocessor.ffmpeg import FFmpegPostProcessor + + +class CompatibleRecoderPP(FFmpegPostProcessor): + def run(self, info): + filepath = Path(info['filepath']) + + vcodec = (info.get('vcodec') or '').lower() + acodec = (info.get('acodec') or '').lower() + + video_ok = vcodec.startswith(('h264', 'avc')) + audio_ok = acodec.startswith(('aac', 'mp4a')) + + if video_ok and audio_ok: + self.to_screen(f'Not converting {filepath}') + return [], info + + if not filepath.exists(): + self.report_error(f'{filepath} does not exist!') + return [], info + + new_filepath = filepath.with_suffix('.new.mp4') + + if new_filepath.exists(): + self.report_error(f'{new_filepath} already exists') + return [], info + + # Copy streams that already satisfy the codec requirements. + video_codec = 'copy' if video_ok else 'libx264' + audio_codec = 'copy' if audio_ok else 'aac' + + self.to_screen( + f'Re-encoding {filepath} ' + f'(video: {"copy" if video_ok else "H.264"}, ' + f'audio: {"copy" if audio_ok else "AAC"})') + + try: + self.run_ffmpeg( + str(filepath), + str(new_filepath), + [ + '-c:v', video_codec, + '-c:a', audio_codec, + '-f', 'mp4', + ], + ) + except Exception as e: + self.report_error(f'ffmpeg failed: {e}') + return [], info + + filepath.unlink() + + final_filepath = filepath.with_suffix('.mp4') + + if final_filepath.exists(): + self.report_error(f'{final_filepath} already exists') + return [], info + + new_filepath.replace(final_filepath) + + info['filepath'] = str(final_filepath) + + return [], info \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt b/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt index d04d9a80..13cdd9fe 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.apache.commons.io.FileUtils import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.util.Collections @@ -180,6 +181,9 @@ object RuntimeManager { // NODE_OPTIONS = "--require ${optionsFile.absolutePath}" // } + val ytdlpPluginsFolder = File(FileUtil.getBundledYTDLPPluginsPath(appContext)) + copyAssetFolder(appContext, "yt_dlp_plugins", ytdlpPluginsFolder) + initialized = true } catch (e: Exception) { e.printStackTrace() @@ -192,6 +196,38 @@ object RuntimeManager { } } + fun copyAssetFolder(context: Context, assetFolderPath: String, targetFolder: File) { + val assetManager = context.assets + val files = assetManager.list(assetFolderPath) ?: return + + if (!targetFolder.exists()) { + targetFolder.mkdirs() + } + + for (file in files) { + val assetPath = if (assetFolderPath.isEmpty()) file else "$assetFolderPath/$file" + val subFiles = assetManager.list(assetPath) + + if (subFiles != null && subFiles.isNotEmpty()) { + // It's a directory -> recursively copy it + copyAssetFolder(context, assetPath, File(targetFolder, file)) + } else { + // It's a file -> write to internal storage + copyAssetFile(context, assetPath, File(targetFolder, file)) + } + } + } + + private fun copyAssetFile(context: Context, assetPath: String, outFile: File) { + if (outFile.exists()) return // Skip if already copied + + context.assets.open(assetPath).use { input -> + FileOutputStream(outFile).use { output -> + input.copyTo(output) + } + } + } + fun reInit(context: Context) { synchronized(initLock) { initialized = false diff --git a/app/src/main/java/com/deniscerri/ytdl/database/models/VideoPreferences.kt b/app/src/main/java/com/deniscerri/ytdl/database/models/VideoPreferences.kt index 1e3f99bb..621dd581 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/models/VideoPreferences.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/models/VideoPreferences.kt @@ -11,6 +11,7 @@ data class VideoPreferences ( var sponsorBlockFilters: ArrayList = arrayListOf(), var writeSubs: Boolean = false, var writeAutoSubs: Boolean = false, + var burnSubs: Boolean = false, var subsLanguages: String = ".*-orig", var audioFormatIDs : ArrayList = arrayListOf(), var removeAudio: Boolean = false, diff --git a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt index c5237803..4ba722fc 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt @@ -263,6 +263,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel val embedSubs = sharedPreferences.getBoolean("embed_subtitles", false) val saveSubs = sharedPreferences.getBoolean("write_subtitles", false) val saveAutoSubs = sharedPreferences.getBoolean("write_auto_subtitles", false) + val burnSubs = sharedPreferences.getBoolean("burn_subtitles", false) val recodeVideo = sharedPreferences.getBoolean("recode_video", false) val compatibilityMode = sharedPreferences.getBoolean("compatible_video", false) val removeAudio = sharedPreferences.getBoolean("remove_audio", false) @@ -310,6 +311,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel ArrayList(sponsorblock), saveSubs, saveAutoSubs, + burnSubs, subsLanguages, audioFormatIDs = preferredAudioFormats, recodeVideo = recodeVideo, @@ -454,6 +456,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel val embedSubs = sharedPreferences.getBoolean("embed_subtitles", false) val saveSubs = sharedPreferences.getBoolean("write_subtitles", false) val saveAutoSubs = sharedPreferences.getBoolean("write_auto_subtitles", false) + val burnSubs = sharedPreferences.getBoolean("burn_subtitles", false) val recodeVideo = sharedPreferences.getBoolean("recode_video", false) val removeAudio = sharedPreferences.getBoolean("remove_audio", false) val compatibilityMode = sharedPreferences.getBoolean("compatible_video", false) @@ -511,6 +514,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel sponsorBlockFilters = ArrayList(sponsorblock), writeSubs = saveSubs, writeAutoSubs = saveAutoSubs, + burnSubs = burnSubs, subsLanguages = subsLanguages, recodeVideo = recodeVideo, compatibilityMode = compatibilityMode, diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadMultipleBottomSheetDialog.kt b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadMultipleBottomSheetDialog.kt index 7bf32369..622303a4 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadMultipleBottomSheetDialog.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadMultipleBottomSheetDialog.kt @@ -715,6 +715,10 @@ class DownloadMultipleBottomSheetDialog : BottomSheetDialogFragment(), Configure items.forEach { it.videoPreferences.writeAutoSubs = checked } CoroutineScope(Dispatchers.IO).launch { items.forEach { downloadViewModel.updateDownload(it) } } }, + burnSubtitlesClicked = { checked -> + items.forEach { it.videoPreferences.burnSubs = checked } + CoroutineScope(Dispatchers.IO).launch { items.forEach { downloadViewModel.updateDownload(it) } } + }, subtitleLanguagesSet = {value -> items.forEach { it.videoPreferences.subsLanguages = value } CoroutineScope(Dispatchers.IO).launch { items.forEach { downloadViewModel.updateDownload(it) } } diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadVideoFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadVideoFragment.kt index d7e79cb0..c0df5cc1 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadVideoFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadVideoFragment.kt @@ -477,6 +477,9 @@ class DownloadVideoFragment(private var resultItem: ResultItem? = null, private saveAutoSubtitlesClicked = { downloadItem.videoPreferences.writeAutoSubs = it }, + burnSubtitlesClicked = { + downloadItem.videoPreferences.burnSubs = it + }, subtitleLanguagesSet = { downloadItem.videoPreferences.subsLanguages = it }, diff --git a/app/src/main/java/com/deniscerri/ytdl/util/FileUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/FileUtil.kt index 9b8e915e..6098987b 100644 --- a/app/src/main/java/com/deniscerri/ytdl/util/FileUtil.kt +++ b/app/src/main/java/com/deniscerri/ytdl/util/FileUtil.kt @@ -389,6 +389,10 @@ object FileUtil { return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis/TERMINAL_CACHE" } + fun getBundledYTDLPPluginsPath(context: Context) : String { + return File(context.filesDir, "yt_dlp_plugins").absolutePath + } + fun getCookieFile(context : Context, ignoreIfExists: Boolean = false, path: (path: String) -> Unit){ val cookiesFile = File(context.cacheDir, "cookies.txt") if (ignoreIfExists || cookiesFile.exists()){ diff --git a/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt index ca96d400..062e323c 100644 --- a/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt +++ b/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt @@ -1333,6 +1333,7 @@ object UiUtil { filenameTemplateSet: (String) -> Unit, saveSubtitlesClicked: (Boolean) -> Unit, saveAutoSubtitlesClicked: (Boolean) -> Unit, + burnSubtitlesClicked: (Boolean) -> Unit, subtitleLanguagesSet: (String) -> Unit, removeAudioClicked: (Boolean) -> Unit, recodeVideoClicked: (Boolean) -> Unit, @@ -1456,6 +1457,7 @@ object UiUtil { firstItem.videoPreferences.embedSubs, firstItem.videoPreferences.writeSubs, firstItem.videoPreferences.writeAutoSubs, + firstItem.videoPreferences.burnSubs, ) adjustSubtitles.createBadge(context, count.filter { it }.size) } @@ -1466,6 +1468,7 @@ object UiUtil { val embedSubs = adjustSubtitleView.findViewById(R.id.embed_subtitles) val saveSubtitles = adjustSubtitleView.findViewById(R.id.save_subs) val saveAutoSubtitles = adjustSubtitleView.findViewById(R.id.save_auto_subs) + val burnSubtitles = adjustSubtitleView.findViewById(R.id.burn_subs) val subtitleLanguages = adjustSubtitleView.findViewById(R.id.subtitle_languages) val subtitleLanguagesDescription = adjustSubtitleView.findViewById(R.id.subtitle) subtitleLanguagesDescription.text = items.first().videoPreferences.subsLanguages @@ -1474,6 +1477,7 @@ object UiUtil { embedSubs!!.isChecked = items.all { it.videoPreferences.embedSubs } embedSubs.setOnClickListener { subtitleLanguages.isClickable = embedSubs.isChecked || saveSubtitles.isChecked + burnSubtitles.isEnabled = embedSubs.isChecked embedSubsClicked(embedSubs.isChecked) items.forEach { it.videoPreferences.embedSubs = embedSubs.isChecked } @@ -1490,10 +1494,22 @@ object UiUtil { subtitleLanguages.visibility = View.VISIBLE } + if (items.all { it.videoPreferences.burnSubs}) { + burnSubtitles.isChecked = true + } + saveSubtitles.setOnCheckedChangeListener { _, _ -> subtitleLanguages.isClickable = embedSubs.isChecked || saveSubtitles.isChecked || saveAutoSubtitles.isChecked saveSubtitlesClicked(saveSubtitles.isChecked) items.forEach { it.videoPreferences.writeSubs = saveSubtitles.isChecked } + + burnSubtitles.isEnabled = saveSubtitles.isChecked || saveAutoSubtitles.isChecked + if (!burnSubtitles.isEnabled) { + burnSubtitles.isChecked = false + burnSubtitlesClicked(burnSubtitles.isChecked) + items.forEach { it.videoPreferences.burnSubs = burnSubtitles.isChecked } + } + calculateAdjustSubtitlesChangeCount() } @@ -1501,6 +1517,14 @@ object UiUtil { subtitleLanguages.isClickable = embedSubs.isChecked || saveSubtitles.isChecked || saveAutoSubtitles.isChecked saveAutoSubtitlesClicked(saveAutoSubtitles.isChecked) items.forEach { it.videoPreferences.writeAutoSubs = saveAutoSubtitles.isChecked } + + burnSubtitles.isEnabled = saveSubtitles.isChecked || saveAutoSubtitles.isChecked + if (!burnSubtitles.isEnabled) { + burnSubtitles.isChecked = false + burnSubtitlesClicked(burnSubtitles.isChecked) + items.forEach { it.videoPreferences.burnSubs = burnSubtitles.isChecked } + } + calculateAdjustSubtitlesChangeCount() } @@ -1519,6 +1543,13 @@ object UiUtil { } } + burnSubtitles.isEnabled = saveSubtitles.isChecked || saveAutoSubtitles.isChecked + burnSubtitles.setOnCheckedChangeListener { _, _ -> + burnSubtitlesClicked(burnSubtitles.isChecked) + items.forEach { it.videoPreferences.burnSubs = burnSubtitles.isChecked } + calculateAdjustSubtitlesChangeCount() + } + val adjustSubtitleDialog = MaterialAlertDialogBuilder(context) .setTitle(context.getString(R.string.subtitles)) .setView(adjustSubtitleView) diff --git a/app/src/main/java/com/deniscerri/ytdl/util/extractors/ytdlp/YTDLPUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/extractors/ytdlp/YTDLPUtil.kt index 6fba79b0..91608943 100644 --- a/app/src/main/java/com/deniscerri/ytdl/util/extractors/ytdlp/YTDLPUtil.kt +++ b/app/src/main/java/com/deniscerri/ytdl/util/extractors/ytdlp/YTDLPUtil.kt @@ -929,6 +929,7 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co } request.addOption("--newline") + request.addOption("--plugin-dirs", FileUtil.getBundledYTDLPPluginsPath(context)) val metadataCommands = StringJoiner(" ") @@ -1387,9 +1388,9 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co var vCodecPref = context.getStringArray(R.array.video_codec_values_ytdlp)[vCodecPrefIndex] if (downloadItem.videoPreferences.compatibilityMode) { - request.addOption("--recode-video", "mp4") - request.addOption("--merge-output-format", "mp4/mkv") - request.addOption("--ppa", "VideoConvertor+ffmpeg_o:-profile:v baseline") + request.addOption("--merge-output-format", "mp4") + request.addOption("--remux-video", "mp4") + request.addOption("--use-postprocessor", "CompatibleRecoder:when=after_move") vCodecPref = "h264" aCodecPref = "aac" } @@ -1570,6 +1571,8 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co request.addOption("--write-auto-subs") } + + if (downloadItem.videoPreferences.embedSubs) { if (sharedPreferences.getBoolean("no_keep_subs", false) && (downloadItem.videoPreferences.writeSubs || downloadItem.videoPreferences.writeAutoSubs)) { request.addOption("--compat-options", "no-keep-subs") @@ -1587,6 +1590,10 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co request.addOption("--sub-langs", downloadItem.videoPreferences.subsLanguages.ifEmpty { ".*-orig" }) } + if (downloadItem.videoPreferences.burnSubs) { + request.addOption("--use-postprocessor", "BurnSubs:when=after_move") + } + var copyStream = "" if (downloadItem.videoPreferences.cropValues.isNotBlank()){ diff --git a/app/src/main/res/layout/subtitle_download_preferences_dialog.xml b/app/src/main/res/layout/subtitle_download_preferences_dialog.xml index dbde0165..8b7bfb38 100644 --- a/app/src/main/res/layout/subtitle_download_preferences_dialog.xml +++ b/app/src/main/res/layout/subtitle_download_preferences_dialog.xml @@ -27,6 +27,14 @@ android:text="@string/save_auto_subs" android:layout_height="wrap_content" /> + + Exit Packages are helper applications YTDLnis uses to help with yt-dlp commands. Some of them are already bundled, but you can install newer versions that are published without the need to update the app. The installed apk\'s replace the bundled version that the app uses. In order to use the BgUtils POT Provider, the app needs to download Brainicism/bgutil-ytdlp-pot-provider repo from github and initiate the installation. This might take a while to finish. Proceed now? + Burn subtitles Every hourEvery %d hours Every dayEvery %d days Every weekEvery %d weeks