392 lines
17 KiB
Dart
392 lines
17 KiB
Dart
import 'package:path/path.dart' as p;
|
|
|
|
import '../l10n/tr.dart';
|
|
/// 다운로드 종류
|
|
enum DownloadKind {
|
|
/// YouTube 등 동영상 사이트 → yt-dlp
|
|
video,
|
|
|
|
/// 마그넷 링크 · .torrent 주소 · 직접 파일 주소 (184: zip 등 - 원래 이름 그대로) → aria2
|
|
torrent,
|
|
}
|
|
|
|
/// 184: 동영상 · 소리가 아닌 직접 파일 주소 (…/100MB.zip 등). 동영상 받기 (yt-dlp) 로 받으면 다 받은 뒤
|
|
/// 후처리에서 "실패" 가 되고 이름도 "100MB [100MB].unknown_video" 가 됐다 - 일반 파일 받기 (aria2) 로 원래 이름 그대로 받는다.
|
|
/// 클립보드 자동 감지에는 쓰지 않는다 (복사만 해도 받기 시작하지 않게) - 사용자가 넣은 주소 · 브라우저 [다운로드] 에만
|
|
bool isDirectFileUrl(String url) => _directFile.hasMatch(url.trim());
|
|
|
|
final _directFile = RegExp(
|
|
r'^https?://[^\s<>"?#]+\.(?:zip|7z|rar|gz|tgz|tar|xz|bz2|zst|exe|msi|dmg|pkg|deb|rpm|appimage|iso|img|apk|apks|xapk|'
|
|
r'pdf|epub|docx?|xlsx?|pptx?|hwpx?|odt|ods|txt|csv|json|xml|bin|dat|ttf|otf|jpg|jpeg|png|gif|webp|bmp|svg|psd)'
|
|
r'(?:[?#][^\s<>"]*)?$',
|
|
caseSensitive: false);
|
|
|
|
class DetectedLink {
|
|
final DownloadKind kind;
|
|
final String url;
|
|
const DetectedLink(this.kind, this.url);
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is DetectedLink && other.url == url && other.kind == kind;
|
|
@override
|
|
int get hashCode => Object.hash(kind, url);
|
|
}
|
|
|
|
final _youtube = RegExp(
|
|
r'https?://(?:www\.|m\.|music\.)?(?:youtube\.com/(?:watch\?[^\s]*v=|shorts/|live/|playlist\?[^\s]*list=|embed/)|youtu\.be/)[^\s<>"]+',
|
|
caseSensitive: false);
|
|
final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false);
|
|
final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false);
|
|
|
|
/// 마그넷 주소의 이름 (dn=…, 주소 글 풀기 · + 는 빈칸). 마그넷이 아니거나 이름이 없으면 null.
|
|
/// 관리자 지시 (10/20, 28 확인): 메타데이터를 받기 전에도 줄 제목에 이 이름을 보인다 (마그넷 주소 전체 대신)
|
|
String? magnetName(String url) {
|
|
if (!url.toLowerCase().startsWith('magnet:?')) return null;
|
|
for (final part in url.substring(8).split('&')) {
|
|
final i = part.indexOf('=');
|
|
if (i <= 0 || part.substring(0, i).toLowerCase() != 'dn') continue;
|
|
String name;
|
|
try {
|
|
name = Uri.decodeQueryComponent(part.substring(i + 1));
|
|
} catch (_) {
|
|
name = part.substring(i + 1); // 잘못 적힌 % 는 그대로
|
|
}
|
|
// 줄바꿈 · 제어 문자는 빈칸으로 (한 줄 제목)
|
|
name = name.replaceAll(RegExp(r'[\x00-\x1f\x7f]+'), ' ').trim();
|
|
if (name.isNotEmpty) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로)
|
|
List<DetectedLink> detectLinks(String text) {
|
|
final found = <int, DetectedLink>{};
|
|
for (final m in _youtube.allMatches(text)) {
|
|
found[m.start] = DetectedLink(DownloadKind.video, _trimUrl(m[0]!));
|
|
}
|
|
for (final m in _magnet.allMatches(text)) {
|
|
found[m.start] = DetectedLink(DownloadKind.torrent, m[0]!);
|
|
}
|
|
for (final m in _torrentUrl.allMatches(text)) {
|
|
found[m.start] = DetectedLink(DownloadKind.torrent, _trimUrl(m[0]!));
|
|
}
|
|
final keys = found.keys.toList()..sort();
|
|
final out = <DetectedLink>[];
|
|
for (final k in keys) {
|
|
if (!out.contains(found[k])) out.add(found[k]!);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// 문장 끝의 괄호·마침표 등 제거
|
|
String _trimUrl(String u) => u.replaceFirst(RegExp(r'[).,;!?\]]+$'), '');
|
|
|
|
/// yt-dlp 진행 줄 (--progress-template "download:[JJ]%(progress._percent_str)s|...")
|
|
class YtDlpProgress {
|
|
final double? percent;
|
|
final String speed;
|
|
final String eta;
|
|
|
|
/// 지금 받는 파일(영상 또는 음성)의 받은 크기 · 전체 크기 (바이트, 모르면 null)
|
|
final int? received;
|
|
final int? total;
|
|
const YtDlpProgress(this.percent, this.speed, this.eta, {this.received, this.total});
|
|
}
|
|
|
|
/// yt-dlp 는 영상과 음성을 따로 받은 뒤 합친다. 파일마다 0→100% 가 되므로
|
|
/// 그대로 보여 주면 "100% 가 됐다가 다시 처음부터" 처럼 보인다. 여러 파일을 하나의 진행으로 합친다.
|
|
class YtDlpTotals {
|
|
/// 받기 전에 yt-dlp 가 알려 준 전체 크기 (영상 + 음성, 모르면 null)
|
|
int? expected;
|
|
int _base = 0, _cur = 0;
|
|
int? _curTotal;
|
|
double? _percent;
|
|
|
|
/// 다음 파일을 받기 시작 (지금까지 받은 것은 끝난 것으로 더한다)
|
|
void nextFile() {
|
|
_base += _curTotal ?? _cur;
|
|
_cur = 0;
|
|
_curTotal = null;
|
|
_percent = null;
|
|
}
|
|
|
|
void update(YtDlpProgress p) {
|
|
if (p.received != null) _cur = p.received!;
|
|
if (p.total != null) _curTotal = p.total;
|
|
_percent = p.percent ?? _percent;
|
|
}
|
|
|
|
int get received => _base + _cur;
|
|
|
|
/// 전체 크기: 미리 안 크기와 지금까지 본 크기 중 큰 쪽 (추정치가 작게 나오는 경우 대비)
|
|
int? get total {
|
|
final seen = _curTotal == null ? null : _base + _curTotal!;
|
|
if (expected == null) return seen;
|
|
return seen != null && seen > expected! ? seen : expected;
|
|
}
|
|
|
|
/// 전체 진행률. 크기를 전혀 모르면 yt-dlp 의 퍼센트를 그대로.
|
|
double? get progress {
|
|
final t = total;
|
|
if (t == null || t <= 0) return _percent;
|
|
return (received / t).clamp(0.0, 1.0);
|
|
}
|
|
}
|
|
|
|
YtDlpProgress? parseYtDlpProgress(String line) {
|
|
if (!line.startsWith('[JJ]')) return null;
|
|
final parts = line.substring(4).split('|');
|
|
final pct = double.tryParse(parts[0].replaceAll('%', '').trim());
|
|
String part(int i) => i < parts.length ? parts[i].trim() : '';
|
|
String clean(String s) => s == 'NA' || s == 'Unknown' || s.startsWith('Unknown') ? '' : s;
|
|
// 크기는 "12345" 또는 "12345.0", 모르면 "NA"
|
|
int? bytes(int i) => double.tryParse(part(i))?.round();
|
|
return YtDlpProgress(pct == null ? null : pct / 100, clean(part(1)), clean(part(2)),
|
|
received: bytes(3), total: bytes(4));
|
|
}
|
|
|
|
/// YouTube 재생목록 주소인지 (재생목록 ID 반환).
|
|
/// 믹스(RD…) · 좋아요(LL) · 나중에 볼 동영상(WL) 은 제외 (끝없는 목록 · 로그인 필요)
|
|
String? youtubePlaylistId(String url) {
|
|
final m = RegExp(r'[?&]list=([\w-]+)').firstMatch(url);
|
|
if (m == null) return null;
|
|
final id = m[1]!;
|
|
if (id.startsWith('RD') || id == 'LL' || id == 'WL') return null;
|
|
return id;
|
|
}
|
|
|
|
/// 폴더 이름으로 쓸 수 없는 글자 제거
|
|
String safeFolderName(String s) {
|
|
final t = s.replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1F]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
final cut = t.length > 80 ? t.substring(0, 80).trim() : t;
|
|
return cut.replaceAll(RegExp(r'[. ]+$'), '');
|
|
}
|
|
|
|
/// YouTube 받을 형식
|
|
enum YtContainer {
|
|
mp4('MP4 (동영상)'),
|
|
webm('WebM (동영상)'),
|
|
mp3('음성만 MP3'),
|
|
m4a('음성만 M4A');
|
|
|
|
/// 한국어 원문 (번역 사전의 열쇠)
|
|
final String koLabel;
|
|
|
|
/// 화면에 보일 이름 (화면 언어로)
|
|
String get label => tr(koLabel);
|
|
const YtContainer(this.koLabel);
|
|
|
|
bool get audioOnly => this == mp3 || this == m4a;
|
|
}
|
|
|
|
/// YouTube 화질 (세로 픽셀 이하 중 가장 좋은 것)
|
|
enum YtQuality {
|
|
best('최고 화질', null),
|
|
p2160('4K (2160p)', 2160),
|
|
p1440('1440p', 1440),
|
|
p1080('1080p', 1080),
|
|
p720('720p', 720),
|
|
p480('480p', 480),
|
|
p360('360p', 360);
|
|
|
|
/// 한국어 원문 (번역 사전의 열쇠)
|
|
final String koLabel;
|
|
|
|
/// 화면에 보일 이름 (화면 언어로)
|
|
String get label => tr(koLabel);
|
|
final int? height;
|
|
const YtQuality(this.koLabel, this.height);
|
|
}
|
|
|
|
/// yt-dlp 형식 인수
|
|
///
|
|
/// [preferH264]: 같은 크기면 H.264 를 먼저 고른다 (Android: 휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 못 푸는 일이 많아
|
|
/// 재생이 끊기고 배터리를 많이 씀). YouTube 의 H.264 는 1080p 까지라 그보다 큰 화질을 고르면 크기가 먼저다.
|
|
List<String> ytDlpFormatArgs(YtContainer c, YtQuality q, {bool preferH264 = false}) {
|
|
final res = q.height == null ? '' : 'res:${q.height},';
|
|
final codec = preferH264 ? 'vcodec:h264,' : '';
|
|
return switch (c) {
|
|
YtContainer.mp4 => ['-S', '$res${codec}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'],
|
|
YtContainer.webm => ['-S', '${res}ext:webm:webm', '--merge-output-format', 'webm'],
|
|
YtContainer.mp3 => ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'],
|
|
YtContainer.m4a => ['-f', 'ba[ext=m4a]/ba/b', '-x', '--audio-format', 'm4a'],
|
|
};
|
|
}
|
|
|
|
/// yt-dlp 에 넘기는 로그인 쿠키의 사이트 (YouTube · Google · Instagram · X · 네이버 / 치지직 - 28)
|
|
const loginCookieDomains = ['youtube.com', 'google.com', 'instagram.com', 'x.com', 'twitter.com', 'naver.com'];
|
|
|
|
/// 53: 앱 안 브라우저가 로그인 쿠키를 yt-dlp 용 파일로 내보낼지 · 어느 사이트만 (환경 설정 > 다운로드 > YouTube 쿠키)
|
|
class CookieExport {
|
|
/// 쿠키를 쓰지 않으면 (앱 안 브라우저 로그인 사용이 아니면) 파일을 만들지 않는다
|
|
static bool enabled = true;
|
|
static List<String> sites = loginCookieDomains;
|
|
|
|
/// 내보낸 파일 이름 (앱 안 브라우저 데이터 폴더)
|
|
static const fileName = 'cookies_youtube.txt';
|
|
}
|
|
|
|
/// [d] (".x.com" 등) 가 내보낼 사이트 ([CookieExport.sites], 처음은 [loginCookieDomains] 모두) 중 하나이거나
|
|
/// 그 아래인지 (netflix.com 이 x.com 으로 잡히지 않게)
|
|
bool isLoginCookieDomain(String d) {
|
|
final h = (d.startsWith('.') ? d.substring(1) : d).toLowerCase();
|
|
return CookieExport.sites.any((x) => h == x || h.endsWith('.$x'));
|
|
}
|
|
|
|
/// 앱 안 브라우저(내장 Edge) 의 쿠키를 쓰는 값
|
|
const internalBrowserCookies = '*internal';
|
|
|
|
/// yt-dlp 쿠키 인수 (YouTube 로그인 · 로봇 확인 대응). cookies.txt 가 있으면 우선.
|
|
/// 앱 안 브라우저: [internalCookieFile] (브라우저가 내보낸 cookies.txt) 만 쓴다.
|
|
/// 아직 없으면 (앱 안 브라우저를 쓴 적이 없음) 쿠키 없이 받는다 - 프로필 폴더를 직접 읽는 방식은
|
|
/// 브라우저가 켜져 있거나 암호화 방식이 달라 실패하는 일이 많아 쓰지 않는다.
|
|
List<String> ytDlpCookieArgs({
|
|
String browser = '',
|
|
String file = '',
|
|
String internalProfile = '',
|
|
String internalCookieFile = '',
|
|
}) {
|
|
if (file.trim().isNotEmpty) return ['--cookies', file.trim()];
|
|
if (browser == internalBrowserCookies) {
|
|
return internalCookieFile.isEmpty ? const [] : ['--cookies', internalCookieFile];
|
|
}
|
|
if (browser.trim().isNotEmpty) return ['--cookies-from-browser', browser.trim()];
|
|
return const [];
|
|
}
|
|
|
|
/// 쿠키 한 개 (브라우저 → yt-dlp 로 넘기기용)
|
|
class CookieRecord {
|
|
final String name, value, domain, path;
|
|
|
|
/// 만료 시각 (초, 0 = 세션 쿠키)
|
|
final int expires;
|
|
final bool secure, httpOnly;
|
|
const CookieRecord({
|
|
required this.name,
|
|
required this.value,
|
|
required this.domain,
|
|
this.path = '/',
|
|
this.expires = 0,
|
|
this.secure = false,
|
|
this.httpOnly = false,
|
|
});
|
|
}
|
|
|
|
/// yt-dlp 가 읽는 Netscape 형식 cookies.txt
|
|
String toNetscapeCookies(Iterable<CookieRecord> cookies) {
|
|
final sb = StringBuffer('# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n');
|
|
final seen = <String>{};
|
|
for (final c in cookies) {
|
|
if (c.name.isEmpty || c.domain.isEmpty) continue;
|
|
if (!seen.add('${c.domain}|${c.path}|${c.name}')) continue;
|
|
final sub = c.domain.startsWith('.');
|
|
final value = c.value.replaceAll(RegExp(r'[\t\r\n]'), '');
|
|
sb.writeln([
|
|
'${c.httpOnly ? '#HttpOnly_' : ''}${c.domain}',
|
|
sub ? 'TRUE' : 'FALSE',
|
|
c.path.isEmpty ? '/' : c.path,
|
|
c.secure ? 'TRUE' : 'FALSE',
|
|
'${c.expires}',
|
|
c.name,
|
|
value,
|
|
].join('\t'));
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/// yt-dlp 오류를 알기 쉬운 안내로
|
|
/// 쿠키를 읽지 못해 난 오류인지 (브라우저가 켜져 있음 · 암호 해독 실패 · 파일 없음 등).
|
|
/// 이런 오류는 쿠키 없이 다시 받으면 되는 경우가 많다.
|
|
/// 184-2: 동영상 받기 (yt-dlp) 가 동영상이 아닌 파일을 다 받은 뒤 후처리 (FFmpeg) 에서 "동영상이 아님" 으로 실패했는지.
|
|
/// 그때는 받은 파일을 원래 이름으로 남기고 끝 (다 받은 뒤 "실패" 로 두지 않는다)
|
|
bool isNonVideoPostprocessError(String err) =>
|
|
err.contains('Postprocessing') && (err.contains('Invalid data found') || err.contains('Error opening input'));
|
|
|
|
/// 184-2: 그 파일의 원래 이름 - 주소의 물음표 뒤에 파일 이름이 있으면 그것 (download.php?file=a.zip),
|
|
/// 아니면 주소 경로의 끝 (스크립트 이름이 아니면), 그것도 아니면 받은 이름에서 " [id]" · ".unknown_video" 를 뺀 것
|
|
String nonVideoFileName(String url, String downloaded) {
|
|
final u = Uri.tryParse(url.trim());
|
|
bool fileLike(String s) {
|
|
final ext = p.extension(s).toLowerCase();
|
|
return ext.length > 1 && !const ['.php', '.asp', '.aspx', '.jsp', '.cgi', '.html', '.htm', '.do', '.action'].contains(ext);
|
|
}
|
|
|
|
if (u != null) {
|
|
for (final v in u.queryParameters.values) {
|
|
final base = p.basename(v);
|
|
if (fileLike(base)) return base;
|
|
}
|
|
final seg = u.pathSegments.where((s) => s.isNotEmpty).lastOrNull;
|
|
if (seg != null && fileLike(seg)) return seg;
|
|
}
|
|
var name = p.basename(downloaded).replaceFirst(RegExp(r'\.unknown_video$'), '');
|
|
name = name.replaceFirst(RegExp(r' \[[^\]]*\](?=(\.[^.]*)?$)'), '');
|
|
return name.isEmpty ? p.basename(downloaded) : name;
|
|
}
|
|
|
|
bool isCookieReadError(String err) {
|
|
final e = err.toLowerCase();
|
|
return e.contains('cookie') &&
|
|
(e.contains('could not find') ||
|
|
e.contains('could not copy') ||
|
|
e.contains('failed to decrypt') ||
|
|
e.contains('permission denied') ||
|
|
e.contains('database') ||
|
|
e.contains('unsupported browser') ||
|
|
e.contains('no such file'));
|
|
}
|
|
|
|
String friendlyYtDlpError(String err) {
|
|
if (err.contains('Sign in to confirm') || err.contains('not a bot')) {
|
|
return tr('YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 ' '브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.');
|
|
}
|
|
if (err.contains('Private video') || err.contains('members-only')) return tr('비공개 · 회원 전용 영상입니다.');
|
|
if (err.contains('Video unavailable')) return tr('볼 수 없는 영상입니다 (삭제 · 지역 제한).');
|
|
if (isCookieReadError(err)) {
|
|
return tr('브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 "앱 안 브라우저" 를 고르거나, ' '고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).');
|
|
}
|
|
return err;
|
|
}
|
|
|
|
/// 글 속 주소의 서명 · 키가 든 부분 (?… 물음 · #… · 아이디:비밀번호@) 을 가린다 - 화면 · 기록에 서명된 주소를 보이지 않게
|
|
String redactUrlSecrets(String text) => text.replaceAllMapped(
|
|
RegExp(r'''(https?|wss?|ftp|dav)://(?:[^\s/@'"]+@)?([^\s?#'"]+)([?#][^\s'")\]>]*)?''', caseSensitive: false),
|
|
(m) => '${m[1]}://${m[2]}${m[3] == null ? '' : '?…'}');
|
|
|
|
/// 136: 실패한 다운로드의 이유를 사람 말로 (yt-dlp · aria2 원문 → 화면 언어). 원문은 [자세히] 에서 그대로 보인다.
|
|
/// 알아보지 못하면 "받지 못했습니다".
|
|
String friendlyDownloadError(String raw) {
|
|
final known = friendlyYtDlpError(raw);
|
|
if (known != raw) return known;
|
|
final e = raw.toLowerCase();
|
|
bool any(List<String> xs) => xs.any(e.contains);
|
|
if (any(['unsupported url', 'no suitable extractor'])) return tr('지원하지 않는 사이트입니다');
|
|
if (any(['http error 404', ' 404 ', '404 not found', 'resource not found', 'errorcode=3)'])) {
|
|
return tr('주소에 영상이 없습니다 (404)');
|
|
}
|
|
if (any(['http error 403', 'http error 401', '403 forbidden', 'login required', 'requires authentication',
|
|
'use --cookies', 'errorcode=24)'])) {
|
|
return tr('로그인이 필요합니다 (403)');
|
|
}
|
|
if (any(['no space left', 'disk full', 'errorcode=9)'])) return tr('저장 공간이 모자랍니다');
|
|
if (any(['unable to download webpage', 'getaddrinfo', 'name or service not known', 'name resolution',
|
|
'temporary failure', 'timed out', 'timeout', 'connection refused', 'connection reset', 'network is unreachable',
|
|
'no route to host', 'network problem', 'errorcode=1)', 'errorcode=2)', 'errorcode=6)', 'errorcode=19)'])) {
|
|
return tr('연결할 수 없습니다');
|
|
}
|
|
return tr('받지 못했습니다');
|
|
}
|
|
|
|
/// 바이트 → 읽기 쉬운 크기
|
|
String formatBytes(num b) {
|
|
if (b < 1024) return '${b.round()}B';
|
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
var v = b / 1024;
|
|
var i = 0;
|
|
while (v >= 1024 && i < units.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return '${v.toStringAsFixed(v < 10 ? 1 : 0)}${units[i]}';
|
|
}
|