mdl-cli 0.3.0__tar.gz → 0.3.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.
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/CHANGELOG.md +11 -0
- {mdl_cli-0.3.0/mdl_cli.egg-info → mdl_cli-0.3.1}/PKG-INFO +6 -3
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/README.md +4 -1
- mdl_cli-0.3.1/docs/installation.md +175 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/docs/usage.md +33 -10
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_audio.py +3 -2
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_info.py +2 -1
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_video.py +2 -1
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/config.py +6 -6
- mdl_cli-0.3.1/mdl/core/runtime.py +122 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/infra/runner.py +17 -4
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/services/download_service.py +3 -5
- {mdl_cli-0.3.0 → mdl_cli-0.3.1/mdl_cli.egg-info}/PKG-INFO +6 -3
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/SOURCES.txt +4 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/requires.txt +1 -1
- mdl_cli-0.3.1/mdl_cli.egg-info/scm_file_list.json +53 -0
- mdl_cli-0.3.1/mdl_cli.egg-info/scm_version.json +8 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/pyproject.toml +1 -1
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_builders.py +7 -8
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_config.py +12 -4
- mdl_cli-0.3.1/tests/test_core_runtime.py +50 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_infra_runner.py +21 -5
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_services_download_service.py +4 -4
- mdl_cli-0.3.0/docs/installation.md +0 -131
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.github/workflows/ci.yml +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.github/workflows/release.yml +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.gitignore +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/LICENSE +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/app.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_common.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/cli.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/audio.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/info.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/settings.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/smoke.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/video.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/config_store.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/options.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/playlist.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/resolve.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/infra/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/services/__init__.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/dependency_links.txt +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/entry_points.txt +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/top_level.txt +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/setup.cfg +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/conftest.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_app.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_builders_common.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_cli.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_commands_settings.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_commands_wrappers.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_config_store.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_options.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_playlist.py +0 -0
- {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_resolve.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- `mdl` now prefers the `yt-dlp` installed as a dependency of `mdl-cli`, so upgrading `mdl-cli` also updates `yt-dlp` and its dependencies.
|
|
9
|
+
- Dependency now requires `yt-dlp[default]>=2026.10.21`, which includes `yt-dlp-ejs` and current YouTube support.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Corrected output template fallback syntax (for example, folders named `artist|uploader`).
|
|
13
|
+
- Audio, video, and smoke downloads now always require `ffmpeg`, since extraction and remuxing depend on it.
|
|
14
|
+
- Removed the redundant `--add-metadata` flag from audio downloads.
|
|
15
|
+
|
|
5
16
|
## [0.3.0] - 2026-04-13
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdl-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A human-friendly wrapper over yt-dlp.
|
|
5
5
|
Author: Tomás Ezequiel Aguirre
|
|
6
6
|
License: MIT License
|
|
@@ -39,7 +39,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
39
39
|
Requires-Python: >=3.10
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: yt-dlp>=
|
|
42
|
+
Requires-Dist: yt-dlp[default]>=2026.10.21
|
|
43
43
|
Provides-Extra: dev
|
|
44
44
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
45
45
|
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
@@ -59,8 +59,11 @@ Dynamic: license-file
|
|
|
59
59
|
|
|
60
60
|
- Python `>=3.10`
|
|
61
61
|
- `ffmpeg` available in `PATH`
|
|
62
|
+
- A JavaScript runtime for YouTube (`deno >=2.3` recommended, or `node >=22`)
|
|
62
63
|
|
|
63
|
-
`mdl` executes `yt-dlp` under the hood.
|
|
64
|
+
`mdl` executes `yt-dlp` under the hood. Installing `mdl-cli` installs `yt-dlp[default]`
|
|
65
|
+
(including `yt-dlp-ejs`) automatically. If `yt-dlp` is not bundled, `mdl` falls back to
|
|
66
|
+
the `yt-dlp` available in `PATH`.
|
|
64
67
|
|
|
65
68
|
## Installation
|
|
66
69
|
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
- Python `>=3.10`
|
|
12
12
|
- `ffmpeg` available in `PATH`
|
|
13
|
+
- A JavaScript runtime for YouTube (`deno >=2.3` recommended, or `node >=22`)
|
|
13
14
|
|
|
14
|
-
`mdl` executes `yt-dlp` under the hood.
|
|
15
|
+
`mdl` executes `yt-dlp` under the hood. Installing `mdl-cli` installs `yt-dlp[default]`
|
|
16
|
+
(including `yt-dlp-ejs`) automatically. If `yt-dlp` is not bundled, `mdl` falls back to
|
|
17
|
+
the `yt-dlp` available in `PATH`.
|
|
15
18
|
|
|
16
19
|
## Installation
|
|
17
20
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
- Python 3.10+
|
|
6
|
+
- `ffmpeg` (required for audio extraction and video remuxing)
|
|
7
|
+
- A JavaScript runtime for YouTube (`deno >=2.3` recommended, or `node >=22`)
|
|
8
|
+
|
|
9
|
+
`mdl-cli` installs `yt-dlp[default]` automatically, which includes `yt-dlp-ejs`.
|
|
10
|
+
If `yt-dlp` is not bundled, `mdl` falls back to the `yt-dlp` available in `PATH`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install Python
|
|
15
|
+
|
|
16
|
+
### Linux
|
|
17
|
+
|
|
18
|
+
Use your distribution package manager. Example (Debian/Ubuntu):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
sudo apt update
|
|
22
|
+
sudo apt install -y python3 python3-pip python3-venv
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### macOS
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
brew install python
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Windows
|
|
32
|
+
|
|
33
|
+
PowerShell:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
winget install -e --id Python.Python.3.12
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Manual installer:
|
|
40
|
+
- https://www.python.org/downloads/windows/
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Install mdl-cli
|
|
45
|
+
|
|
46
|
+
### Install pipx (if needed)
|
|
47
|
+
|
|
48
|
+
Linux (Debian/Ubuntu):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
sudo apt install pipx
|
|
52
|
+
pipx ensurepath
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
macOS:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python3 -m pip install --user pipx
|
|
59
|
+
python3 -m pipx ensurepath
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Windows (PowerShell):
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
py -m pip install --user pipx
|
|
66
|
+
py -m pipx ensurepath
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Open a new terminal after `ensurepath`.
|
|
70
|
+
|
|
71
|
+
### Recommended (pipx)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pipx install mdl-cli
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Alternative (pip)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install mdl-cli
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Install ffmpeg
|
|
86
|
+
|
|
87
|
+
### Linux
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sudo apt install ffmpeg
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### macOS
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
brew install ffmpeg
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Windows
|
|
100
|
+
|
|
101
|
+
PowerShell:
|
|
102
|
+
|
|
103
|
+
```powershell
|
|
104
|
+
winget install -e --id Gyan.FFmpeg
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Manual install:
|
|
108
|
+
- https://ffmpeg.org/download.html
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Install a JavaScript runtime (YouTube)
|
|
113
|
+
|
|
114
|
+
yt-dlp needs a JavaScript runtime for full YouTube support. Deno is recommended and
|
|
115
|
+
enabled by default.
|
|
116
|
+
|
|
117
|
+
### Deno
|
|
118
|
+
|
|
119
|
+
Linux/macOS:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
curl -fsSL https://deno.land/install.sh | sh
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Windows (PowerShell):
|
|
126
|
+
|
|
127
|
+
```powershell
|
|
128
|
+
winget install -e --id DenoLand.Deno
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Minimum supported version: `2.3.0`.
|
|
132
|
+
|
|
133
|
+
If you prefer Node.js (`>=22`), enable it in your yt-dlp configuration file with
|
|
134
|
+
`--js-runtimes node`. See the yt-dlp EJS guide for details:
|
|
135
|
+
https://github.com/yt-dlp/yt-dlp/wiki/EJS
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## yt-dlp installation and updates
|
|
140
|
+
|
|
141
|
+
`yt-dlp[default]` is installed automatically as a dependency of `mdl-cli`.
|
|
142
|
+
You do not need to install it separately.
|
|
143
|
+
|
|
144
|
+
To upgrade `mdl-cli` and its dependencies (including `yt-dlp` and `yt-dlp-ejs`):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
pipx upgrade mdl-cli --pip-args="--upgrade-strategy eager"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If you install with `pip` instead:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -U mdl-cli
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`mdl` prefers the `yt-dlp` installed alongside it and falls back to `yt-dlp` in
|
|
157
|
+
`PATH` when needed.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Verify Installation
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python --version
|
|
165
|
+
ffmpeg -version
|
|
166
|
+
deno --version
|
|
167
|
+
mdl --version
|
|
168
|
+
mdl smoke audio
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Check the yt-dlp version used by `mdl`:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
mdl info "https://www.youtube.com/watch?v=jNQXAC9IVRw"
|
|
175
|
+
```
|
|
@@ -15,8 +15,11 @@ It provides:
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
17
17
|
- Python `>=3.10`
|
|
18
|
-
- `
|
|
19
|
-
-
|
|
18
|
+
- `ffmpeg` available in `PATH` (required for audio extraction and video remuxing)
|
|
19
|
+
- A JavaScript runtime for YouTube (`deno >=2.3` recommended, or `node >=22`)
|
|
20
|
+
|
|
21
|
+
`mdl-cli` installs `yt-dlp[default]` (including `yt-dlp-ejs`) automatically. If it is
|
|
22
|
+
not bundled, `mdl` falls back to `yt-dlp` in `PATH`.
|
|
20
23
|
|
|
21
24
|
## Installation
|
|
22
25
|
|
|
@@ -173,11 +176,11 @@ Downloads include:
|
|
|
173
176
|
|
|
174
177
|
## Output Templates
|
|
175
178
|
|
|
176
|
-
- Audio single: `%(artists.0
|
|
177
|
-
- Audio playlist (album): `%(artists.0
|
|
178
|
-
- Audio playlist (flat): `%(playlist_title|
|
|
179
|
-
- Video single: `%(uploader|
|
|
180
|
-
- Video playlist: `%(playlist_title|
|
|
179
|
+
- Audio single: `%(artists.0,artist,uploader|Unknown)s/%(title)s.%(ext)s`
|
|
180
|
+
- Audio playlist (album): `%(artists.0,artist,uploader|Unknown)s/%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s`
|
|
181
|
+
- Audio playlist (flat): `%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s`
|
|
182
|
+
- Video single: `%(uploader,channel|Unknown)s/%(title)s.%(ext)s`
|
|
183
|
+
- Video playlist: `%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s`
|
|
181
184
|
|
|
182
185
|
Layout behavior:
|
|
183
186
|
|
|
@@ -198,12 +201,32 @@ Command-level defaults:
|
|
|
198
201
|
|
|
199
202
|
## Troubleshooting
|
|
200
203
|
|
|
201
|
-
### `yt-dlp not
|
|
204
|
+
### `yt-dlp is not available`
|
|
205
|
+
|
|
206
|
+
Reinstall `mdl-cli` so its dependencies are installed:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pipx install --force mdl-cli
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
When using `pip`:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pip install --force-reinstall mdl-cli
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
If you want an explicit external install:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pipx install "yt-dlp[default]"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### `yt-dlp` version warning
|
|
202
225
|
|
|
203
|
-
|
|
226
|
+
Update `mdl-cli` and all of its dependencies:
|
|
204
227
|
|
|
205
228
|
```bash
|
|
206
|
-
|
|
229
|
+
pipx upgrade mdl-cli --pip-args="--upgrade-strategy eager"
|
|
207
230
|
```
|
|
208
231
|
|
|
209
232
|
### `ffmpeg not found in PATH`
|
|
@@ -6,6 +6,7 @@ from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
|
6
6
|
from mdl.core.config import Defaults
|
|
7
7
|
from mdl.core.options import RunOptions
|
|
8
8
|
from mdl.core.playlist import detect_playlist_layout, is_playlist_url
|
|
9
|
+
from mdl.core.runtime import yt_dlp_command
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def build_audio_command(url: str, opts: RunOptions) -> List[str]:
|
|
@@ -24,14 +25,14 @@ def build_audio_command(url: str, opts: RunOptions) -> List[str]:
|
|
|
24
25
|
tpl = Defaults.audio_single_tpl
|
|
25
26
|
out_template = str(opts.out_dir / tpl)
|
|
26
27
|
|
|
27
|
-
cmd: List[str] =
|
|
28
|
+
cmd: List[str] = yt_dlp_command()
|
|
28
29
|
cmd += base_yt_dlp_args(opts)
|
|
29
30
|
|
|
30
31
|
cmd += ["-f", "bestaudio/best"]
|
|
31
32
|
cmd += ["-x", "--audio-format", opts.audio_format]
|
|
32
33
|
|
|
33
34
|
# Metadata (no quality impact)
|
|
34
|
-
cmd += ["--
|
|
35
|
+
cmd += ["--embed-metadata"]
|
|
35
36
|
|
|
36
37
|
if is_playlist:
|
|
37
38
|
# Normalize autogenerated labels like "Album - <name>" to "<name>".
|
|
@@ -4,6 +4,7 @@ from typing import List
|
|
|
4
4
|
|
|
5
5
|
from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
6
6
|
from mdl.core.options import RunOptions
|
|
7
|
+
from mdl.core.runtime import yt_dlp_command
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def build_info_command(url: str, opts: RunOptions) -> List[str]:
|
|
@@ -12,7 +13,7 @@ def build_info_command(url: str, opts: RunOptions) -> List[str]:
|
|
|
12
13
|
|
|
13
14
|
Equivalent to: yt-dlp -F URL
|
|
14
15
|
"""
|
|
15
|
-
cmd: List[str] =
|
|
16
|
+
cmd: List[str] = yt_dlp_command()
|
|
16
17
|
cmd += base_yt_dlp_args(opts)
|
|
17
18
|
cmd += ["-F", url]
|
|
18
19
|
return cmd
|
|
@@ -6,6 +6,7 @@ from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
|
6
6
|
from mdl.core.config import Defaults
|
|
7
7
|
from mdl.core.options import RunOptions
|
|
8
8
|
from mdl.core.playlist import is_playlist_url
|
|
9
|
+
from mdl.core.runtime import yt_dlp_command
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def build_video_command(url: str, opts: RunOptions) -> List[str]:
|
|
@@ -20,7 +21,7 @@ def build_video_command(url: str, opts: RunOptions) -> List[str]:
|
|
|
20
21
|
tpl = Defaults.video_playlist_flat_tpl if is_playlist else Defaults.video_single_tpl
|
|
21
22
|
out_template = str(opts.out_dir / tpl)
|
|
22
23
|
|
|
23
|
-
cmd: List[str] =
|
|
24
|
+
cmd: List[str] = yt_dlp_command()
|
|
24
25
|
cmd += base_yt_dlp_args(opts)
|
|
25
26
|
|
|
26
27
|
# Best quality selection (avoid forcing container here)
|
|
@@ -79,12 +79,12 @@ class Defaults:
|
|
|
79
79
|
|
|
80
80
|
# Output templates
|
|
81
81
|
# Album layout: keep grouping by artist -> album/playlist title.
|
|
82
|
-
audio_single_tpl: str = "%(artists.0
|
|
83
|
-
audio_playlist_tpl: str = "%(artists.0
|
|
84
|
-
video_playlist_tpl: str = "%(uploader|
|
|
82
|
+
audio_single_tpl: str = "%(artists.0,artist,uploader|Unknown)s/%(title)s.%(ext)s"
|
|
83
|
+
audio_playlist_tpl: str = "%(artists.0,artist,uploader|Unknown)s/%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s"
|
|
84
|
+
video_playlist_tpl: str = "%(uploader,channel|Unknown)s/%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s"
|
|
85
85
|
|
|
86
86
|
# Flat layout: keep all playlist entries under a single playlist folder.
|
|
87
|
-
audio_playlist_flat_tpl: str = "%(playlist_title|
|
|
88
|
-
video_playlist_flat_tpl: str = "%(playlist_title|
|
|
87
|
+
audio_playlist_flat_tpl: str = "%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s"
|
|
88
|
+
video_playlist_flat_tpl: str = "%(playlist_title,playlist|Unknown)s/%(playlist_index)02d - %(title)s.%(ext)s"
|
|
89
89
|
|
|
90
|
-
video_single_tpl: str = "%(uploader|
|
|
90
|
+
video_single_tpl: str = "%(uploader,channel|Unknown)s/%(title)s.%(ext)s"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
YT_DLP_MAX_AGE_DAYS = 90
|
|
12
|
+
|
|
13
|
+
_VERSION_DATE_RE = re.compile(
|
|
14
|
+
r"^(?P<year>\d{4})\.(?P<month>\d{2})\.(?P<day>\d{2})(?:\.|$)"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def has_bundled_yt_dlp() -> bool:
|
|
19
|
+
"""Whether yt-dlp is installed alongside mdl."""
|
|
20
|
+
return importlib.util.find_spec("yt_dlp") is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def yt_dlp_command() -> list[str]:
|
|
24
|
+
"""
|
|
25
|
+
Return the command prefix used to invoke yt-dlp.
|
|
26
|
+
|
|
27
|
+
Prefer the yt-dlp installed as a dependency of mdl so that installing or
|
|
28
|
+
upgrading mdl also installs or upgrades yt-dlp. Fall back to PATH for
|
|
29
|
+
development setups that manage yt-dlp externally.
|
|
30
|
+
"""
|
|
31
|
+
if has_bundled_yt_dlp():
|
|
32
|
+
return [sys.executable, "-m", "yt_dlp"]
|
|
33
|
+
return ["yt-dlp"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def yt_dlp_available() -> bool:
|
|
37
|
+
return has_bundled_yt_dlp() or shutil.which("yt-dlp") is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bundled_yt_dlp_version() -> str | None:
|
|
41
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return version("yt-dlp")
|
|
45
|
+
except PackageNotFoundError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from yt_dlp.version import __version__
|
|
50
|
+
except ImportError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return str(__version__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _external_yt_dlp_version() -> str | None:
|
|
57
|
+
executable = shutil.which("yt-dlp")
|
|
58
|
+
if executable is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
[executable, "--version"],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=10,
|
|
67
|
+
check=False,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
version = result.stdout.strip()
|
|
76
|
+
return version or None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@lru_cache(maxsize=1)
|
|
80
|
+
def yt_dlp_version() -> str | None:
|
|
81
|
+
version = _bundled_yt_dlp_version()
|
|
82
|
+
if version:
|
|
83
|
+
return version
|
|
84
|
+
return _external_yt_dlp_version()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def yt_dlp_age_days(version: str, *, today: date | None = None) -> int | None:
|
|
88
|
+
match = _VERSION_DATE_RE.match(version)
|
|
89
|
+
if match is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
released = date(
|
|
94
|
+
int(match.group("year")),
|
|
95
|
+
int(match.group("month")),
|
|
96
|
+
int(match.group("day")),
|
|
97
|
+
)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
return ((today or date.today()) - released).days
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def yt_dlp_update_hint() -> str:
|
|
105
|
+
if has_bundled_yt_dlp():
|
|
106
|
+
return 'pipx upgrade mdl-cli --pip-args="--upgrade-strategy eager"'
|
|
107
|
+
return 'pipx install --force "yt-dlp[default]"'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def yt_dlp_version_warning(*, today: date | None = None) -> str | None:
|
|
111
|
+
version = yt_dlp_version()
|
|
112
|
+
if version is None:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
age = yt_dlp_age_days(version, today=today)
|
|
116
|
+
if age is None or age <= YT_DLP_MAX_AGE_DAYS:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
f"[mdl] WARNING: yt-dlp {version} is {age} days old. "
|
|
121
|
+
f"Update it with: {yt_dlp_update_hint()}"
|
|
122
|
+
)
|
|
@@ -7,6 +7,8 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from typing import List
|
|
9
9
|
|
|
10
|
+
from mdl.core import runtime
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
def _command_exists(name: str) -> bool:
|
|
12
14
|
return shutil.which(name) is not None
|
|
@@ -23,10 +25,17 @@ def print_command(cmd: List[str]) -> None:
|
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def _check_dependencies(*, needs_ffmpeg: bool) -> int:
|
|
26
|
-
if not
|
|
27
|
-
print("[mdl] ERROR: yt-dlp not
|
|
28
|
-
print(
|
|
29
|
-
|
|
28
|
+
if not runtime.yt_dlp_available():
|
|
29
|
+
print("[mdl] ERROR: yt-dlp is not available.", file=sys.stderr)
|
|
30
|
+
print(
|
|
31
|
+
"[mdl] Reinstall mdl-cli so its dependencies are installed: "
|
|
32
|
+
"pipx install --force mdl-cli",
|
|
33
|
+
file=sys.stderr,
|
|
34
|
+
)
|
|
35
|
+
print(
|
|
36
|
+
'[mdl] Or install yt-dlp manually: pipx install "yt-dlp[default]"',
|
|
37
|
+
file=sys.stderr,
|
|
38
|
+
)
|
|
30
39
|
return 127
|
|
31
40
|
|
|
32
41
|
if needs_ffmpeg and not _command_exists("ffmpeg"):
|
|
@@ -35,6 +44,10 @@ def _check_dependencies(*, needs_ffmpeg: bool) -> int:
|
|
|
35
44
|
print("[mdl] Or ensure it is available in your PATH.", file=sys.stderr)
|
|
36
45
|
return 127
|
|
37
46
|
|
|
47
|
+
warning = runtime.yt_dlp_version_warning()
|
|
48
|
+
if warning:
|
|
49
|
+
print(warning, file=sys.stderr)
|
|
50
|
+
|
|
38
51
|
return 0
|
|
39
52
|
|
|
40
53
|
|
|
@@ -28,15 +28,13 @@ def _run_or_print(opts: Options, cmd: list[str], *, needs_ffmpeg: bool = False)
|
|
|
28
28
|
def run_audio_download(opts: Options, run_opts: RunOptions) -> int:
|
|
29
29
|
url = require_url(opts)
|
|
30
30
|
cmd = build_audio_command(url, run_opts)
|
|
31
|
-
needs_ffmpeg
|
|
32
|
-
return _run_or_print(opts, cmd, needs_ffmpeg=needs_ffmpeg)
|
|
31
|
+
return _run_or_print(opts, cmd, needs_ffmpeg=True)
|
|
33
32
|
|
|
34
33
|
|
|
35
34
|
def run_video_download(opts: Options, run_opts: RunOptions) -> int:
|
|
36
35
|
url = require_url(opts)
|
|
37
36
|
cmd = build_video_command(url, run_opts)
|
|
38
|
-
needs_ffmpeg
|
|
39
|
-
return _run_or_print(opts, cmd, needs_ffmpeg=needs_ffmpeg)
|
|
37
|
+
return _run_or_print(opts, cmd, needs_ffmpeg=True)
|
|
40
38
|
|
|
41
39
|
|
|
42
40
|
def run_info(opts: Options, run_opts: RunOptions) -> int:
|
|
@@ -57,4 +55,4 @@ def run_smoke(opts: Options, run_opts: RunOptions) -> int:
|
|
|
57
55
|
print_command(cmd)
|
|
58
56
|
return 0
|
|
59
57
|
|
|
60
|
-
return run_command(cmd, needs_ffmpeg=
|
|
58
|
+
return run_command(cmd, needs_ffmpeg=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdl-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A human-friendly wrapper over yt-dlp.
|
|
5
5
|
Author: Tomás Ezequiel Aguirre
|
|
6
6
|
License: MIT License
|
|
@@ -39,7 +39,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
39
39
|
Requires-Python: >=3.10
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: yt-dlp>=
|
|
42
|
+
Requires-Dist: yt-dlp[default]>=2026.10.21
|
|
43
43
|
Provides-Extra: dev
|
|
44
44
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
45
45
|
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
@@ -59,8 +59,11 @@ Dynamic: license-file
|
|
|
59
59
|
|
|
60
60
|
- Python `>=3.10`
|
|
61
61
|
- `ffmpeg` available in `PATH`
|
|
62
|
+
- A JavaScript runtime for YouTube (`deno >=2.3` recommended, or `node >=22`)
|
|
62
63
|
|
|
63
|
-
`mdl` executes `yt-dlp` under the hood.
|
|
64
|
+
`mdl` executes `yt-dlp` under the hood. Installing `mdl-cli` installs `yt-dlp[default]`
|
|
65
|
+
(including `yt-dlp-ejs`) automatically. If `yt-dlp` is not bundled, `mdl` falls back to
|
|
66
|
+
the `yt-dlp` available in `PATH`.
|
|
64
67
|
|
|
65
68
|
## Installation
|
|
66
69
|
|
|
@@ -27,6 +27,7 @@ mdl/core/config_store.py
|
|
|
27
27
|
mdl/core/options.py
|
|
28
28
|
mdl/core/playlist.py
|
|
29
29
|
mdl/core/resolve.py
|
|
30
|
+
mdl/core/runtime.py
|
|
30
31
|
mdl/infra/__init__.py
|
|
31
32
|
mdl/infra/runner.py
|
|
32
33
|
mdl/services/__init__.py
|
|
@@ -36,6 +37,8 @@ mdl_cli.egg-info/SOURCES.txt
|
|
|
36
37
|
mdl_cli.egg-info/dependency_links.txt
|
|
37
38
|
mdl_cli.egg-info/entry_points.txt
|
|
38
39
|
mdl_cli.egg-info/requires.txt
|
|
40
|
+
mdl_cli.egg-info/scm_file_list.json
|
|
41
|
+
mdl_cli.egg-info/scm_version.json
|
|
39
42
|
mdl_cli.egg-info/top_level.txt
|
|
40
43
|
tests/conftest.py
|
|
41
44
|
tests/test_app.py
|
|
@@ -49,5 +52,6 @@ tests/test_core_config_store.py
|
|
|
49
52
|
tests/test_core_options.py
|
|
50
53
|
tests/test_core_playlist.py
|
|
51
54
|
tests/test_core_resolve.py
|
|
55
|
+
tests/test_core_runtime.py
|
|
52
56
|
tests/test_infra_runner.py
|
|
53
57
|
tests/test_services_download_service.py
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".github/workflows/ci.yml",
|
|
4
|
+
".github/workflows/release.yml",
|
|
5
|
+
".gitignore",
|
|
6
|
+
"CHANGELOG.md",
|
|
7
|
+
"LICENSE",
|
|
8
|
+
"README.md",
|
|
9
|
+
"docs/installation.md",
|
|
10
|
+
"docs/usage.md",
|
|
11
|
+
"mdl/__init__.py",
|
|
12
|
+
"mdl/app.py",
|
|
13
|
+
"mdl/builders/__init__.py",
|
|
14
|
+
"mdl/builders/yt_dlp_audio.py",
|
|
15
|
+
"mdl/builders/yt_dlp_common.py",
|
|
16
|
+
"mdl/builders/yt_dlp_info.py",
|
|
17
|
+
"mdl/builders/yt_dlp_video.py",
|
|
18
|
+
"mdl/cli.py",
|
|
19
|
+
"mdl/commands/__init__.py",
|
|
20
|
+
"mdl/commands/audio.py",
|
|
21
|
+
"mdl/commands/info.py",
|
|
22
|
+
"mdl/commands/settings.py",
|
|
23
|
+
"mdl/commands/smoke.py",
|
|
24
|
+
"mdl/commands/video.py",
|
|
25
|
+
"mdl/core/__init__.py",
|
|
26
|
+
"mdl/core/config.py",
|
|
27
|
+
"mdl/core/config_store.py",
|
|
28
|
+
"mdl/core/options.py",
|
|
29
|
+
"mdl/core/playlist.py",
|
|
30
|
+
"mdl/core/resolve.py",
|
|
31
|
+
"mdl/core/runtime.py",
|
|
32
|
+
"mdl/infra/__init__.py",
|
|
33
|
+
"mdl/infra/runner.py",
|
|
34
|
+
"mdl/services/__init__.py",
|
|
35
|
+
"mdl/services/download_service.py",
|
|
36
|
+
"pyproject.toml",
|
|
37
|
+
"tests/conftest.py",
|
|
38
|
+
"tests/test_app.py",
|
|
39
|
+
"tests/test_builders.py",
|
|
40
|
+
"tests/test_builders_common.py",
|
|
41
|
+
"tests/test_cli.py",
|
|
42
|
+
"tests/test_commands_settings.py",
|
|
43
|
+
"tests/test_commands_wrappers.py",
|
|
44
|
+
"tests/test_core_config.py",
|
|
45
|
+
"tests/test_core_config_store.py",
|
|
46
|
+
"tests/test_core_options.py",
|
|
47
|
+
"tests/test_core_playlist.py",
|
|
48
|
+
"tests/test_core_resolve.py",
|
|
49
|
+
"tests/test_core_runtime.py",
|
|
50
|
+
"tests/test_infra_runner.py",
|
|
51
|
+
"tests/test_services_download_service.py"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -4,6 +4,7 @@ from mdl.builders.yt_dlp_audio import build_audio_command
|
|
|
4
4
|
from mdl.builders.yt_dlp_video import build_video_command
|
|
5
5
|
from mdl.builders.yt_dlp_info import build_info_command
|
|
6
6
|
from mdl.core.config import Defaults
|
|
7
|
+
from mdl.core.runtime import yt_dlp_command
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def _assert_contains_kv(cmd: list[str], key: str, value: str) -> None:
|
|
@@ -27,7 +28,7 @@ def test_audio_single_with_shared_flags_and_cover_snapshot(make_run_options) ->
|
|
|
27
28
|
cmd = build_audio_command(url, opts)
|
|
28
29
|
|
|
29
30
|
assert cmd == [
|
|
30
|
-
|
|
31
|
+
*yt_dlp_command(),
|
|
31
32
|
"--cookies-from-browser",
|
|
32
33
|
"brave",
|
|
33
34
|
"--limit-rate",
|
|
@@ -41,7 +42,6 @@ def test_audio_single_with_shared_flags_and_cover_snapshot(make_run_options) ->
|
|
|
41
42
|
"-x",
|
|
42
43
|
"--audio-format",
|
|
43
44
|
"opus",
|
|
44
|
-
"--add-metadata",
|
|
45
45
|
"--embed-metadata",
|
|
46
46
|
"--embed-thumbnail",
|
|
47
47
|
"--ignore-errors",
|
|
@@ -59,11 +59,10 @@ def test_audio_playlist_uses_playlist_template_and_no_cover_flags(make_run_optio
|
|
|
59
59
|
|
|
60
60
|
cmd = build_audio_command(url, opts)
|
|
61
61
|
|
|
62
|
-
assert cmd[
|
|
62
|
+
assert cmd[: len(yt_dlp_command())] == yt_dlp_command()
|
|
63
63
|
_assert_contains_kv(cmd, "-f", "bestaudio/best")
|
|
64
64
|
assert "-x" in cmd
|
|
65
65
|
_assert_contains_kv(cmd, "--audio-format", "m4a")
|
|
66
|
-
assert "--add-metadata" in cmd
|
|
67
66
|
assert "--embed-metadata" in cmd
|
|
68
67
|
assert "--ignore-errors" in cmd
|
|
69
68
|
assert "--continue" in cmd
|
|
@@ -107,7 +106,7 @@ def test_video_single_cover_flags_present_only_when_cover_true(make_run_options)
|
|
|
107
106
|
|
|
108
107
|
cmd = build_video_command(url, opts)
|
|
109
108
|
|
|
110
|
-
assert cmd[
|
|
109
|
+
assert cmd[: len(yt_dlp_command())] == yt_dlp_command()
|
|
111
110
|
_assert_contains_kv(cmd, "-f", "bv*+ba/b")
|
|
112
111
|
_assert_contains_kv(cmd, "--remux-video", "mkv")
|
|
113
112
|
|
|
@@ -125,7 +124,7 @@ def test_video_playlist_cover_flags_absent_when_cover_false(make_run_options) ->
|
|
|
125
124
|
|
|
126
125
|
cmd = build_video_command(url, opts)
|
|
127
126
|
|
|
128
|
-
assert cmd[
|
|
127
|
+
assert cmd[: len(yt_dlp_command())] == yt_dlp_command()
|
|
129
128
|
_assert_contains_kv(cmd, "-f", "bv*+ba/b")
|
|
130
129
|
_assert_contains_kv(cmd, "--remux-video", "mp4")
|
|
131
130
|
assert "--replace-in-metadata" in cmd
|
|
@@ -152,7 +151,7 @@ def test_info_command_minimal(make_run_options) -> None:
|
|
|
152
151
|
opts = make_run_options()
|
|
153
152
|
|
|
154
153
|
cmd = build_info_command(url, opts)
|
|
155
|
-
assert cmd == [
|
|
154
|
+
assert cmd == [*yt_dlp_command(), "-F", url]
|
|
156
155
|
|
|
157
156
|
|
|
158
157
|
def test_info_command_includes_shared_flags_when_present(make_run_options) -> None:
|
|
@@ -166,7 +165,7 @@ def test_info_command_includes_shared_flags_when_present(make_run_options) -> No
|
|
|
166
165
|
|
|
167
166
|
cmd = build_info_command(url, opts)
|
|
168
167
|
|
|
169
|
-
assert cmd[
|
|
168
|
+
assert cmd[: len(yt_dlp_command())] == yt_dlp_command()
|
|
170
169
|
_assert_contains_kv(cmd, "--cookies-from-browser", "chrome")
|
|
171
170
|
_assert_contains_kv(cmd, "--limit-rate", "1M")
|
|
172
171
|
_assert_contains_kv(cmd, "--sleep-interval", "5")
|
|
@@ -31,13 +31,21 @@ def test_xdg_music_dir_reads_user_dirs_file_and_expands_home(
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def test_defaults_audio_templates_use_primary_artist_first() -> None:
|
|
34
|
-
assert core_config.Defaults.audio_single_tpl.startswith(
|
|
35
|
-
|
|
34
|
+
assert core_config.Defaults.audio_single_tpl.startswith(
|
|
35
|
+
"%(artists.0,artist,uploader|Unknown)s/"
|
|
36
|
+
)
|
|
37
|
+
assert core_config.Defaults.audio_playlist_tpl.startswith(
|
|
38
|
+
"%(artists.0,artist,uploader|Unknown)s/"
|
|
39
|
+
)
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
def test_defaults_flat_playlist_templates_group_under_playlist_folder() -> None:
|
|
39
|
-
assert core_config.Defaults.audio_playlist_flat_tpl.startswith(
|
|
40
|
-
|
|
43
|
+
assert core_config.Defaults.audio_playlist_flat_tpl.startswith(
|
|
44
|
+
"%(playlist_title,playlist|Unknown)s/"
|
|
45
|
+
)
|
|
46
|
+
assert core_config.Defaults.video_playlist_flat_tpl.startswith(
|
|
47
|
+
"%(playlist_title,playlist|Unknown)s/"
|
|
48
|
+
)
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def test_default_config_dir_uses_xdg_on_linux(monkeypatch, tmp_path: Path) -> None:
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from mdl.core import runtime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_yt_dlp_command_prefers_bundled_module(monkeypatch) -> None:
|
|
10
|
+
monkeypatch.setattr(runtime, "has_bundled_yt_dlp", lambda: True)
|
|
11
|
+
|
|
12
|
+
assert runtime.yt_dlp_command() == [sys.executable, "-m", "yt_dlp"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_yt_dlp_command_falls_back_to_path(monkeypatch) -> None:
|
|
16
|
+
monkeypatch.setattr(runtime, "has_bundled_yt_dlp", lambda: False)
|
|
17
|
+
|
|
18
|
+
assert runtime.yt_dlp_command() == ["yt-dlp"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_yt_dlp_age_days_parses_release_date() -> None:
|
|
22
|
+
assert runtime.yt_dlp_age_days("2026.02.04", today=date(2026, 10, 22)) == 260
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_yt_dlp_age_days_rejects_unparseable_versions() -> None:
|
|
26
|
+
assert runtime.yt_dlp_age_days("nightly", today=date(2026, 10, 22)) is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_yt_dlp_version_warning_for_old_version(monkeypatch) -> None:
|
|
30
|
+
monkeypatch.setattr(runtime, "yt_dlp_version", lambda: "2026.02.04")
|
|
31
|
+
monkeypatch.setattr(runtime, "yt_dlp_update_hint", lambda: "update-me")
|
|
32
|
+
|
|
33
|
+
warning = runtime.yt_dlp_version_warning(today=date(2026, 10, 22))
|
|
34
|
+
|
|
35
|
+
assert warning is not None
|
|
36
|
+
assert "2026.02.04" in warning
|
|
37
|
+
assert "260 days old" in warning
|
|
38
|
+
assert "update-me" in warning
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_yt_dlp_version_warning_for_recent_version(monkeypatch) -> None:
|
|
42
|
+
monkeypatch.setattr(runtime, "yt_dlp_version", lambda: "2026.10.21")
|
|
43
|
+
|
|
44
|
+
assert runtime.yt_dlp_version_warning(today=date(2026, 10, 22)) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_yt_dlp_version_warning_without_version(monkeypatch) -> None:
|
|
48
|
+
monkeypatch.setattr(runtime, "yt_dlp_version", lambda: None)
|
|
49
|
+
|
|
50
|
+
assert runtime.yt_dlp_version_warning(today=date(2026, 10, 22)) is None
|
|
@@ -3,8 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
6
|
from mdl.infra import runner
|
|
9
7
|
|
|
10
8
|
|
|
@@ -17,17 +15,19 @@ def test_printable_cmd_quotes_arguments_with_spaces() -> None:
|
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
def test_check_dependencies_returns_127_when_yt_dlp_is_missing(monkeypatch, capsys) -> None:
|
|
20
|
-
monkeypatch.setattr(runner, "
|
|
18
|
+
monkeypatch.setattr(runner.runtime, "yt_dlp_available", lambda: False)
|
|
21
19
|
|
|
22
20
|
rc = runner._check_dependencies(needs_ffmpeg=False)
|
|
23
21
|
captured = capsys.readouterr()
|
|
24
22
|
|
|
25
23
|
assert rc == 127
|
|
26
|
-
assert "yt-dlp not
|
|
24
|
+
assert "yt-dlp is not available" in captured.err
|
|
27
25
|
|
|
28
26
|
|
|
29
27
|
def test_check_dependencies_returns_127_when_ffmpeg_is_required_and_missing(monkeypatch, capsys) -> None:
|
|
30
|
-
monkeypatch.setattr(runner, "
|
|
28
|
+
monkeypatch.setattr(runner.runtime, "yt_dlp_available", lambda: True)
|
|
29
|
+
monkeypatch.setattr(runner, "_command_exists", lambda name: name != "ffmpeg")
|
|
30
|
+
monkeypatch.setattr(runner.runtime, "yt_dlp_version_warning", lambda: None)
|
|
31
31
|
|
|
32
32
|
rc = runner._check_dependencies(needs_ffmpeg=True)
|
|
33
33
|
captured = capsys.readouterr()
|
|
@@ -36,6 +36,22 @@ def test_check_dependencies_returns_127_when_ffmpeg_is_required_and_missing(monk
|
|
|
36
36
|
assert "ffmpeg not found in PATH" in captured.err
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_check_dependencies_warns_when_yt_dlp_is_old(monkeypatch, capsys) -> None:
|
|
40
|
+
monkeypatch.setattr(runner.runtime, "yt_dlp_available", lambda: True)
|
|
41
|
+
monkeypatch.setattr(runner, "_command_exists", lambda name: True)
|
|
42
|
+
monkeypatch.setattr(
|
|
43
|
+
runner.runtime,
|
|
44
|
+
"yt_dlp_version_warning",
|
|
45
|
+
lambda: "[mdl] WARNING: yt-dlp is old",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
rc = runner._check_dependencies(needs_ffmpeg=False)
|
|
49
|
+
captured = capsys.readouterr()
|
|
50
|
+
|
|
51
|
+
assert rc == 0
|
|
52
|
+
assert "yt-dlp is old" in captured.err
|
|
53
|
+
|
|
54
|
+
|
|
39
55
|
def test_run_command_returns_dependency_error_and_still_prints_command(monkeypatch, capsys) -> None:
|
|
40
56
|
monkeypatch.setattr(runner, "_check_dependencies", lambda *, needs_ffmpeg: 127)
|
|
41
57
|
monkeypatch.setattr(runner.subprocess, "run", lambda cmd: (_ for _ in ()).throw(AssertionError("unexpected call")))
|
|
@@ -50,7 +50,7 @@ def test_run_audio_download_builds_command_and_passes_ffmpeg_requirement(
|
|
|
50
50
|
monkeypatch.setattr(svc, "_run_or_print", fake_run_or_print)
|
|
51
51
|
|
|
52
52
|
opts = make_options(command="audio", url="https://example.com")
|
|
53
|
-
run_opts = make_run_options(cover=
|
|
53
|
+
run_opts = make_run_options(cover=False, audio_format="opus")
|
|
54
54
|
rc = svc.run_audio_download(opts, run_opts)
|
|
55
55
|
|
|
56
56
|
assert rc == 7
|
|
@@ -83,7 +83,7 @@ def test_run_video_download_builds_command_and_passes_ffmpeg_requirement(
|
|
|
83
83
|
|
|
84
84
|
assert rc == 9
|
|
85
85
|
assert calls["cmd"] == ["video-cmd", "https://example.com", "mkv"]
|
|
86
|
-
assert calls["needs_ffmpeg"] is
|
|
86
|
+
assert calls["needs_ffmpeg"] is True
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def test_run_info_builds_info_command_without_ffmpeg_flag(
|
|
@@ -130,7 +130,7 @@ def test_run_smoke_audio_print_mode_uses_smoke_audio_url(
|
|
|
130
130
|
assert printed == [["audio-smoke", svc.SMOKE_AUDIO_URL]]
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
def
|
|
133
|
+
def test_run_smoke_video_always_requires_ffmpeg(
|
|
134
134
|
monkeypatch,
|
|
135
135
|
make_options,
|
|
136
136
|
make_run_options,
|
|
@@ -147,7 +147,7 @@ def test_run_smoke_video_executes_with_cover_driven_ffmpeg_requirement(
|
|
|
147
147
|
monkeypatch.setattr(svc, "run_command", fake_run_command)
|
|
148
148
|
|
|
149
149
|
opts = make_options(command="smoke", smoke_kind="video", print_cmd=False, url=None)
|
|
150
|
-
rc = svc.run_smoke(opts, make_run_options(cover=
|
|
150
|
+
rc = svc.run_smoke(opts, make_run_options(cover=False))
|
|
151
151
|
|
|
152
152
|
assert rc == 5
|
|
153
153
|
assert calls["cmd"] == ["video-smoke", svc.SMOKE_VIDEO_URL]
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# Installation
|
|
2
|
-
|
|
3
|
-
## Requirements
|
|
4
|
-
|
|
5
|
-
- Python 3.10+
|
|
6
|
-
- `ffmpeg` (required for some features)
|
|
7
|
-
- `yt-dlp` available in `PATH`
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Install Python
|
|
12
|
-
|
|
13
|
-
### Linux
|
|
14
|
-
|
|
15
|
-
Use your distribution package manager. Example (Debian/Ubuntu):
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
sudo apt update
|
|
19
|
-
sudo apt install -y python3 python3-pip python3-venv
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
### macOS
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
brew install python
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### Windows
|
|
29
|
-
|
|
30
|
-
PowerShell:
|
|
31
|
-
|
|
32
|
-
```powershell
|
|
33
|
-
winget install -e --id Python.Python.3.12
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
Manual installer:
|
|
37
|
-
- https://www.python.org/downloads/windows/
|
|
38
|
-
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
## Install mdl-cli
|
|
42
|
-
|
|
43
|
-
### Install pipx (if needed)
|
|
44
|
-
|
|
45
|
-
Linux (Debian/Ubuntu):
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
sudo apt install pipx
|
|
49
|
-
pipx ensurepath
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
macOS:
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
python3 -m pip install --user pipx
|
|
56
|
-
python3 -m pipx ensurepath
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Windows (PowerShell):
|
|
60
|
-
|
|
61
|
-
```powershell
|
|
62
|
-
py -m pip install --user pipx
|
|
63
|
-
py -m pipx ensurepath
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Open a new terminal after `ensurepath`.
|
|
67
|
-
|
|
68
|
-
### Recommended (pipx)
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
pipx install mdl-cli
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### Alternative (pip)
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
pip install mdl-cli
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
---
|
|
81
|
-
|
|
82
|
-
## Install ffmpeg
|
|
83
|
-
|
|
84
|
-
### Linux
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
sudo apt install ffmpeg
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### macOS
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
brew install ffmpeg
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### Windows
|
|
97
|
-
|
|
98
|
-
PowerShell:
|
|
99
|
-
|
|
100
|
-
```powershell
|
|
101
|
-
winget install -e --id Gyan.FFmpeg
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Manual install:
|
|
105
|
-
- https://ffmpeg.org/download.html
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## Install yt-dlp
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
pipx install yt-dlp
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
If you prefer `pip`:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
pip install yt-dlp
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
---
|
|
122
|
-
|
|
123
|
-
## Verify Installation
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
python --version
|
|
127
|
-
yt-dlp --version
|
|
128
|
-
ffmpeg -version
|
|
129
|
-
mdl --version
|
|
130
|
-
mdl smoke audio
|
|
131
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|