Android 다운로드: 앱 안 브라우저 [다운로드] · 다운로드 목록 (yt-dlp · aria2c)

- youtubedl-android 0.18.1 (Python · yt-dlp · ffmpeg · aria2c) 을 넣고, MainActivity 가 처음 켤 때 풀어
  실행 경로 · 환경 변수를 알려 준다. 받을 때마다 새 yt-dlp 확인 (못 받아도 계속)
- YtDlpBackend · Aria2Backend: 실행 명령 앞 인수 · 환경 변수 · JS 실행기 (deno / quickjs) 를 받게 해 Windows 와 같은 코드로 실행
- 다운로드 엔진은 처음 쓸 때 준비 (앱 시작을 늦추지 않음), 실패하면 다음에 다시 시도
- 기본 위치: 내장 저장소 Download/JJ_MKVMaker. 환경 설정에 다운로드 위치 · YouTube 형식 · 화질 · 쿠키 · 동시 다운로드 수 표시
- R8 keep 규칙 (youtubedl-android 의 Jackson · 압축 풀기 클래스)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-02 21:36:15 +09:00
parent 04cd32e0eb
commit 0160c7591d
20 changed files with 284 additions and 21 deletions

View File

@ -53,6 +53,7 @@ android {
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
}
}
@ -76,3 +77,11 @@ kotlin {
flutter {
source = "../.."
}
// 다운로드 (앱 안 브라우저 · 다운로드 목록): Android 용 yt-dlp (Python 포함) · ffmpeg (영상 · 음성 합치기) · aria2c (토렌트)
// 실행 파일을 앱에 넣고 처음 켤 때 풀어 둔다. 실행은 Dart (YtDlpBackend · Aria2Backend) 가 Windows 와 같은 방식으로 한다.
dependencies {
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
}

7
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,7 @@
# youtubedl-android (Android 다운로드): 코드 축소(R8)가 Jackson · 압축 풀기 클래스를 지우거나 이름을 바꾸면
# YoutubeDL 객체를 만들 때 ExceptionInInitializerError 가 난다 (youtubedl-android README 의 권장 규칙)
-keep class com.yausername.** { *; }
-keep class com.fasterxml.jackson.** { *; }
-keep class org.apache.commons.compress.archivers.zip.** { *; }
-dontwarn com.fasterxml.jackson.**
-dontwarn org.apache.commons.**

View File

@ -7,11 +7,15 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import com.yausername.aria2c.Aria2c
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@ -23,11 +27,61 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
"downloadToolsUpdate" -> inBackground(result) {
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
}
else -> result.notImplemented()
}
}
}
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
Thread {
try {
val value = work()
runOnUiThread { result.success(value) }
} catch (e: Throwable) {
android.util.Log.e("jj_mkvmaker", "download tools", e)
val cause = generateSequence(e) { it.cause }.last()
runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) }
}
}.start()
}
/// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다.
/// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다.
private fun downloadToolsInit(): Map<String, Any> {
YoutubeDL.init(applicationContext)
FFmpeg.init(applicationContext)
Aria2c.init(applicationContext)
val bin = applicationInfo.nativeLibraryDir
val base = File(noBackupFilesDir, YoutubeDL.baseName)
val packages = File(base, "packages")
val python = File(packages, "python").absolutePath
val ffmpeg = File(packages, "ffmpeg").absolutePath
val aria2c = File(packages, "aria2c").absolutePath
val cert = "$python/usr/etc/tls/cert.pem"
return mapOf(
"python" to "$bin/libpython.so",
"ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath,
"ffmpeg" to "$bin/libffmpeg.so",
"quickjs" to "$bin/libqjs.so",
"aria2c" to "$bin/libaria2c.so",
"cert" to cert,
"version" to (YoutubeDL.versionName(applicationContext) ?: ""),
"env" to mapOf(
"LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib",
"SSL_CERT_FILE" to cert,
"PATH" to (System.getenv("PATH") ?: "") + ":" + bin,
"PYTHONHOME" to "$python/usr",
"HOME" to "$python/usr",
"TMPDIR" to cacheDir.absolutePath,
),
)
}
private fun hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()

View File

@ -203,6 +203,8 @@ class AppSettings {
/// 다운로드 기본 위치: 프로그램 폴더 (쓸 수 없으면 사용자 다운로드 폴더)
String resolvedDownloadRoot() {
if (downloadRoot != null && downloadRoot!.isNotEmpty) return downloadRoot!;
// Android: 프로그램 폴더가 없으므로 내장 저장소의 Download\JJ_MKVMaker
if (Platform.isAndroid) return '/storage/emulated/0/Download/JJ_MKVMaker';
final appDir = p.dirname(Platform.resolvedExecutable);
try {
final probe = File(p.join(appDir, '.jj_write_test'));

View File

@ -14,6 +14,7 @@ import 'app/download_manager.dart';
import 'app/settings.dart';
import 'core/app_update.dart';
import 'core/playlist.dart';
import 'platform/android/android_download_tools.dart';
import 'platform/android/android_storage.dart';
import 'platform/windows/app_paths.dart';
import 'platform/windows/cef_runtime.dart';
@ -329,10 +330,36 @@ Future<void> runAndroid(String dataDir) async {
final messengerKey = GlobalKey<ScaffoldMessengerState>();
AndroidStorageService.navigatorKey = navigatorKey;
// 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다.
AndroidDownloadTools.log = controller.note;
final downloads = DownloadManager(
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings,
);
downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text('다운로드 추가: ${t.source}'))));
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async {
if (!controller.settings.addFinishedDownloads) return;
final files = DownloadManager.videoFilesOf(t);
if (files.isEmpty) return;
final n = await controller.addDownloaded(files);
downloads.dropFromList(t);
if (n > 0) {
messengerKey.currentState?.showSnackBar(SnackBar(content: Text('동영상 목록에 추가: ${t.title}')));
}
});
downloads.setAutoAdd = (on) async {
await controller.updateSettings((x) => x.addFinishedDownloads = on);
downloads.refresh();
};
downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (controller.settings.startScreen == 'browser') {
navigatorKey.currentState
?.push(MaterialPageRoute<void>(builder: (_) => BrowserPage(c: controller, bookmarks: bookmarks)));
navigatorKey.currentState?.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));
}
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다
if (!await AndroidAccess.hasAllFiles()) {
@ -346,11 +373,13 @@ Future<void> runAndroid(String dataDir) async {
runApp(JjCapCutApp(
controller: controller,
downloads: downloads,
bookmarks: bookmarks,
navigatorKey: navigatorKey,
messengerKey: messengerKey,
onExit: () async {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return;
if (controller.busy && ctx != null && ctx.mounted) {
final ok = await showDialog<bool>(
context: ctx,
@ -366,6 +395,7 @@ Future<void> runAndroid(String dataDir) async {
if (ok != true) return;
}
controller.cancel();
await downloads.shutdown();
controller.note('── 정상 종료 ──');
await services.shell.quit();
},

View File

@ -0,0 +1,139 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../../app/settings.dart';
import '../../core/download_detect.dart';
import '../../services/downloader.dart';
import '../windows/aria2_backend.dart';
import '../windows/ytdlp_backend.dart';
/// Android 의 다운로드 프로그램 (youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c).
/// MainActivity.kt 의 "downloadToolsInit" 이 처음 켤 때 풀고, 직접 실행할 경로 · 환경 변수를 알려 준다.
/// 실행은 Windows 와 같은 [YtDlpBackend] · [Aria2Backend] 가 한다.
class AndroidDownloadTools {
static const _ch = MethodChannel('jj_mkvmaker/android');
final String python, ytdlp, ffmpeg, quickjs, aria2c, cert, version;
final Map<String, String> environment;
AndroidDownloadTools._(Map<Object?, Object?> m)
: python = m['python'] as String,
ytdlp = m['ytdlp'] as String,
ffmpeg = m['ffmpeg'] as String,
quickjs = m['quickjs'] as String,
aria2c = m['aria2c'] as String,
cert = m['cert'] as String,
version = m['version'] as String? ?? '',
environment = (m['env'] as Map).cast<String, String>();
static Future<AndroidDownloadTools>? _ready;
/// 작업 기록에 남길 곳 (main 에서 정함)
static void Function(String)? log;
/// 한 번만 준비한다 (처음 켤 때 Python 등을 푸느라 몇 초 걸림).
/// YouTube 가 바뀌면 오래된 yt-dlp 는 받지 못하므로 준비할 때 새 yt-dlp 가 있으면 받는다 (못 받아도 계속).
static Future<AndroidDownloadTools> ready() {
final f = _ready ??= _prepare();
// 실패하면 (저장 공간 부족 등) 다음에 다시 시도
f.then(
(_) {},
onError: (Object _) {
if (identical(_ready, f)) _ready = null;
},
);
return f;
}
static Future<AndroidDownloadTools> _prepare() async {
final m = await _ch.invokeMethod<Map<Object?, Object?>>('downloadToolsInit');
try {
final r = await _ch.invokeMethod<String>('downloadToolsUpdate').timeout(const Duration(seconds: 30));
if (r == 'DONE') log?.call('yt-dlp 를 새 버전으로 바꿨습니다');
} catch (e) {
log?.call('yt-dlp 업데이트 확인 실패 (지금 버전으로 받습니다): $e');
}
final tools = AndroidDownloadTools._(m!);
log?.call('다운로드 준비됨: yt-dlp ${tools.version}');
return tools;
}
/// 다운로드 엔진: yt-dlp (동영상 사이트) · aria2 (토렌트). 실제 엔진은 처음 쓸 때 준비된다.
static List<DownloadBackend> backends(AppSettings Function() s) => [
_Deferred(DownloadKind.video, () async {
final t = await ready();
return YtDlpBackend(
ytdlp: t.python,
prefixArgs: [t.ytdlp, '--no-cache-dir'],
environment: t.environment,
// --ffmpeg-location 은 파일 경로도 받는다 (libffmpeg.so = ffmpeg 실행 파일)
ffmpegDir: t.ffmpeg,
jsRuntime: 'quickjs:${t.quickjs}',
formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality),
cookieArgs: () => ytDlpCookieArgs(
browser: s().ytCookiesBrowser,
file: s().ytCookiesFile,
internalCookieFile: s().internalCookieFile,
),
fallbackCookieArgs: () =>
ytDlpCookieArgs(browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile),
);
}),
_Deferred(DownloadKind.torrent, () async {
final t = await ready();
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}']);
}),
];
}
/// 처음 쓸 때 엔진을 준비하는 다운로드 엔진 (앱 시작을 늦추지 않도록)
class _Deferred implements DownloadBackend {
@override
final DownloadKind kind;
final Future<DownloadBackend> Function() _create;
Future<DownloadBackend>? _backend;
_Deferred(this.kind, this._create);
Future<DownloadBackend> get _b {
final f = _backend ??= _create();
// 실패하면 다음에 다시 시도
f.then(
(_) {},
onError: (Object _) {
if (identical(_backend, f)) _backend = null;
},
);
return f;
}
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => (await _b).expandPlaylist(url);
@override
Future<void> start(DownloadTask t, void Function() changed) async {
final DownloadBackend b;
try {
b = await _b;
} catch (e) {
t
..state = DownloadState.failed
..error = '다운로드 프로그램을 준비할 수 없습니다: $e';
changed();
return;
}
return b.start(t, changed);
}
@override
Future<void> pause(DownloadTask t) async => (await _b).pause(t);
@override
Future<void> cancel(DownloadTask t) async => (await _b).cancel(t);
@override
Future<void> shutdown() async {
if (_backend != null) await (await _backend!).shutdown();
}
}

View File

@ -22,7 +22,12 @@ class Aria2Backend implements DownloadBackend {
Timer? _poll;
final _tasks = <String, (DownloadTask, void Function())>{};
Aria2Backend({String? aria2c}) : aria2c = aria2c ?? locateTool('aria2c');
/// 추가 환경 변수 · 인수 (Android: 라이브러리 위치 · 인증서 파일)
final Map<String, String> environment;
final List<String> extraArgs;
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const []})
: aria2c = aria2c ?? locateTool('aria2c');
@override
DownloadKind get kind => DownloadKind.torrent;
@ -48,7 +53,8 @@ class Aria2Backend implements DownloadBackend {
'--bt-save-metadata=false', '--follow-torrent=mem',
'--console-log-level=warn', '--summary-interval=0',
'--file-allocation=none', '--auto-file-renaming=false',
]);
...extraArgs,
], environment: environment.isEmpty ? null : environment);
_daemon!.stdout.drain<void>();
_daemon!.stderr.drain<void>();
unawaited(_daemon!.exitCode.then((_) => _daemon = null));

View File

@ -27,8 +27,14 @@ class YtDlpBackend implements DownloadBackend {
/// yt-dlp 가 영상·음성을 합칠 때 쓰는 ffmpeg 폴더
final String? ffmpegDir;
/// YouTube 추출용 JavaScript 실행기 (없으면 일부 화질이 빠질 수 있음)
final String? deno;
/// YouTube 추출용 JavaScript 실행기 "deno:경로" · "quickjs:경로" (없으면 일부 화질이 빠질 수 있음)
final String? jsRuntime;
/// yt-dlp 앞에 붙일 인수 (Android: python 으로 yt-dlp 스크립트를 실행 → [ytdlp] 는 python, 여기에 스크립트 경로)
final List<String> prefixArgs;
/// 추가 환경 변수 (Android: Python · ffmpeg 라이브러리 위치 등)
final Map<String, String> environment;
final _procs = <String, Process>{};
final _stopping = <String>{};
@ -53,16 +59,22 @@ class YtDlpBackend implements DownloadBackend {
String? ytdlp,
this.ffmpegDir,
String? deno,
String? jsRuntime,
this.prefixArgs = const [],
this.environment = const {},
List<String> Function()? formatArgs,
List<String> Function()? cookieArgs,
List<String> Function()? fallbackCookieArgs,
}) : ytdlp = ytdlp ?? locateTool('yt-dlp'),
deno = deno ?? _existing(locateTool('deno')),
jsRuntime = jsRuntime ?? _denoRuntime(deno ?? _existing(locateTool('deno'))),
formatArgs = formatArgs ?? (() => ytDlpFormatArgs(YtContainer.mp4, YtQuality.best)),
cookieArgs = cookieArgs ?? (() => const []),
fallbackCookieArgs = fallbackCookieArgs ?? (() => const []);
static const _env = {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'};
static const _pyEnv = {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'};
Map<String, String> get _env => {..._pyEnv, ...environment};
static String? _denoRuntime(String? deno) => deno == null ? null : 'deno:$deno';
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async {
@ -72,8 +84,9 @@ class YtDlpBackend implements DownloadBackend {
Future<ProcessResult> read(List<String> cookies) => Process.run(
ytdlp,
[
...prefixArgs,
'--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8',
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
if (jsRuntime != null) ...['--js-runtimes', jsRuntime!],
...cookies,
'https://www.youtube.com/playlist?list=$listId',
],
@ -130,7 +143,7 @@ class YtDlpBackend implements DownloadBackend {
...formatArgs(),
..._cookiesFor(t),
if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!],
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
if (jsRuntime != null) ...['--js-runtimes', jsRuntime!],
t.source,
];
@ -145,7 +158,7 @@ class YtDlpBackend implements DownloadBackend {
final Process proc;
try {
proc = await Process.start(ytdlp, argsFor(t), environment: _env);
proc = await Process.start(ytdlp, [...prefixArgs, ...argsFor(t)], environment: _env);
} on ProcessException catch (e) {
t
..state = DownloadState.failed

View File

@ -9,6 +9,7 @@ import '../core/download_detect.dart';
import '../platform/common/nllb_translator.dart';
import '../platform/common/opensubtitles_provider.dart';
import '../platform/common/whisper_recognizer.dart';
import '../platform/android/android_download_tools.dart';
import '../platform/android/android_shell.dart';
import '../platform/android/android_storage.dart';
import '../platform/android/ffmpeg_kit_media_tool.dart';
@ -119,7 +120,7 @@ class PlatformServices {
);
}
if (Platform.isAndroid) {
// 다운로드 (yt-dlp · aria2) · 업데이트 · 탐색기 연결은 데스크톱 전용
// 업데이트 · 탐색기 연결은 데스크톱 전용. 다운로드는 앱에 넣은 yt-dlp · aria2c (youtubedl-android)
MediaKitPreviewPlayer.ensureInitialized();
return PlatformServices(
mediaTool: FfmpegKitMediaTool(),
@ -128,6 +129,7 @@ class PlatformServices {
createMediaPlayer: MediaKitFullPlayer.new,
createRecognizer: WhisperRecognizer.new,
createTranslator: NllbTranslator.new,
createDownloadBackends: AndroidDownloadTools.backends,
shell: AndroidShell(),
createSubtitleProviders: (s) => [
OpenSubtitlesProvider(

View File

@ -371,25 +371,24 @@ class _SettingsPageState extends State<SettingsPage> {
},
onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null),
),
if (desk) ...[
_folderTile(
title: '다운로드 (jj_yt-dlp · jj_aria2)',
value: s.downloadRoot,
defaultText: '프로그램 폴더 (기본): ${s.resolvedDownloadRoot()}',
defaultText: '${desk ? '프로그램 폴더' : '내장 저장소'} (기본): ${s.resolvedDownloadRoot()}',
onPick: () async {
final d = await _pickDir('다운로드 위치');
if (d != null) await c.updateSettings((x) => x.downloadRoot = d);
},
onReset: () => c.updateSettings((x) => x.downloadRoot = null),
),
_section('다운로드'),
if (desk) _section('다운로드'),
if (desk)
SwitchListTile(
value: s.clipboardWatch,
onChanged: (v) => c.updateSettings((x) => x.clipboardWatch = v),
title: const Text('복사(Ctrl+C)한 주소 자동 다운로드'),
subtitle: const Text('YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)'),
),
],
_section('재생'),
ListTile(
title: const Text('동영상 하나를 재생할 때'),
@ -438,7 +437,6 @@ class _SettingsPageState extends State<SettingsPage> {
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())),
_textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다',
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true),
if (desk) ...[
ListTile(
title: const Text('YouTube 받을 형식'),
subtitle: const Text('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장'),
@ -479,6 +477,8 @@ class _SettingsPageState extends State<SettingsPage> {
items: [
const DropdownMenuItem(value: '', child: Text('사용 안 함')),
const DropdownMenuItem(value: internalBrowserCookies, child: Text('앱 안 브라우저 (권장)')),
// PC 브라우저의 쿠키는 Windows 에서만
if (desk)
for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera'])
DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))),
const DropdownMenuItem(value: '*file', child: Text('cookies.txt 파일…')),
@ -511,7 +511,6 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n),
),
),
],
_section('변환'),
ListTile(
title: const Text('동시 MKV 변환 수'),

View File

@ -0,0 +1,2 @@
#Fri Oct 02 20:36:01 KST 2026
gradle.version=8.14.3