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.
Files changed (60) hide show
  1. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/CHANGELOG.md +11 -0
  2. {mdl_cli-0.3.0/mdl_cli.egg-info → mdl_cli-0.3.1}/PKG-INFO +6 -3
  3. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/README.md +4 -1
  4. mdl_cli-0.3.1/docs/installation.md +175 -0
  5. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/docs/usage.md +33 -10
  6. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_audio.py +3 -2
  7. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_info.py +2 -1
  8. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_video.py +2 -1
  9. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/config.py +6 -6
  10. mdl_cli-0.3.1/mdl/core/runtime.py +122 -0
  11. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/infra/runner.py +17 -4
  12. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/services/download_service.py +3 -5
  13. {mdl_cli-0.3.0 → mdl_cli-0.3.1/mdl_cli.egg-info}/PKG-INFO +6 -3
  14. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/SOURCES.txt +4 -0
  15. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/requires.txt +1 -1
  16. mdl_cli-0.3.1/mdl_cli.egg-info/scm_file_list.json +53 -0
  17. mdl_cli-0.3.1/mdl_cli.egg-info/scm_version.json +8 -0
  18. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/pyproject.toml +1 -1
  19. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_builders.py +7 -8
  20. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_config.py +12 -4
  21. mdl_cli-0.3.1/tests/test_core_runtime.py +50 -0
  22. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_infra_runner.py +21 -5
  23. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_services_download_service.py +4 -4
  24. mdl_cli-0.3.0/docs/installation.md +0 -131
  25. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.github/workflows/ci.yml +0 -0
  26. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.github/workflows/release.yml +0 -0
  27. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/.gitignore +0 -0
  28. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/LICENSE +0 -0
  29. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/__init__.py +0 -0
  30. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/app.py +0 -0
  31. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/__init__.py +0 -0
  32. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/builders/yt_dlp_common.py +0 -0
  33. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/cli.py +0 -0
  34. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/__init__.py +0 -0
  35. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/audio.py +0 -0
  36. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/info.py +0 -0
  37. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/settings.py +0 -0
  38. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/smoke.py +0 -0
  39. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/commands/video.py +0 -0
  40. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/__init__.py +0 -0
  41. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/config_store.py +0 -0
  42. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/options.py +0 -0
  43. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/playlist.py +0 -0
  44. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/core/resolve.py +0 -0
  45. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/infra/__init__.py +0 -0
  46. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl/services/__init__.py +0 -0
  47. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/dependency_links.txt +0 -0
  48. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/entry_points.txt +0 -0
  49. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/mdl_cli.egg-info/top_level.txt +0 -0
  50. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/setup.cfg +0 -0
  51. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/conftest.py +0 -0
  52. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_app.py +0 -0
  53. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_builders_common.py +0 -0
  54. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_cli.py +0 -0
  55. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_commands_settings.py +0 -0
  56. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_commands_wrappers.py +0 -0
  57. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_config_store.py +0 -0
  58. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_options.py +0 -0
  59. {mdl_cli-0.3.0 → mdl_cli-0.3.1}/tests/test_core_playlist.py +0 -0
  60. {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.0
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>=2024.0
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. Ensure `yt-dlp` is installed and available in `PATH`.
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. Ensure `yt-dlp` is installed and available in `PATH`.
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
- - `yt-dlp` available in `PATH`
19
- - `ffmpeg` available in `PATH` (required when `cover=on`)
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|artist|uploader)s/%(title)s.%(ext)s`
177
- - Audio playlist (album): `%(artists.0|artist|uploader)s/%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s`
178
- - Audio playlist (flat): `%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s`
179
- - Video single: `%(uploader|channel)s/%(title)s.%(ext)s`
180
- - Video playlist: `%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s`
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 found in PATH`
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
- Install `yt-dlp` and verify:
226
+ Update `mdl-cli` and all of its dependencies:
204
227
 
205
228
  ```bash
206
- yt-dlp --version
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] = ["yt-dlp"]
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 += ["--add-metadata", "--embed-metadata"]
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] = ["yt-dlp"]
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] = ["yt-dlp"]
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|artist|uploader)s/%(title)s.%(ext)s"
83
- audio_playlist_tpl: str = "%(artists.0|artist|uploader)s/%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s"
84
- video_playlist_tpl: str = "%(uploader|channel)s/%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s"
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|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s"
88
- video_playlist_flat_tpl: str = "%(playlist_title|playlist)s/%(playlist_index)02d - %(title)s.%(ext)s"
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|channel)s/%(title)s.%(ext)s"
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 _command_exists("yt-dlp"):
27
- print("[mdl] ERROR: yt-dlp not found in PATH.", file=sys.stderr)
28
- print("[mdl] Install it (recommended): pipx install yt-dlp", file=sys.stderr)
29
- print("[mdl] Or ensure it is available in your PATH.", file=sys.stderr)
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 = run_opts.cover
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 = run_opts.cover
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=run_opts.cover)
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.0
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>=2024.0
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. Ensure `yt-dlp` is installed and available in `PATH`.
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
@@ -1,4 +1,4 @@
1
- yt-dlp>=2024.0
1
+ yt-dlp[default]>=2026.10.21
2
2
 
3
3
  [dev]
4
4
  pytest>=7
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.1",
3
+ "distance": 0,
4
+ "node": "gc9fdff90179cb2f7ace5a84c5642b42cd2b9a797",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-22"
8
+ }
@@ -17,7 +17,7 @@ authors = [
17
17
  keywords = ["yt-dlp", "downloader", "cli", "audio", "video"]
18
18
 
19
19
  dependencies = [
20
- "yt-dlp>=2024.0"
20
+ "yt-dlp[default]>=2026.10.21"
21
21
  ]
22
22
 
23
23
  classifiers = [
@@ -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
- "yt-dlp",
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[0] == "yt-dlp"
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[0] == "yt-dlp"
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[0] == "yt-dlp"
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 == ["yt-dlp", "-F", url]
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[0] == "yt-dlp"
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("%(artists.0|artist|uploader)s/")
35
- assert core_config.Defaults.audio_playlist_tpl.startswith("%(artists.0|artist|uploader)s/")
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("%(playlist_title|playlist)s/")
40
- assert core_config.Defaults.video_playlist_flat_tpl.startswith("%(playlist_title|playlist)s/")
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, "_command_exists", lambda name: False if name == "yt-dlp" else True)
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 found in PATH" in captured.err
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, "_command_exists", lambda name: name == "yt-dlp")
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=True, audio_format="opus")
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 False
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 test_run_smoke_video_executes_with_cover_driven_ffmpeg_requirement(
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=True))
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