meltdl 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. meltdl-1.0.1/LICENSE +21 -0
  2. meltdl-1.0.1/PKG-INFO +140 -0
  3. meltdl-1.0.1/README.md +110 -0
  4. meltdl-1.0.1/components/__init__.py +0 -0
  5. meltdl-1.0.1/components/downloader.py +335 -0
  6. meltdl-1.0.1/components/remuxer.py +138 -0
  7. meltdl-1.0.1/components/subcleaner.py +95 -0
  8. meltdl-1.0.1/config/__init__.py +0 -0
  9. meltdl-1.0.1/config/config.json +39 -0
  10. meltdl-1.0.1/main.py +188 -0
  11. meltdl-1.0.1/meltdl.egg-info/PKG-INFO +140 -0
  12. meltdl-1.0.1/meltdl.egg-info/SOURCES.txt +37 -0
  13. meltdl-1.0.1/meltdl.egg-info/dependency_links.txt +1 -0
  14. meltdl-1.0.1/meltdl.egg-info/entry_points.txt +2 -0
  15. meltdl-1.0.1/meltdl.egg-info/requires.txt +3 -0
  16. meltdl-1.0.1/meltdl.egg-info/top_level.txt +6 -0
  17. meltdl-1.0.1/mother_script.py +614 -0
  18. meltdl-1.0.1/pyproject.toml +53 -0
  19. meltdl-1.0.1/setup.cfg +4 -0
  20. meltdl-1.0.1/sounds/__init__.py +0 -0
  21. meltdl-1.0.1/sounds/aborting.wav +0 -0
  22. meltdl-1.0.1/sounds/analyze_complete.wav +0 -0
  23. meltdl-1.0.1/sounds/batch_start.wav +0 -0
  24. meltdl-1.0.1/sounds/completion.wav +0 -0
  25. meltdl-1.0.1/sounds/download_error.wav +0 -0
  26. meltdl-1.0.1/sounds/fatal_error.wav +0 -0
  27. meltdl-1.0.1/tests/test_archive.py +36 -0
  28. meltdl-1.0.1/tests/test_config.py +55 -0
  29. meltdl-1.0.1/tests/test_downloader.py +43 -0
  30. meltdl-1.0.1/tests/test_failed.py +27 -0
  31. meltdl-1.0.1/tests/test_mother_script.py +82 -0
  32. meltdl-1.0.1/tests/test_subcleaner.py +71 -0
  33. meltdl-1.0.1/utils/__init__.py +0 -0
  34. meltdl-1.0.1/utils/archive.py +19 -0
  35. meltdl-1.0.1/utils/cli.py +182 -0
  36. meltdl-1.0.1/utils/config.py +154 -0
  37. meltdl-1.0.1/utils/failed.py +19 -0
  38. meltdl-1.0.1/utils/logger.py +24 -0
  39. meltdl-1.0.1/utils/notification.py +79 -0
meltdl-1.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MelT
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
meltdl-1.0.1/PKG-INFO ADDED
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: meltdl
3
+ Version: 1.0.1
4
+ Summary: Modern YouTube downloader CLI with subtitle cleaning, parallel downloads, SponsorBlock, and sound notifications
5
+ Author: Agaroth0x1e
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Agaroth0x1e/melt
8
+ Project-URL: Source, https://github.com/Agaroth0x1e/melt
9
+ Project-URL: BugTracker, https://github.com/Agaroth0x1e/melt/issues
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Multimedia :: Video
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: rich>=13.0.0
27
+ Requires-Dist: yt-dlp>=2023.0.0
28
+ Requires-Dist: mutagen>=1.47.0
29
+ Dynamic: license-file
30
+
31
+ # MelT — Modern YouTube Downloader CLI
32
+
33
+ Download video, audio, and subtitles from YouTube with a modern CLI. Supports playlists, parallel downloads, thumbnail/subtitle embedding, SponsorBlock, and more.
34
+
35
+ ## Quick Start
36
+
37
+ **Download from Releases (recommended):**
38
+ ```
39
+ https://github.com/Agaroth0x1e/melt/releases
40
+ ```
41
+ - `melt.exe` — Windows (standalone, ffmpeg bundled)
42
+ - `melt` — Linux (standalone, ffmpeg bundled)
43
+
44
+ **Install via pip:**
45
+ ```
46
+ pip install https://github.com/Agaroth0x1e/melt/releases/download/v1.0.1/melt-1.0.1-py3-none-any.whl
47
+ ```
48
+
49
+ **From source:**
50
+ ```
51
+ pip install -r requirements.txt
52
+ python main.py
53
+ ```
54
+
55
+ **Termux (Android):**
56
+ See [BUILD.md#termux-android](BUILD.md#termux-android) for full setup guide.
57
+
58
+ Pre-built binaries bundle ffmpeg. Source mode requires ffmpeg in PATH (`scoop install ffmpeg` / `brew install ffmpeg` / `sudo apt install ffmpeg`).
59
+
60
+ ## Features
61
+
62
+ - Single video & playlist downloads
63
+ - Multi-URL support (paste space-separated URLs)
64
+ - Batch file loading (`@file.txt`)
65
+ - Video (mp4) or Audio (m4a) with thumbnail embedding
66
+ - Subtitle download with roll-up caption cleaning
67
+ - Subtitle embedding into video/audio
68
+ - Parallel downloads (configurable threads)
69
+ - SponsorBlock integration (auto-skip sponsored segments)
70
+ - Dry-run mode to preview before downloading
71
+ - Download queue persistence (resume crashes with `--resume`)
72
+ - Rate limiting, cookies file support, archive dedup
73
+ - Standalone .exe with bundled ffmpeg
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ python main.py # Interactive mode
79
+ python main.py --resume # Resume interrupted queue
80
+ python main.py --help # Show help
81
+ python main.py --version # Show version
82
+ ```
83
+
84
+ ### Multi-URL
85
+
86
+ ```
87
+ Enter YouTube URL: https://youtu.be/A https://youtube.com/playlist?list=XYZ
88
+ ```
89
+
90
+ ### Batch file
91
+
92
+ ```
93
+ Enter YouTube URL: @videos.txt
94
+ ```
95
+
96
+ File format (one URL per line, `#` for comments):
97
+ ```
98
+ https://youtu.be/A
99
+ https://youtube.com/playlist?list=XYZ
100
+ # This line is ignored
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ Edit `config/config.json`:
106
+
107
+ | Key | Default | Description |
108
+ |-----|---------|-------------|
109
+ | `max_threads` | `3` | Parallel downloads |
110
+ | `default_format` | `"video"` | `"video"` or `"audio"` |
111
+ | `clear_temp` | `true` | Delete temp files after download |
112
+ | `numbering` | `false` | Prepend index to filenames |
113
+ | `duplicate_action` | `"skip"` | `"skip"`, `"overwrite"`, or `"keep"` |
114
+ | `sponsorblock` | `true` | Skip sponsored segments |
115
+ | `dry_run` | `false` | Preview without downloading |
116
+ | `exit_on_complete` | `false` | Exit after download (false = loop back) |
117
+ | `reverse_playlist` | `false` | Process newest-first |
118
+ | `rate_limit` | `""` | e.g. `"5M"`, empty = unlimited |
119
+ | `cookies_file` | `""` | Path to cookies.txt |
120
+ | `playlist_folder_template` | `"%(playlist_title)s"` | Subfolder naming for playlists |
121
+ | `timeout_seconds` | `5` | Seconds before auto-confirm prompts |
122
+ | `video.preferred_format` | `"mp4"` | `"mp4"`, `"mkv"`, or `"webm"` |
123
+ | `video.quality_priority` | `["480","360","720"]` | Preferred resolutions |
124
+ | `video.preferred_codec` | `"h264"` | `"h264"`, `"h265"`, or `"vp9"` |
125
+ | `audio.preferred_format` | `"m4a"` | `"m4a"`, `"mp3"`, or `"opus"` |
126
+ | `audio.quality_priority` | `["128","192","264"]` | Preferred bitrates |
127
+ | `audio.default_quality` | `128` | Fallback bitrate |
128
+ | `subtitle.prefer_human` | `true` | Prefer human subs over auto |
129
+ | `subtitle.language` | `"en"` | Language code |
130
+ | `subtitle.preferred_format` | `"srt"` | `"srt"`, `"vtt"`, or `"ass"` |
131
+
132
+ ## Build from Source
133
+
134
+ See [BUILD.md](BUILD.md) for platform-specific build instructions.
135
+
136
+ ## Requirements
137
+
138
+ - Python 3.8+
139
+ - ffmpeg (bundled in .exe, system install for source)
140
+ - Node.js (optional, 2x yt-dlp speed)
meltdl-1.0.1/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # MelT — Modern YouTube Downloader CLI
2
+
3
+ Download video, audio, and subtitles from YouTube with a modern CLI. Supports playlists, parallel downloads, thumbnail/subtitle embedding, SponsorBlock, and more.
4
+
5
+ ## Quick Start
6
+
7
+ **Download from Releases (recommended):**
8
+ ```
9
+ https://github.com/Agaroth0x1e/melt/releases
10
+ ```
11
+ - `melt.exe` — Windows (standalone, ffmpeg bundled)
12
+ - `melt` — Linux (standalone, ffmpeg bundled)
13
+
14
+ **Install via pip:**
15
+ ```
16
+ pip install https://github.com/Agaroth0x1e/melt/releases/download/v1.0.1/melt-1.0.1-py3-none-any.whl
17
+ ```
18
+
19
+ **From source:**
20
+ ```
21
+ pip install -r requirements.txt
22
+ python main.py
23
+ ```
24
+
25
+ **Termux (Android):**
26
+ See [BUILD.md#termux-android](BUILD.md#termux-android) for full setup guide.
27
+
28
+ Pre-built binaries bundle ffmpeg. Source mode requires ffmpeg in PATH (`scoop install ffmpeg` / `brew install ffmpeg` / `sudo apt install ffmpeg`).
29
+
30
+ ## Features
31
+
32
+ - Single video & playlist downloads
33
+ - Multi-URL support (paste space-separated URLs)
34
+ - Batch file loading (`@file.txt`)
35
+ - Video (mp4) or Audio (m4a) with thumbnail embedding
36
+ - Subtitle download with roll-up caption cleaning
37
+ - Subtitle embedding into video/audio
38
+ - Parallel downloads (configurable threads)
39
+ - SponsorBlock integration (auto-skip sponsored segments)
40
+ - Dry-run mode to preview before downloading
41
+ - Download queue persistence (resume crashes with `--resume`)
42
+ - Rate limiting, cookies file support, archive dedup
43
+ - Standalone .exe with bundled ffmpeg
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ python main.py # Interactive mode
49
+ python main.py --resume # Resume interrupted queue
50
+ python main.py --help # Show help
51
+ python main.py --version # Show version
52
+ ```
53
+
54
+ ### Multi-URL
55
+
56
+ ```
57
+ Enter YouTube URL: https://youtu.be/A https://youtube.com/playlist?list=XYZ
58
+ ```
59
+
60
+ ### Batch file
61
+
62
+ ```
63
+ Enter YouTube URL: @videos.txt
64
+ ```
65
+
66
+ File format (one URL per line, `#` for comments):
67
+ ```
68
+ https://youtu.be/A
69
+ https://youtube.com/playlist?list=XYZ
70
+ # This line is ignored
71
+ ```
72
+
73
+ ## Configuration
74
+
75
+ Edit `config/config.json`:
76
+
77
+ | Key | Default | Description |
78
+ |-----|---------|-------------|
79
+ | `max_threads` | `3` | Parallel downloads |
80
+ | `default_format` | `"video"` | `"video"` or `"audio"` |
81
+ | `clear_temp` | `true` | Delete temp files after download |
82
+ | `numbering` | `false` | Prepend index to filenames |
83
+ | `duplicate_action` | `"skip"` | `"skip"`, `"overwrite"`, or `"keep"` |
84
+ | `sponsorblock` | `true` | Skip sponsored segments |
85
+ | `dry_run` | `false` | Preview without downloading |
86
+ | `exit_on_complete` | `false` | Exit after download (false = loop back) |
87
+ | `reverse_playlist` | `false` | Process newest-first |
88
+ | `rate_limit` | `""` | e.g. `"5M"`, empty = unlimited |
89
+ | `cookies_file` | `""` | Path to cookies.txt |
90
+ | `playlist_folder_template` | `"%(playlist_title)s"` | Subfolder naming for playlists |
91
+ | `timeout_seconds` | `5` | Seconds before auto-confirm prompts |
92
+ | `video.preferred_format` | `"mp4"` | `"mp4"`, `"mkv"`, or `"webm"` |
93
+ | `video.quality_priority` | `["480","360","720"]` | Preferred resolutions |
94
+ | `video.preferred_codec` | `"h264"` | `"h264"`, `"h265"`, or `"vp9"` |
95
+ | `audio.preferred_format` | `"m4a"` | `"m4a"`, `"mp3"`, or `"opus"` |
96
+ | `audio.quality_priority` | `["128","192","264"]` | Preferred bitrates |
97
+ | `audio.default_quality` | `128` | Fallback bitrate |
98
+ | `subtitle.prefer_human` | `true` | Prefer human subs over auto |
99
+ | `subtitle.language` | `"en"` | Language code |
100
+ | `subtitle.preferred_format` | `"srt"` | `"srt"`, `"vtt"`, or `"ass"` |
101
+
102
+ ## Build from Source
103
+
104
+ See [BUILD.md](BUILD.md) for platform-specific build instructions.
105
+
106
+ ## Requirements
107
+
108
+ - Python 3.8+
109
+ - ffmpeg (bundled in .exe, system install for source)
110
+ - Node.js (optional, 2x yt-dlp speed)
File without changes
@@ -0,0 +1,335 @@
1
+ import os
2
+ import sys
3
+ import urllib.parse
4
+ import yt_dlp
5
+
6
+ class YtdlpLogger:
7
+ def debug(self, msg): pass
8
+ def info(self, msg): pass
9
+ def warning(self, msg): pass
10
+ def error(self, msg): pass
11
+
12
+
13
+ class Downloader:
14
+ def __init__(self, config, logger):
15
+ self.config = config
16
+ self.logger = logger
17
+ self._info_cache = {}
18
+ self._flat_cache = {}
19
+
20
+ def _resolve(self, path):
21
+ return path if os.path.isabs(path) else self.config.resolve_path(path)
22
+
23
+ def _media_extensions(self):
24
+ return {'.mp4', '.mkv', '.webm', '.m4a', '.mp3', '.mka', '.opus', '.ogg', '.wav', '.aac', '.flac'}
25
+
26
+ def _find_media_file(self, directory):
27
+ candidates = []
28
+ for f in os.listdir(directory):
29
+ ext = os.path.splitext(f)[1].lower()
30
+ if ext in self._media_extensions():
31
+ candidates.append(os.path.join(directory, f))
32
+ if candidates:
33
+ return max(candidates, key=os.path.getmtime)
34
+ return None
35
+
36
+ def _find_subtitle_file(self, directory, lang='en', prefer_human=True):
37
+ files = sorted(os.listdir(directory), reverse=True)
38
+ candidates = {'human': [], 'auto': []}
39
+ for f in files:
40
+ lower = f.lower()
41
+ if '.clean.' in lower:
42
+ continue
43
+ is_sub = lower.endswith(f'.{lang}.srt') or lower.endswith(f'.{lang}.vtt') \
44
+ or lower.endswith('.srt') or lower.endswith('.vtt')
45
+ if not is_sub:
46
+ continue
47
+ if lang in lower and lower.count(lang) == 1:
48
+ candidates['human'].append(f)
49
+ else:
50
+ candidates['auto'].append(f)
51
+ pick = candidates['human'] if prefer_human and candidates['human'] else candidates['auto']
52
+ return os.path.join(directory, pick[0]) if pick else None
53
+
54
+ def get_video_info(self, url, force=False):
55
+ if not force and url in self._info_cache:
56
+ return self._info_cache[url]
57
+ info_opts = {
58
+ 'quiet': True,
59
+ 'no_warnings': True,
60
+ 'extract_flat': False,
61
+ 'skip_download': True,
62
+ }
63
+ with yt_dlp.YoutubeDL(info_opts) as ydl:
64
+ try:
65
+ info = ydl.extract_info(url, download=False)
66
+ self._info_cache[url] = info
67
+ return info
68
+ except Exception as e:
69
+ raise RuntimeError(f"Failed to fetch video info: {e}")
70
+
71
+ def is_playlist(self, url):
72
+ if url in self._flat_cache:
73
+ return bool(self._flat_cache[url].get('entries'))
74
+ try:
75
+ flat_opts = {'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}
76
+ with yt_dlp.YoutubeDL(flat_opts) as ydl:
77
+ info = ydl.extract_info(url, download=False)
78
+ self._flat_cache[url] = info
79
+ return bool(info.get('entries'))
80
+ except Exception:
81
+ return False
82
+
83
+ def get_playlist_entries(self, url):
84
+ if url in self._flat_cache:
85
+ info = self._flat_cache[url]
86
+ else:
87
+ flat_opts = {'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}
88
+ with yt_dlp.YoutubeDL(flat_opts) as ydl:
89
+ info = ydl.extract_info(url, download=False)
90
+ self._flat_cache[url] = info
91
+
92
+ if not info.get('entries') and self._has_list_param(url):
93
+ pl_url = f"https://www.youtube.com/playlist?list={urllib.parse.parse_qs(urllib.parse.urlparse(url).query)['list'][0]}"
94
+ if pl_url not in self._flat_cache:
95
+ try:
96
+ flat_opts = {'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}
97
+ with yt_dlp.YoutubeDL(flat_opts) as ydl:
98
+ pl_info = ydl.extract_info(pl_url, download=False)
99
+ self._flat_cache[pl_url] = pl_info
100
+ if pl_info.get('entries'):
101
+ info = pl_info
102
+ self._flat_cache[url] = pl_info
103
+ except Exception:
104
+ pass
105
+
106
+ entries = []
107
+ title = info.get('title', 'Unknown')
108
+ pid = info.get('id', '')
109
+ if 'entries' in info and info['entries']:
110
+ for entry in info['entries']:
111
+ if entry:
112
+ entries.append({
113
+ 'id': entry.get('id', ''),
114
+ 'title': entry.get('title', 'Unknown'),
115
+ 'url': f"https://www.youtube.com/watch?v={entry.get('id', '')}",
116
+ 'playlist': title,
117
+ 'playlist_id': pid,
118
+ })
119
+ return entries, title, pid
120
+
121
+ @staticmethod
122
+ def _has_list_param(url):
123
+ try:
124
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
125
+ return bool(qs.get('list'))
126
+ except Exception:
127
+ return False
128
+
129
+ def inspect_url(self, url):
130
+ if url not in self._flat_cache:
131
+ flat_opts = {'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}
132
+ with yt_dlp.YoutubeDL(flat_opts) as ydl:
133
+ info = ydl.extract_info(url, download=False)
134
+ self._flat_cache[url] = info
135
+ info = self._flat_cache[url]
136
+ entries = info.get('entries')
137
+ is_pl = bool(entries) or self._has_list_param(url)
138
+ return {
139
+ 'url': url,
140
+ 'is_playlist': is_pl,
141
+ 'title': info.get('title', 'Unknown'),
142
+ 'entry_count': len(entries) if entries else 0,
143
+ 'id': info.get('id', ''),
144
+ }
145
+
146
+ def get_single_entry(self, url):
147
+ info = self.get_video_info(url)
148
+ return {
149
+ 'id': info.get('id', ''),
150
+ 'title': info.get('title', 'Unknown'),
151
+ 'url': url,
152
+ 'playlist': None,
153
+ 'playlist_id': None,
154
+ }
155
+
156
+ def get_single_entry_fast(self, url):
157
+ if url in self._flat_cache:
158
+ info = self._flat_cache[url]
159
+ else:
160
+ flat_opts = {'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}
161
+ with yt_dlp.YoutubeDL(flat_opts) as ydl:
162
+ info = ydl.extract_info(url, download=False)
163
+ self._flat_cache[url] = info
164
+ return {
165
+ 'id': info.get('id', ''),
166
+ 'title': info.get('title', 'Unknown'),
167
+ 'url': url,
168
+ 'playlist': None,
169
+ 'playlist_id': None,
170
+ }
171
+
172
+ def _video_format_string(self):
173
+ ext = self.config['video']['preferred_format']
174
+ codec = self.config['video'].get('preferred_codec', 'h264')
175
+ vcodec = {'h264': 'avc1', 'h265': 'hevc', 'vp9': 'vp9'}.get(codec, 'avc1')
176
+ priorities = self.config['video'].get('quality_priority', ['480', '360', '720'])
177
+
178
+ formats = []
179
+ for q in priorities:
180
+ formats.append(f"bestvideo[ext={ext}][vcodec^={vcodec}][height<={q}]+bestaudio[ext=m4a]")
181
+ formats.append(f"bestvideo[ext={ext}][vcodec^={vcodec}][height<=1080][height>360]+bestaudio[ext=m4a]")
182
+ formats.append(f"bestvideo[ext={ext}]+bestaudio[ext=m4a]")
183
+ formats.append("best")
184
+
185
+ return "/".join(formats)
186
+
187
+ def _audio_format_string(self):
188
+ ext = self.config['audio']['preferred_format']
189
+ priorities = self.config['audio'].get('quality_priority', ['128', '192', '264'])
190
+
191
+ formats = []
192
+ for q in priorities:
193
+ q_int = int(q)
194
+ formats.append(f"bestaudio[ext={ext}][abr<={q_int + 16}][abr>={max(0, q_int - 16)}]")
195
+ formats.append(f"bestaudio[ext={ext}]")
196
+ formats.append("bestaudio")
197
+
198
+ return "/".join(formats)
199
+
200
+ def _progress_hook(self, d):
201
+ if d['status'] == 'downloading':
202
+ pct = d.get('_percent_str', '').strip()
203
+ speed = d.get('_speed_str', '')
204
+ eta = d.get('_eta_str', '')
205
+ total = d.get('_total_bytes_str', '')
206
+ parts = [p for p in [pct, speed, eta, total] if p]
207
+ if parts:
208
+ sys.stderr.write('\r[download] ' + ' • '.join(parts) + ' ')
209
+ sys.stderr.flush()
210
+
211
+ def _cookies_opts(self):
212
+ cf = self.config['general'].get('cookies_file', '')
213
+ if cf:
214
+ cf_path = self._resolve(cf)
215
+ if os.path.exists(cf_path):
216
+ return {'cookiefile': cf_path}
217
+ return {}
218
+
219
+ def _rate_limit_opts(self):
220
+ val = self.config['general'].get('rate_limit', '')
221
+ if val:
222
+ return {'limit_rate': val}
223
+ return {}
224
+
225
+ def _sponsorblock_opts(self):
226
+ if self.config['general'].get('sponsorblock', True):
227
+ return {'sponsorblock_mark': 'all'}
228
+ return {}
229
+
230
+ def _ffmpeg_location_opts(self):
231
+ path = os.environ.get('FFMPEG_PATH', '')
232
+ if path:
233
+ return {'ffmpeg_location': path}
234
+ return {}
235
+
236
+ def download_video(self, entry, job_dir):
237
+ work_dir = self._resolve(job_dir)
238
+ os.makedirs(work_dir, exist_ok=True)
239
+
240
+ tmpl = self.config['general']['filename_template']
241
+ outtmpl = os.path.join(work_dir, tmpl)
242
+
243
+ ydl_opts = {
244
+ 'format': self._video_format_string(),
245
+ 'outtmpl': outtmpl,
246
+ 'quiet': True,
247
+ 'no_warnings': True,
248
+ 'noprogress': True,
249
+ 'writethumbnail': True,
250
+ 'merge_output_format': 'mp4',
251
+ 'skip_download': False,
252
+ 'progress_hooks': [self._progress_hook],
253
+ 'postprocessors': [{'key': 'EmbedThumbnail'}],
254
+ }
255
+ ydl_opts.update(self._cookies_opts())
256
+ ydl_opts.update(self._rate_limit_opts())
257
+ ydl_opts.update(self._sponsorblock_opts())
258
+ ydl_opts.update(self._ffmpeg_location_opts())
259
+
260
+ self.logger.info(f"Downloading video: {entry['title']}")
261
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
262
+ try:
263
+ ydl.download([entry['url']])
264
+ return self._find_media_file(work_dir)
265
+ except Exception as e:
266
+ raise RuntimeError(f"Download failed: {e}")
267
+
268
+ def download_audio(self, entry, job_dir):
269
+ work_dir = self._resolve(job_dir)
270
+ os.makedirs(work_dir, exist_ok=True)
271
+
272
+ tmpl = self.config['general']['filename_template']
273
+ outtmpl = os.path.join(work_dir, tmpl)
274
+ quality = self.config['audio']['default_quality']
275
+
276
+ ydl_opts = {
277
+ 'format': self._audio_format_string(),
278
+ 'outtmpl': outtmpl,
279
+ 'quiet': True,
280
+ 'no_warnings': True,
281
+ 'noprogress': True,
282
+ 'writethumbnail': True,
283
+ 'postprocessors': [{
284
+ 'key': 'FFmpegExtractAudio',
285
+ 'preferredcodec': 'm4a',
286
+ 'preferredquality': str(quality),
287
+ }, {
288
+ 'key': 'EmbedThumbnail',
289
+ }],
290
+ 'skip_download': False,
291
+ 'progress_hooks': [self._progress_hook],
292
+ }
293
+
294
+ ydl_opts.update(self._cookies_opts())
295
+ ydl_opts.update(self._rate_limit_opts())
296
+ ydl_opts.update(self._sponsorblock_opts())
297
+ ydl_opts.update(self._ffmpeg_location_opts())
298
+
299
+ self.logger.info(f"Downloading audio: {entry['title']}")
300
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
301
+ try:
302
+ ydl.download([entry['url']])
303
+ return self._find_media_file(work_dir)
304
+ except Exception as e:
305
+ raise RuntimeError(f"Download failed: {e}")
306
+
307
+ def download_subtitles(self, entry, job_dir):
308
+ work_dir = self._resolve(job_dir)
309
+ os.makedirs(work_dir, exist_ok=True)
310
+
311
+ tmpl = self.config['general']['filename_template']
312
+ outtmpl = os.path.join(work_dir, tmpl)
313
+ lang = self.config['subtitle']['language']
314
+ prefer_human = self.config['subtitle'].get('prefer_human', True)
315
+ sub_fmt = self.config['subtitle'].get('preferred_format', 'srt')
316
+
317
+ ydl_opts = {
318
+ 'outtmpl': outtmpl,
319
+ 'quiet': True,
320
+ 'no_warnings': True,
321
+ 'skip_download': True,
322
+ 'writesubtitles': True,
323
+ 'writeautomaticsub': True,
324
+ 'subtitleslangs': [lang],
325
+ 'subtitlesformat': sub_fmt,
326
+ 'logger': YtdlpLogger(),
327
+ }
328
+
329
+ ydl_opts.update(self._cookies_opts())
330
+ ydl_opts.update(self._ffmpeg_location_opts())
331
+
332
+ self.logger.info(f"Downloading subtitles for: {entry['title']}")
333
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
334
+ ydl.download([entry['url']])
335
+ return self._find_subtitle_file(work_dir, lang, prefer_human)