156 lines
5.6 KiB
Dart
156 lines
5.6 KiB
Dart
/// 다운로드 종류
|
|
enum DownloadKind {
|
|
/// YouTube 등 동영상 사이트 → yt-dlp
|
|
video,
|
|
|
|
/// 마그넷 링크 · .torrent 주소 → aria2
|
|
torrent,
|
|
}
|
|
|
|
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);
|
|
|
|
/// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로)
|
|
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;
|
|
const YtDlpProgress(this.percent, this.speed, this.eta);
|
|
}
|
|
|
|
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;
|
|
return YtDlpProgress(pct == null ? null : pct / 100, clean(part(1)), clean(part(2)));
|
|
}
|
|
|
|
/// 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 label;
|
|
const YtContainer(this.label);
|
|
|
|
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 label;
|
|
final int? height;
|
|
const YtQuality(this.label, this.height);
|
|
}
|
|
|
|
/// yt-dlp 형식 인수
|
|
List<String> ytDlpFormatArgs(YtContainer c, YtQuality q) {
|
|
final res = q.height == null ? '' : 'res:${q.height},';
|
|
return switch (c) {
|
|
YtContainer.mp4 => ['-S', '${res}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 로봇 확인 대응). cookies.txt 가 있으면 우선.
|
|
List<String> ytDlpCookieArgs({String browser = '', String file = ''}) {
|
|
if (file.trim().isNotEmpty) return ['--cookies', file.trim()];
|
|
if (browser.trim().isNotEmpty) return ['--cookies-from-browser', browser.trim()];
|
|
return const [];
|
|
}
|
|
|
|
/// yt-dlp 오류를 알기 쉬운 안내로
|
|
String friendlyYtDlpError(String err) {
|
|
if (err.contains('Sign in to confirm') || err.contains('not a bot')) {
|
|
return 'YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 '
|
|
'브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.';
|
|
}
|
|
if (err.contains('Private video') || err.contains('members-only')) return '비공개 · 회원 전용 영상입니다.';
|
|
if (err.contains('Video unavailable')) return '볼 수 없는 영상입니다 (삭제 · 지역 제한).';
|
|
if (err.contains('could not find') && err.contains('cookies')) {
|
|
return '브라우저 쿠키를 읽지 못했습니다. 브라우저를 닫고 다시 시도하거나 cookies.txt 를 지정하세요.';
|
|
}
|
|
return err;
|
|
}
|
|
|
|
/// 바이트 → 읽기 쉬운 크기
|
|
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]}';
|
|
}
|