mdl-cli 0.3.0__py3-none-any.whl
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/__init__.py +8 -0
- mdl/app.py +50 -0
- mdl/builders/__init__.py +0 -0
- mdl/builders/yt_dlp_audio.py +48 -0
- mdl/builders/yt_dlp_common.py +27 -0
- mdl/builders/yt_dlp_info.py +18 -0
- mdl/builders/yt_dlp_video.py +44 -0
- mdl/cli.py +169 -0
- mdl/commands/__init__.py +13 -0
- mdl/commands/audio.py +8 -0
- mdl/commands/info.py +8 -0
- mdl/commands/settings.py +60 -0
- mdl/commands/smoke.py +8 -0
- mdl/commands/video.py +8 -0
- mdl/core/__init__.py +11 -0
- mdl/core/config.py +90 -0
- mdl/core/config_store.py +175 -0
- mdl/core/options.py +86 -0
- mdl/core/playlist.py +35 -0
- mdl/core/resolve.py +73 -0
- mdl/infra/__init__.py +3 -0
- mdl/infra/runner.py +60 -0
- mdl/services/__init__.py +15 -0
- mdl/services/download_service.py +60 -0
- mdl_cli-0.3.0.dist-info/METADATA +100 -0
- mdl_cli-0.3.0.dist-info/RECORD +30 -0
- mdl_cli-0.3.0.dist-info/WHEEL +5 -0
- mdl_cli-0.3.0.dist-info/entry_points.txt +2 -0
- mdl_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- mdl_cli-0.3.0.dist-info/top_level.txt +1 -0
mdl/__init__.py
ADDED
mdl/app.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import Callable, Dict
|
|
5
|
+
|
|
6
|
+
from mdl.commands.audio import handle_audio
|
|
7
|
+
from mdl.commands.video import handle_video
|
|
8
|
+
from mdl.commands.info import handle_info
|
|
9
|
+
from mdl.commands.smoke import handle_smoke
|
|
10
|
+
from mdl.commands.settings import SETTINGS_COMMANDS, handle_settings
|
|
11
|
+
from mdl.core.options import Options
|
|
12
|
+
from mdl.core.resolve import resolve_run_options
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Handlers that require RunOptions
|
|
16
|
+
_RUN_HANDLERS: Dict[str, Callable] = {
|
|
17
|
+
"info": handle_info,
|
|
18
|
+
"smoke": handle_smoke,
|
|
19
|
+
"audio": handle_audio,
|
|
20
|
+
"video": handle_video,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Settings commands are handled without runtime resolution.
|
|
24
|
+
_SETTINGS = set(SETTINGS_COMMANDS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_app(args: argparse.Namespace) -> int:
|
|
28
|
+
"""
|
|
29
|
+
Application entrypoint (routing/orchestration).
|
|
30
|
+
|
|
31
|
+
Responsibilities:
|
|
32
|
+
- Convert argparse Namespace -> Options DTO
|
|
33
|
+
- Handle settings commands (no yt-dlp execution)
|
|
34
|
+
- Resolve RunOptions (config + defaults)
|
|
35
|
+
- Dispatch to the correct command handler
|
|
36
|
+
"""
|
|
37
|
+
opts = Options.from_namespace(args)
|
|
38
|
+
|
|
39
|
+
# Settings commands: no runtime resolution needed
|
|
40
|
+
if opts.command in _SETTINGS:
|
|
41
|
+
return handle_settings(opts)
|
|
42
|
+
|
|
43
|
+
# Resolve runtime options (config + defaults) for commands that invoke yt-dlp
|
|
44
|
+
run_opts = resolve_run_options(opts)
|
|
45
|
+
|
|
46
|
+
handler = _RUN_HANDLERS.get(opts.command)
|
|
47
|
+
if handler is None:
|
|
48
|
+
raise SystemExit(f"[mdl] ERROR: Unknown command '{opts.command}'.")
|
|
49
|
+
|
|
50
|
+
return handler(opts, run_opts)
|
mdl/builders/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
6
|
+
from mdl.core.config import Defaults
|
|
7
|
+
from mdl.core.options import RunOptions
|
|
8
|
+
from mdl.core.playlist import detect_playlist_layout, is_playlist_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_audio_command(url: str, opts: RunOptions) -> List[str]:
|
|
12
|
+
"""Build the yt-dlp command for best-quality audio downloads."""
|
|
13
|
+
is_playlist = is_playlist_url(url)
|
|
14
|
+
if is_playlist:
|
|
15
|
+
mode = str(getattr(opts, "playlist_mode", "auto")).strip().lower()
|
|
16
|
+
if mode == "album":
|
|
17
|
+
layout = "album"
|
|
18
|
+
elif mode == "flat":
|
|
19
|
+
layout = "flat"
|
|
20
|
+
else:
|
|
21
|
+
layout = detect_playlist_layout(url)
|
|
22
|
+
tpl = Defaults.audio_playlist_tpl if layout == "album" else Defaults.audio_playlist_flat_tpl
|
|
23
|
+
else:
|
|
24
|
+
tpl = Defaults.audio_single_tpl
|
|
25
|
+
out_template = str(opts.out_dir / tpl)
|
|
26
|
+
|
|
27
|
+
cmd: List[str] = ["yt-dlp"]
|
|
28
|
+
cmd += base_yt_dlp_args(opts)
|
|
29
|
+
|
|
30
|
+
cmd += ["-f", "bestaudio/best"]
|
|
31
|
+
cmd += ["-x", "--audio-format", opts.audio_format]
|
|
32
|
+
|
|
33
|
+
# Metadata (no quality impact)
|
|
34
|
+
cmd += ["--add-metadata", "--embed-metadata"]
|
|
35
|
+
|
|
36
|
+
if is_playlist:
|
|
37
|
+
# Normalize autogenerated labels like "Album - <name>" to "<name>".
|
|
38
|
+
cmd += ["--replace-in-metadata", "playlist_title", r"^Album -\s*", ""]
|
|
39
|
+
|
|
40
|
+
# Cover behavior (best-effort; actual fallback strategy is handled later)
|
|
41
|
+
if opts.cover:
|
|
42
|
+
cmd += ["--embed-thumbnail"]
|
|
43
|
+
|
|
44
|
+
# Robustness
|
|
45
|
+
cmd += ["--ignore-errors", "--continue", "--no-overwrites"]
|
|
46
|
+
|
|
47
|
+
cmd += ["-o", out_template, url]
|
|
48
|
+
return cmd
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from mdl.core.options import RunOptions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def base_yt_dlp_args(opts: RunOptions) -> List[str]:
|
|
9
|
+
"""Flags shared across all yt-dlp invocations."""
|
|
10
|
+
args: List[str] = []
|
|
11
|
+
|
|
12
|
+
if opts.cookies_from:
|
|
13
|
+
args += ["--cookies-from-browser", opts.cookies_from]
|
|
14
|
+
|
|
15
|
+
# Preset-derived throttling (only set in safe preset)
|
|
16
|
+
if opts.limit_rate:
|
|
17
|
+
args += ["--limit-rate", opts.limit_rate]
|
|
18
|
+
|
|
19
|
+
if opts.sleep_min is not None and opts.sleep_max is not None:
|
|
20
|
+
args += [
|
|
21
|
+
"--sleep-interval",
|
|
22
|
+
str(opts.sleep_min),
|
|
23
|
+
"--max-sleep-interval",
|
|
24
|
+
str(opts.sleep_max),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
return args
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
6
|
+
from mdl.core.options import RunOptions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_info_command(url: str, opts: RunOptions) -> List[str]:
|
|
10
|
+
"""
|
|
11
|
+
Build the yt-dlp command to display available formats for a URL.
|
|
12
|
+
|
|
13
|
+
Equivalent to: yt-dlp -F URL
|
|
14
|
+
"""
|
|
15
|
+
cmd: List[str] = ["yt-dlp"]
|
|
16
|
+
cmd += base_yt_dlp_args(opts)
|
|
17
|
+
cmd += ["-F", url]
|
|
18
|
+
return cmd
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from mdl.builders.yt_dlp_common import base_yt_dlp_args
|
|
6
|
+
from mdl.core.config import Defaults
|
|
7
|
+
from mdl.core.options import RunOptions
|
|
8
|
+
from mdl.core.playlist import is_playlist_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_video_command(url: str, opts: RunOptions) -> List[str]:
|
|
12
|
+
"""
|
|
13
|
+
Build the yt-dlp command for best-quality video.
|
|
14
|
+
|
|
15
|
+
Strategy:
|
|
16
|
+
- Always pick best video + best audio (no forced downgrade).
|
|
17
|
+
- Remux to the configured container (mp4/mkv) without re-encoding.
|
|
18
|
+
"""
|
|
19
|
+
is_playlist = is_playlist_url(url)
|
|
20
|
+
tpl = Defaults.video_playlist_flat_tpl if is_playlist else Defaults.video_single_tpl
|
|
21
|
+
out_template = str(opts.out_dir / tpl)
|
|
22
|
+
|
|
23
|
+
cmd: List[str] = ["yt-dlp"]
|
|
24
|
+
cmd += base_yt_dlp_args(opts)
|
|
25
|
+
|
|
26
|
+
# Best quality selection (avoid forcing container here)
|
|
27
|
+
cmd += ["-f", "bv*+ba/b"]
|
|
28
|
+
|
|
29
|
+
# Remux to the user-chosen container (safe subset: mp4|mkv)
|
|
30
|
+
cmd += ["--remux-video", opts.video_format]
|
|
31
|
+
|
|
32
|
+
if is_playlist:
|
|
33
|
+
# Normalize autogenerated labels like "Album - <name>" to "<name>".
|
|
34
|
+
cmd += ["--replace-in-metadata", "playlist_title", r"^Album -\s*", ""]
|
|
35
|
+
|
|
36
|
+
# Cover behavior (optional)
|
|
37
|
+
if opts.cover:
|
|
38
|
+
cmd += ["--write-thumbnail", "--embed-thumbnail"]
|
|
39
|
+
|
|
40
|
+
# Robustness
|
|
41
|
+
cmd += ["--ignore-errors", "--continue", "--no-overwrites"]
|
|
42
|
+
|
|
43
|
+
cmd += ["-o", out_template, url]
|
|
44
|
+
return cmd
|
mdl/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
from mdl import __version__
|
|
8
|
+
from mdl.app import run_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _add_download_flags(p: argparse.ArgumentParser) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Flags for download commands (audio/video).
|
|
14
|
+
Intentionally minimal: users should not tune rate/sleep/etc.
|
|
15
|
+
Presets handle that internally.
|
|
16
|
+
"""
|
|
17
|
+
p.add_argument("url", metavar="URL", help="Target URL (single item or playlist).")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _add_setting_value_arg(p: argparse.ArgumentParser, *, name: str, metavar: str = "VALUE") -> None:
|
|
21
|
+
"""
|
|
22
|
+
Adds an optional positional VALUE for setting commands:
|
|
23
|
+
- `mdl <setting>` shows the current value.
|
|
24
|
+
- `mdl <setting> <VALUE>` sets a new value.
|
|
25
|
+
"""
|
|
26
|
+
p.add_argument(name, nargs="?", default=None, metavar=metavar)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _add_print_flag(p: argparse.ArgumentParser) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Per-command print flag.
|
|
32
|
+
This makes `--print` valid AFTER the subcommand:
|
|
33
|
+
mdl audio URL --print
|
|
34
|
+
"""
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--print",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Print the final yt-dlp command and exit (no execution).",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _add_playlist_mode_flag(p: argparse.ArgumentParser) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Audio playlist folder layout strategy:
|
|
45
|
+
- auto: detect album-like playlists (OLAK...) and use album layout.
|
|
46
|
+
- album: force artist/channel + playlist folder layout.
|
|
47
|
+
- flat: force single playlist folder for all entries.
|
|
48
|
+
"""
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--mode",
|
|
51
|
+
dest="playlist_mode",
|
|
52
|
+
choices=("auto", "album", "flat"),
|
|
53
|
+
default="auto",
|
|
54
|
+
help="Playlist folder layout strategy (default: auto).",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
59
|
+
"""
|
|
60
|
+
CLI definition only. All business logic lives in mdl.app / core / commands.
|
|
61
|
+
"""
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="mdl",
|
|
64
|
+
description="mdl-cli: a human-friendly wrapper around yt-dlp.",
|
|
65
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
|
+
allow_abbrev=False,
|
|
67
|
+
epilog=(
|
|
68
|
+
"Examples:\n"
|
|
69
|
+
" mdl audio URL --print\n"
|
|
70
|
+
" mdl video URL --print\n"
|
|
71
|
+
" mdl audio URL --mode flat\n"
|
|
72
|
+
" mdl info URL\n"
|
|
73
|
+
" mdl smoke audio\n"
|
|
74
|
+
" mdl smoke video\n"
|
|
75
|
+
" mdl config\n"
|
|
76
|
+
" mdl out\n"
|
|
77
|
+
" mdl out ./downloads\n"
|
|
78
|
+
"\n"
|
|
79
|
+
"Settings:\n"
|
|
80
|
+
" mdl config\n"
|
|
81
|
+
" mdl out\n"
|
|
82
|
+
" mdl out ./downloads\n"
|
|
83
|
+
" mdl preset\n"
|
|
84
|
+
" mdl preset fast\n"
|
|
85
|
+
" mdl preset --list\n"
|
|
86
|
+
" mdl cookies brave\n"
|
|
87
|
+
" mdl cover on\n"
|
|
88
|
+
" mdl audio-format opus\n"
|
|
89
|
+
" mdl video-format mkv\n"
|
|
90
|
+
"\n"
|
|
91
|
+
"Tip:\n"
|
|
92
|
+
" During audio/video/smoke downloads, press Ctrl+C to cancel.\n"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--version",
|
|
98
|
+
action="version",
|
|
99
|
+
version=f"mdl {__version__}",
|
|
100
|
+
help="Show program version and exit.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
104
|
+
|
|
105
|
+
# Downloads
|
|
106
|
+
p_audio = subparsers.add_parser("audio", help="Download best-quality audio.")
|
|
107
|
+
_add_download_flags(p_audio)
|
|
108
|
+
_add_playlist_mode_flag(p_audio)
|
|
109
|
+
_add_print_flag(p_audio)
|
|
110
|
+
|
|
111
|
+
p_video = subparsers.add_parser("video", help="Download best-quality video.")
|
|
112
|
+
_add_download_flags(p_video)
|
|
113
|
+
_add_print_flag(p_video)
|
|
114
|
+
|
|
115
|
+
# Info
|
|
116
|
+
p_info = subparsers.add_parser("info", help="Show available formats for a URL (yt-dlp -F).")
|
|
117
|
+
p_info.add_argument("url", metavar="URL", help="Target URL to inspect (no download).")
|
|
118
|
+
|
|
119
|
+
# Smoke
|
|
120
|
+
p_smoke = subparsers.add_parser("smoke", help="Download a small sample to verify setup.")
|
|
121
|
+
smoke_sub = p_smoke.add_subparsers(dest="smoke_kind", required=True)
|
|
122
|
+
|
|
123
|
+
p_smoke_audio = smoke_sub.add_parser("audio", help="Smoke test: audio download.")
|
|
124
|
+
_add_print_flag(p_smoke_audio)
|
|
125
|
+
|
|
126
|
+
p_smoke_video = smoke_sub.add_parser("video", help="Smoke test: video download.")
|
|
127
|
+
_add_print_flag(p_smoke_video)
|
|
128
|
+
|
|
129
|
+
# Persistent settings (show on no arg, set on value, list on --list)
|
|
130
|
+
p_cover = subparsers.add_parser("cover", help="Configure cover behavior (thumbnail).")
|
|
131
|
+
p_cover.add_argument("--list", action="store_true", help="List allowed values.")
|
|
132
|
+
_add_setting_value_arg(p_cover, name="value", metavar="on|off")
|
|
133
|
+
|
|
134
|
+
p_cookies = subparsers.add_parser("cookies", help="Configure cookies browser (or disable).")
|
|
135
|
+
p_cookies.add_argument("--list", action="store_true", help="List allowed values.")
|
|
136
|
+
_add_setting_value_arg(p_cookies, name="value", metavar="BROWSER|none")
|
|
137
|
+
|
|
138
|
+
p_preset = subparsers.add_parser("preset", help="Configure the default preset (safe/fast).")
|
|
139
|
+
p_preset.add_argument("--list", action="store_true", help="List allowed values.")
|
|
140
|
+
_add_setting_value_arg(p_preset, name="value", metavar="safe|fast")
|
|
141
|
+
|
|
142
|
+
p_audio_fmt = subparsers.add_parser("audio-format", help="Configure default audio container.")
|
|
143
|
+
p_audio_fmt.add_argument("--list", action="store_true", help="List allowed values.")
|
|
144
|
+
_add_setting_value_arg(p_audio_fmt, name="value", metavar="opus|m4a|mp3|flac")
|
|
145
|
+
|
|
146
|
+
p_video_fmt = subparsers.add_parser("video-format", help="Configure default video container (remux).")
|
|
147
|
+
p_video_fmt.add_argument("--list", action="store_true", help="List allowed values.")
|
|
148
|
+
_add_setting_value_arg(p_video_fmt, name="value", metavar="mp4|mkv")
|
|
149
|
+
|
|
150
|
+
p_out = subparsers.add_parser("out", help="Configure default output base directory.")
|
|
151
|
+
_add_setting_value_arg(p_out, name="value", metavar="PATH")
|
|
152
|
+
|
|
153
|
+
subparsers.add_parser("config", help="Show the persistent config values.")
|
|
154
|
+
|
|
155
|
+
return parser
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
159
|
+
parser = build_parser()
|
|
160
|
+
|
|
161
|
+
# UX: if user runs just `mdl`, show help instead of an error.
|
|
162
|
+
if argv is None:
|
|
163
|
+
argv = sys.argv[1:]
|
|
164
|
+
if not argv:
|
|
165
|
+
parser.print_help()
|
|
166
|
+
raise SystemExit(0)
|
|
167
|
+
|
|
168
|
+
args = parser.parse_args(argv)
|
|
169
|
+
raise SystemExit(run_app(args))
|
mdl/commands/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from mdl.commands.audio import handle_audio
|
|
2
|
+
from mdl.commands.video import handle_video
|
|
3
|
+
from mdl.commands.info import handle_info
|
|
4
|
+
from mdl.commands.smoke import handle_smoke
|
|
5
|
+
from mdl.commands.settings import handle_settings
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"handle_audio",
|
|
9
|
+
"handle_video",
|
|
10
|
+
"handle_info",
|
|
11
|
+
"handle_smoke",
|
|
12
|
+
"handle_settings",
|
|
13
|
+
]
|
mdl/commands/audio.py
ADDED
mdl/commands/info.py
ADDED
mdl/commands/settings.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from mdl.core.config_store import (
|
|
8
|
+
describe_config_value,
|
|
9
|
+
list_allowed_values,
|
|
10
|
+
load_config,
|
|
11
|
+
save_config,
|
|
12
|
+
set_config_value,
|
|
13
|
+
SETTINGS_COMMANDS,
|
|
14
|
+
)
|
|
15
|
+
from mdl.core.options import Options
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _handle_setting(cmd: str, value: str | None, list_flag: bool) -> int:
|
|
19
|
+
"""
|
|
20
|
+
Settings UX:
|
|
21
|
+
- `mdl config`: show effective persistent config as JSON
|
|
22
|
+
- `mdl <cmd>`: show current value
|
|
23
|
+
- `mdl <cmd> <value>`: set value, persist, print confirmation
|
|
24
|
+
- `mdl <cmd> --list`: list allowed values
|
|
25
|
+
"""
|
|
26
|
+
if cmd not in SETTINGS_COMMANDS:
|
|
27
|
+
raise SystemExit(f"[mdl] ERROR: unknown settings command '{cmd}'.")
|
|
28
|
+
|
|
29
|
+
if cmd == "config":
|
|
30
|
+
cfg = load_config()
|
|
31
|
+
print(json.dumps(asdict(cfg), indent=2, sort_keys=True))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
if list_flag and cmd != "out":
|
|
35
|
+
values = list_allowed_values(cmd)
|
|
36
|
+
print(f"[mdl] {cmd} allowed: {', '.join(values)}")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
cfg = load_config()
|
|
40
|
+
|
|
41
|
+
if value is None:
|
|
42
|
+
current = describe_config_value(cfg, cmd)
|
|
43
|
+
print(f"[mdl] {cmd}: {current}")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
if cmd == "out" and not str(value).strip():
|
|
47
|
+
raise SystemExit("[mdl] ERROR: out requires a PATH")
|
|
48
|
+
|
|
49
|
+
cfg2 = set_config_value(cfg, cmd, value)
|
|
50
|
+
save_config(cfg2)
|
|
51
|
+
current = describe_config_value(cfg2, cmd)
|
|
52
|
+
print(f"[mdl] {cmd}: {current}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def handle_settings(opts: Options) -> int:
|
|
57
|
+
# `--print` makes sense for yt-dlp commands; settings don't execute anything.
|
|
58
|
+
if opts.print_cmd:
|
|
59
|
+
print("[mdl] NOTE: --print is ignored for settings commands.", file=sys.stderr)
|
|
60
|
+
return _handle_setting(opts.command, opts.value, opts.list_values)
|
mdl/commands/smoke.py
ADDED
mdl/commands/video.py
ADDED
mdl/core/__init__.py
ADDED
mdl/core/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _expand_path(raw: str) -> Path:
|
|
11
|
+
return Path(os.path.expandvars(raw)).expanduser()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _xdg_music_dir() -> Path:
|
|
15
|
+
"""
|
|
16
|
+
Best-effort: read XDG user dirs to find the real Music folder.
|
|
17
|
+
Falls back to ~/Music.
|
|
18
|
+
"""
|
|
19
|
+
cfg = Path.home() / ".config" / "user-dirs.dirs"
|
|
20
|
+
if cfg.exists():
|
|
21
|
+
txt = cfg.read_text(encoding="utf-8", errors="ignore")
|
|
22
|
+
|
|
23
|
+
# Typical line: XDG_MUSIC_DIR="$HOME/Music"
|
|
24
|
+
m = re.search(r'^XDG_MUSIC_DIR=(?P<q>["\'])(?P<val>.*?)(?P=q)\s*$', txt, re.MULTILINE)
|
|
25
|
+
if m:
|
|
26
|
+
val = m.group("val").strip()
|
|
27
|
+
|
|
28
|
+
# Support $HOME and ${HOME}
|
|
29
|
+
home = str(Path.home())
|
|
30
|
+
val = val.replace("$HOME", home).replace("${HOME}", home)
|
|
31
|
+
|
|
32
|
+
# Expand any remaining env vars and user markers.
|
|
33
|
+
return _expand_path(val)
|
|
34
|
+
|
|
35
|
+
return Path.home() / "Music"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_music_dir() -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Return the best default Music directory for the current platform.
|
|
41
|
+
Linux prefers XDG user-dirs; other platforms default to ~/Music.
|
|
42
|
+
"""
|
|
43
|
+
if sys.platform.startswith("linux"):
|
|
44
|
+
return _xdg_music_dir()
|
|
45
|
+
return Path.home() / "Music"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_config_dir(app_name: str = "mdl") -> Path:
|
|
49
|
+
"""
|
|
50
|
+
Return an OS-appropriate config directory.
|
|
51
|
+
- Linux/Unix: $XDG_CONFIG_HOME/<app> or ~/.config/<app>
|
|
52
|
+
- macOS/iOS: ~/Library/Application Support/<app>
|
|
53
|
+
- Windows: %APPDATA%\\<app> (fallback to %LOCALAPPDATA%\\<app>)
|
|
54
|
+
"""
|
|
55
|
+
if sys.platform == "win32":
|
|
56
|
+
base = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
|
|
57
|
+
if base:
|
|
58
|
+
return _expand_path(base) / app_name
|
|
59
|
+
return Path.home() / "AppData" / "Roaming" / app_name
|
|
60
|
+
|
|
61
|
+
if sys.platform == "darwin" or sys.platform.startswith("ios"):
|
|
62
|
+
return Path.home() / "Library" / "Application Support" / app_name
|
|
63
|
+
|
|
64
|
+
xdg_base = os.environ.get("XDG_CONFIG_HOME")
|
|
65
|
+
if xdg_base:
|
|
66
|
+
return _expand_path(xdg_base) / app_name
|
|
67
|
+
return Path.home() / ".config" / app_name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Defaults:
|
|
72
|
+
# Output
|
|
73
|
+
out_dir: Path = default_music_dir() / "mdl"
|
|
74
|
+
|
|
75
|
+
# Networking behavior (safe preset)
|
|
76
|
+
limit_rate: str = "1M"
|
|
77
|
+
sleep_min: int = 5
|
|
78
|
+
sleep_max: int = 15
|
|
79
|
+
|
|
80
|
+
# Output templates
|
|
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"
|
|
85
|
+
|
|
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"
|
|
89
|
+
|
|
90
|
+
video_single_tpl: str = "%(uploader|channel)s/%(title)s.%(ext)s"
|
mdl/core/config_store.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mdl.core.config import Defaults, default_config_dir
|
|
9
|
+
from mdl.core.options import AppConfig
|
|
10
|
+
|
|
11
|
+
# Overrideable for tests and advanced users.
|
|
12
|
+
_ENV_CONFIG_DIR = "MDL_CONFIG_DIR"
|
|
13
|
+
|
|
14
|
+
SETTINGS_COMMANDS = {"config", "cover", "cookies", "preset", "audio-format", "video-format", "out"}
|
|
15
|
+
|
|
16
|
+
ALLOWED = {
|
|
17
|
+
"cover": ["on", "off"],
|
|
18
|
+
"cookies": ["none", "brave", "chrome", "chromium", "firefox", "edge"],
|
|
19
|
+
"preset": ["safe", "fast"],
|
|
20
|
+
"audio-format": ["flac", "mp3", "opus", "m4a"],
|
|
21
|
+
"video-format": ["mp4", "mkv"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_config() -> AppConfig:
|
|
26
|
+
# Default audio format: m4a (YouTube typically provides AAC in m4a or Opus in webm;
|
|
27
|
+
# "flac" outputs are transcodes, not true source FLAC).
|
|
28
|
+
return AppConfig(
|
|
29
|
+
preset="safe",
|
|
30
|
+
cookies="none",
|
|
31
|
+
cover=False,
|
|
32
|
+
audio_format="m4a",
|
|
33
|
+
video_format="mp4",
|
|
34
|
+
out_dir=_default_out_dir(),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config_dir() -> Path:
|
|
39
|
+
raw = os.environ.get(_ENV_CONFIG_DIR)
|
|
40
|
+
if raw:
|
|
41
|
+
return _expand_path(raw)
|
|
42
|
+
return default_config_dir("mdl")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _config_file() -> Path:
|
|
46
|
+
return _config_dir() / "config.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config() -> AppConfig:
|
|
50
|
+
cfg_file = _config_file()
|
|
51
|
+
if not cfg_file.exists():
|
|
52
|
+
return default_config()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return default_config()
|
|
58
|
+
|
|
59
|
+
cfg = default_config()
|
|
60
|
+
|
|
61
|
+
preset = _norm_str(data.get("preset", cfg.preset))
|
|
62
|
+
cookies = _norm_str(data.get("cookies", cfg.cookies))
|
|
63
|
+
cover = bool(data.get("cover", cfg.cover))
|
|
64
|
+
audio_format = _norm_str(data.get("audio_format", cfg.audio_format))
|
|
65
|
+
video_format = _norm_str(data.get("video_format", cfg.video_format))
|
|
66
|
+
out_dir = _normalize_out_dir(data.get("out_dir", cfg.out_dir), cfg.out_dir)
|
|
67
|
+
|
|
68
|
+
preset = preset if preset in ALLOWED["preset"] else cfg.preset
|
|
69
|
+
cookies = cookies if cookies in ALLOWED["cookies"] else cfg.cookies
|
|
70
|
+
audio_format = audio_format if audio_format in ALLOWED["audio-format"] else cfg.audio_format
|
|
71
|
+
video_format = video_format if video_format in ALLOWED["video-format"] else cfg.video_format
|
|
72
|
+
|
|
73
|
+
return AppConfig(
|
|
74
|
+
preset=preset,
|
|
75
|
+
cookies=cookies,
|
|
76
|
+
cover=cover,
|
|
77
|
+
audio_format=audio_format,
|
|
78
|
+
video_format=video_format,
|
|
79
|
+
out_dir=out_dir,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(cfg: AppConfig) -> None:
|
|
84
|
+
cfg_dir = _config_dir()
|
|
85
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
payload = asdict(cfg)
|
|
87
|
+
_config_file().write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_allowed_values(setting: str) -> list[str]:
|
|
91
|
+
setting = _norm_str(setting)
|
|
92
|
+
if setting not in ALLOWED:
|
|
93
|
+
raise SystemExit(f"[mdl] ERROR: unknown setting '{setting}'.")
|
|
94
|
+
return list(ALLOWED[setting])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def describe_config_value(cfg: AppConfig, setting: str) -> str:
|
|
98
|
+
setting = _norm_str(setting)
|
|
99
|
+
if setting == "cover":
|
|
100
|
+
return "on" if cfg.cover else "off"
|
|
101
|
+
if setting == "cookies":
|
|
102
|
+
return cfg.cookies
|
|
103
|
+
if setting == "preset":
|
|
104
|
+
return cfg.preset
|
|
105
|
+
if setting == "audio-format":
|
|
106
|
+
return cfg.audio_format
|
|
107
|
+
if setting == "video-format":
|
|
108
|
+
return cfg.video_format
|
|
109
|
+
if setting == "out":
|
|
110
|
+
return cfg.out_dir
|
|
111
|
+
raise SystemExit(f"[mdl] ERROR: unknown setting '{setting}'.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_config_value(cfg: AppConfig, setting: str, value: str) -> AppConfig:
|
|
115
|
+
setting = _norm_str(setting)
|
|
116
|
+
|
|
117
|
+
if setting == "out":
|
|
118
|
+
value_s = str(value).strip()
|
|
119
|
+
if not value_s:
|
|
120
|
+
raise SystemExit("[mdl] ERROR: out requires a PATH")
|
|
121
|
+
out_dir = _normalize_out_dir(value_s, "")
|
|
122
|
+
if not out_dir:
|
|
123
|
+
raise SystemExit("[mdl] ERROR: out requires a PATH")
|
|
124
|
+
return replace(cfg, out_dir=out_dir)
|
|
125
|
+
|
|
126
|
+
value_n = _norm_str(value)
|
|
127
|
+
|
|
128
|
+
if setting not in ALLOWED:
|
|
129
|
+
raise SystemExit(f"[mdl] ERROR: unknown setting '{setting}'.")
|
|
130
|
+
|
|
131
|
+
if value_n not in ALLOWED[setting]:
|
|
132
|
+
allowed = ", ".join(ALLOWED[setting])
|
|
133
|
+
raise SystemExit(f"[mdl] ERROR: invalid value '{value}'. Allowed: {allowed}")
|
|
134
|
+
|
|
135
|
+
if setting == "cover":
|
|
136
|
+
return replace(cfg, cover=(value_n == "on"))
|
|
137
|
+
if setting == "cookies":
|
|
138
|
+
return replace(cfg, cookies=value_n)
|
|
139
|
+
if setting == "preset":
|
|
140
|
+
return replace(cfg, preset=value_n)
|
|
141
|
+
if setting == "audio-format":
|
|
142
|
+
return replace(cfg, audio_format=value_n)
|
|
143
|
+
if setting == "video-format":
|
|
144
|
+
return replace(cfg, video_format=value_n)
|
|
145
|
+
|
|
146
|
+
# unreachable
|
|
147
|
+
raise SystemExit(f"[mdl] ERROR: unknown setting '{setting}'.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _norm_str(x: object) -> str:
|
|
151
|
+
return str(x).strip().lower()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _expand_path(value: str) -> Path:
|
|
155
|
+
return Path(os.path.expandvars(value)).expanduser()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _default_out_dir() -> str:
|
|
159
|
+
try:
|
|
160
|
+
return str(Path(Defaults.out_dir).resolve())
|
|
161
|
+
except Exception:
|
|
162
|
+
return str(Path(Defaults.out_dir))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _normalize_out_dir(raw: object, fallback: str) -> str:
|
|
166
|
+
try:
|
|
167
|
+
value = str(raw).strip()
|
|
168
|
+
except Exception:
|
|
169
|
+
return fallback
|
|
170
|
+
if not value:
|
|
171
|
+
return fallback
|
|
172
|
+
try:
|
|
173
|
+
return str(_expand_path(value).resolve())
|
|
174
|
+
except Exception:
|
|
175
|
+
return fallback
|
mdl/core/options.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Options:
|
|
10
|
+
"""
|
|
11
|
+
Raw CLI options (argparse output). Some fields are optional because not all
|
|
12
|
+
subcommands require them.
|
|
13
|
+
"""
|
|
14
|
+
command: str
|
|
15
|
+
print_cmd: bool
|
|
16
|
+
|
|
17
|
+
# Download/info
|
|
18
|
+
url: Optional[str]
|
|
19
|
+
playlist_mode: str # "auto" | "album" | "flat"
|
|
20
|
+
|
|
21
|
+
# Smoke
|
|
22
|
+
smoke_kind: Optional[str] # "audio" | "video"
|
|
23
|
+
|
|
24
|
+
# Settings commands
|
|
25
|
+
list_values: bool # --list
|
|
26
|
+
value: Optional[str] # optional positional VALUE for settings
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def from_namespace(ns) -> "Options":
|
|
30
|
+
# In cli.py you used --print (dest defaults to "print").
|
|
31
|
+
# We store it internally as print_cmd to avoid using name "print".
|
|
32
|
+
return Options(
|
|
33
|
+
command=str(ns.command),
|
|
34
|
+
print_cmd=bool(getattr(ns, "print", False)),
|
|
35
|
+
|
|
36
|
+
url=(str(ns.url) if hasattr(ns, "url") else None),
|
|
37
|
+
playlist_mode=str(getattr(ns, "playlist_mode", "auto")),
|
|
38
|
+
|
|
39
|
+
smoke_kind=(
|
|
40
|
+
str(ns.smoke_kind)
|
|
41
|
+
if hasattr(ns, "smoke_kind") and ns.smoke_kind is not None
|
|
42
|
+
else None
|
|
43
|
+
),
|
|
44
|
+
|
|
45
|
+
list_values=bool(getattr(ns, "list", False)),
|
|
46
|
+
value=(str(ns.value) if hasattr(ns, "value") and ns.value is not None else None),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class AppConfig:
|
|
52
|
+
"""
|
|
53
|
+
Persistent user configuration.
|
|
54
|
+
Values should be normalized (lowercase).
|
|
55
|
+
"""
|
|
56
|
+
preset: str # "safe" | "fast"
|
|
57
|
+
cookies: str # "none" | "brave" | "chrome" | ...
|
|
58
|
+
cover: bool # cover behavior enabled
|
|
59
|
+
audio_format: str # "flac" | "mp3" | "opus" | "m4a"
|
|
60
|
+
video_format: str # "mp4" | "mkv"
|
|
61
|
+
out_dir: str # absolute base output path as string
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RunOptions:
|
|
66
|
+
"""
|
|
67
|
+
Resolved runtime options used by builders/runner.
|
|
68
|
+
Combines Defaults + AppConfig + command context.
|
|
69
|
+
|
|
70
|
+
Invariants:
|
|
71
|
+
- out_dir is defined (downloads)
|
|
72
|
+
- cookies_from is None when cookies are disabled
|
|
73
|
+
- limit_rate/sleep* are only set in safe preset
|
|
74
|
+
"""
|
|
75
|
+
out_dir: Path
|
|
76
|
+
preset: str
|
|
77
|
+
cookies_from: Optional[str]
|
|
78
|
+
cover: bool
|
|
79
|
+
audio_format: str
|
|
80
|
+
video_format: str
|
|
81
|
+
|
|
82
|
+
# Internal network throttling derived from preset (not user-exposed)
|
|
83
|
+
limit_rate: Optional[str]
|
|
84
|
+
sleep_min: Optional[int]
|
|
85
|
+
sleep_max: Optional[int]
|
|
86
|
+
playlist_mode: str = "auto"
|
mdl/core/playlist.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import parse_qs, urlparse
|
|
4
|
+
|
|
5
|
+
def is_playlist_url(url: str) -> bool:
|
|
6
|
+
"""
|
|
7
|
+
Best-effort heuristic used only for output templates.
|
|
8
|
+
yt-dlp remains the source of truth for what gets downloaded.
|
|
9
|
+
"""
|
|
10
|
+
return "list=" in url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def detect_playlist_layout(url: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Return the playlist output layout:
|
|
16
|
+
- "album": artist/channel folder + playlist folder
|
|
17
|
+
- "flat": single playlist folder
|
|
18
|
+
|
|
19
|
+
Rules:
|
|
20
|
+
- Non-playlist URLs => "flat" (layout is irrelevant for singles).
|
|
21
|
+
- YouTube Music album list IDs typically start with "OLAK".
|
|
22
|
+
"""
|
|
23
|
+
if not is_playlist_url(url):
|
|
24
|
+
return "flat"
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
query = parse_qs(urlparse(url).query)
|
|
28
|
+
list_ids = query.get("list", [])
|
|
29
|
+
except Exception:
|
|
30
|
+
list_ids = []
|
|
31
|
+
|
|
32
|
+
list_id = str(list_ids[0]).strip() if list_ids else ""
|
|
33
|
+
if list_id.upper().startswith("OLAK"):
|
|
34
|
+
return "album"
|
|
35
|
+
return "flat"
|
mdl/core/resolve.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mdl.core.config import Defaults
|
|
7
|
+
from mdl.core.config_store import load_config
|
|
8
|
+
from mdl.core.options import Options, RunOptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_run_options(opts: Options) -> RunOptions:
|
|
12
|
+
"""
|
|
13
|
+
Resolve runtime options from persisted config + Defaults.
|
|
14
|
+
|
|
15
|
+
Note:
|
|
16
|
+
- We intentionally do NOT expose rate/sleep/js runtime/remote components to the user.
|
|
17
|
+
Presets control internal throttling.
|
|
18
|
+
"""
|
|
19
|
+
cfg = load_config()
|
|
20
|
+
|
|
21
|
+
out_raw = str(getattr(cfg, "out_dir", "")).strip()
|
|
22
|
+
out_value = out_raw if out_raw else str(Defaults.out_dir)
|
|
23
|
+
base_out = Path(os.path.expandvars(out_value)).expanduser()
|
|
24
|
+
try:
|
|
25
|
+
out_dir = base_out.resolve()
|
|
26
|
+
except Exception:
|
|
27
|
+
out_dir = Path(str(Defaults.out_dir)).expanduser().resolve()
|
|
28
|
+
|
|
29
|
+
preset = str(cfg.preset).strip().lower()
|
|
30
|
+
|
|
31
|
+
# Cookies: "none" disables cookies integration
|
|
32
|
+
cookies = str(cfg.cookies).strip().lower()
|
|
33
|
+
cookies_from = None if cookies == "none" else cookies
|
|
34
|
+
|
|
35
|
+
# Preset -> internal throttling (not user-visible)
|
|
36
|
+
if preset == "safe":
|
|
37
|
+
limit_rate = str(Defaults.limit_rate)
|
|
38
|
+
sleep_min = int(Defaults.sleep_min)
|
|
39
|
+
sleep_max = int(Defaults.sleep_max)
|
|
40
|
+
elif preset == "fast":
|
|
41
|
+
limit_rate = None
|
|
42
|
+
sleep_min = None
|
|
43
|
+
sleep_max = None
|
|
44
|
+
else:
|
|
45
|
+
# Defensive fallback: treat unknown preset as safe
|
|
46
|
+
preset = "safe"
|
|
47
|
+
limit_rate = str(Defaults.limit_rate)
|
|
48
|
+
sleep_min = int(Defaults.sleep_min)
|
|
49
|
+
sleep_max = int(Defaults.sleep_max)
|
|
50
|
+
|
|
51
|
+
# Defensive sanity checks (even though user can't set these via CLI)
|
|
52
|
+
if preset == "safe":
|
|
53
|
+
if sleep_min is None or sleep_max is None or sleep_min <= 0 or sleep_max <= 0:
|
|
54
|
+
raise SystemExit("[mdl] ERROR: internal sleep settings are invalid.")
|
|
55
|
+
if sleep_min > sleep_max:
|
|
56
|
+
raise SystemExit("[mdl] ERROR: internal sleep-min must be <= sleep-max.")
|
|
57
|
+
|
|
58
|
+
playlist_mode = str(getattr(opts, "playlist_mode", "auto")).strip().lower()
|
|
59
|
+
if playlist_mode not in {"auto", "album", "flat"}:
|
|
60
|
+
playlist_mode = "auto"
|
|
61
|
+
|
|
62
|
+
return RunOptions(
|
|
63
|
+
out_dir=out_dir,
|
|
64
|
+
preset=preset,
|
|
65
|
+
cookies_from=cookies_from,
|
|
66
|
+
cover=bool(cfg.cover),
|
|
67
|
+
audio_format=str(cfg.audio_format).strip().lower(),
|
|
68
|
+
video_format=str(cfg.video_format).strip().lower(),
|
|
69
|
+
limit_rate=limit_rate,
|
|
70
|
+
sleep_min=sleep_min,
|
|
71
|
+
sleep_max=sleep_max,
|
|
72
|
+
playlist_mode=playlist_mode,
|
|
73
|
+
)
|
mdl/infra/__init__.py
ADDED
mdl/infra/runner.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _command_exists(name: str) -> bool:
|
|
12
|
+
return shutil.which(name) is not None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def printable_cmd(cmd: List[str]) -> str:
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
return subprocess.list2cmdline(cmd)
|
|
18
|
+
return " ".join(shlex.quote(s) for s in cmd)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_command(cmd: List[str]) -> None:
|
|
22
|
+
print(f"[mdl] exec: {printable_cmd(cmd)}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
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)
|
|
30
|
+
return 127
|
|
31
|
+
|
|
32
|
+
if needs_ffmpeg and not _command_exists("ffmpeg"):
|
|
33
|
+
print("[mdl] ERROR: ffmpeg not found in PATH.", file=sys.stderr)
|
|
34
|
+
print("[mdl] Install it (Ubuntu/Debian): sudo apt install ffmpeg", file=sys.stderr)
|
|
35
|
+
print("[mdl] Or ensure it is available in your PATH.", file=sys.stderr)
|
|
36
|
+
return 127
|
|
37
|
+
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_command(cmd: List[str], *, needs_ffmpeg: bool = False, print_first: bool = True) -> int:
|
|
42
|
+
"""
|
|
43
|
+
Runs the given command, streaming stdout/stderr.
|
|
44
|
+
Returns the subprocess exit code.
|
|
45
|
+
"""
|
|
46
|
+
dep_rc = _check_dependencies(needs_ffmpeg=needs_ffmpeg)
|
|
47
|
+
if dep_rc != 0:
|
|
48
|
+
# Keep transparency: still show what would have been executed.
|
|
49
|
+
if print_first:
|
|
50
|
+
print_command(cmd)
|
|
51
|
+
return dep_rc
|
|
52
|
+
|
|
53
|
+
if print_first:
|
|
54
|
+
print_command(cmd)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
p = subprocess.run(cmd)
|
|
58
|
+
return int(p.returncode)
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
return 130
|
mdl/services/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from mdl.services.download_service import (
|
|
2
|
+
require_url,
|
|
3
|
+
run_audio_download,
|
|
4
|
+
run_video_download,
|
|
5
|
+
run_info,
|
|
6
|
+
run_smoke,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"require_url",
|
|
11
|
+
"run_audio_download",
|
|
12
|
+
"run_video_download",
|
|
13
|
+
"run_info",
|
|
14
|
+
"run_smoke",
|
|
15
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mdl.builders.yt_dlp_audio import build_audio_command
|
|
4
|
+
from mdl.builders.yt_dlp_video import build_video_command
|
|
5
|
+
from mdl.builders.yt_dlp_info import build_info_command
|
|
6
|
+
from mdl.core.options import Options, RunOptions
|
|
7
|
+
from mdl.infra.runner import print_command, run_command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Deterministic smoke targets used to verify the full download pipeline.
|
|
11
|
+
SMOKE_AUDIO_URL = "https://www.youtube.com/watch?v=dWRCooFKk3c"
|
|
12
|
+
SMOKE_VIDEO_URL = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
|
|
13
|
+
|
|
14
|
+
def require_url(opts: Options) -> str:
|
|
15
|
+
if not opts.url:
|
|
16
|
+
cmd = opts.command or "command"
|
|
17
|
+
raise SystemExit(f"[mdl] ERROR: {cmd} requires URL. Try: mdl {cmd} -h")
|
|
18
|
+
return opts.url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_or_print(opts: Options, cmd: list[str], *, needs_ffmpeg: bool = False) -> int:
|
|
22
|
+
if opts.print_cmd:
|
|
23
|
+
print_command(cmd)
|
|
24
|
+
return 0
|
|
25
|
+
return run_command(cmd, needs_ffmpeg=needs_ffmpeg)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_audio_download(opts: Options, run_opts: RunOptions) -> int:
|
|
29
|
+
url = require_url(opts)
|
|
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)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_video_download(opts: Options, run_opts: RunOptions) -> int:
|
|
36
|
+
url = require_url(opts)
|
|
37
|
+
cmd = build_video_command(url, run_opts)
|
|
38
|
+
needs_ffmpeg = run_opts.cover
|
|
39
|
+
return _run_or_print(opts, cmd, needs_ffmpeg=needs_ffmpeg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_info(opts: Options, run_opts: RunOptions) -> int:
|
|
43
|
+
url = require_url(opts)
|
|
44
|
+
cmd = build_info_command(url, run_opts)
|
|
45
|
+
return _run_or_print(opts, cmd)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_smoke(opts: Options, run_opts: RunOptions) -> int:
|
|
49
|
+
if opts.smoke_kind == "audio":
|
|
50
|
+
cmd = build_audio_command(SMOKE_AUDIO_URL, run_opts)
|
|
51
|
+
elif opts.smoke_kind == "video":
|
|
52
|
+
cmd = build_video_command(SMOKE_VIDEO_URL, run_opts)
|
|
53
|
+
else:
|
|
54
|
+
raise SystemExit("[mdl] ERROR: Unknown smoke kind (expected: audio|video).")
|
|
55
|
+
|
|
56
|
+
if opts.print_cmd:
|
|
57
|
+
print_command(cmd)
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
return run_command(cmd, needs_ffmpeg=run_opts.cover)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdl-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A human-friendly wrapper over yt-dlp.
|
|
5
|
+
Author: Tomás Ezequiel Aguirre
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Tomás Ezequiel Aguirre
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Repository, https://github.com/tomieaguirre/mdl-cli
|
|
29
|
+
Keywords: yt-dlp,downloader,cli,audio,video
|
|
30
|
+
Classifier: Development Status :: 3 - Alpha
|
|
31
|
+
Classifier: Environment :: Console
|
|
32
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: yt-dlp>=2024.0
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
47
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
[](https://www.python.org/)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
[](https://github.com/tomieaguirre/mdl-cli/actions/workflows/ci.yml?query=branch%3Amain)
|
|
53
|
+
|
|
54
|
+
# mdl-cli
|
|
55
|
+
|
|
56
|
+
`mdl` is a cross-platform CLI wrapper around `yt-dlp` with safer defaults and a simpler command surface.
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Python `>=3.10`
|
|
61
|
+
- `ffmpeg` available in `PATH`
|
|
62
|
+
|
|
63
|
+
`mdl` executes `yt-dlp` under the hood. Ensure `yt-dlp` is installed and available in `PATH`.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
Recommended (isolated app install):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pipx install mdl-cli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Alternative (standard Python environment):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install mdl-cli
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For OS-specific setup (Python, `ffmpeg`, `yt-dlp`, and verification), see the [Installation Guide](docs/installation.md).
|
|
80
|
+
|
|
81
|
+
## Basic Usage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
mdl audio "URL"
|
|
85
|
+
mdl video "URL"
|
|
86
|
+
mdl info "URL"
|
|
87
|
+
mdl --help
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Supported platforms: Linux, macOS, and Windows.
|
|
91
|
+
|
|
92
|
+
## Documentation
|
|
93
|
+
|
|
94
|
+
Full command reference and advanced configuration:
|
|
95
|
+
- [Installation Guide](docs/installation.md)
|
|
96
|
+
- [Usage Guide](docs/usage.md)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT (see [License](LICENSE))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
mdl/__init__.py,sha256=-WXI6ePnpJ1-ibRIS9bWyTzx9KKgoWBdHznjRVI9ImE,195
|
|
2
|
+
mdl/app.py,sha256=hXhGTZQXi1MYcyr6ZwathMGve4J7lemzgGOJiFZsX98,1500
|
|
3
|
+
mdl/cli.py,sha256=WIJ1h-US3neUaQE4X5FZC2uvCtgQyUgss7A2KK2maxY,6076
|
|
4
|
+
mdl/builders/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mdl/builders/yt_dlp_audio.py,sha256=M3Jll8fwxA67v6oOk5Mv-Xu9jvnly0jfObZk3SZIIX4,1597
|
|
6
|
+
mdl/builders/yt_dlp_common.py,sha256=V_sA7hMkfSI87I72TtkT_DoKBvWZd09VkXolzSuYbKU,703
|
|
7
|
+
mdl/builders/yt_dlp_info.py,sha256=nzHA84NDTR5UqN-PX39PXe_D01-S4WRrZ0C2_1VG9k4,447
|
|
8
|
+
mdl/builders/yt_dlp_video.py,sha256=aurNJtOWjWqvvSswozTNX0p7M6bQ9QGnZw31tW012Aw,1391
|
|
9
|
+
mdl/commands/__init__.py,sha256=bAScGj3QFhyjaHOQMWKU7_O_f8h0qU02VR_B1PQ-Kdc,341
|
|
10
|
+
mdl/commands/audio.py,sha256=lWsbrx3R2GqviYdOjZ3tXBFacl6biChWRQNQOyRrj44,256
|
|
11
|
+
mdl/commands/info.py,sha256=a5ZG3hm2IzJ2slGSM7cOWjNlFCl-xHWcMLsBqWaAGOw,235
|
|
12
|
+
mdl/commands/settings.py,sha256=sVXItqq0l97FCmhI6MvAV04VmaLVBKSuVLQa1IwAyZg,1759
|
|
13
|
+
mdl/commands/smoke.py,sha256=2vUhaaoWy21HgmBRxfPw6BvpRqkUn4T62oNpK80BgCM,238
|
|
14
|
+
mdl/commands/video.py,sha256=pNJOQ3TSYmYg55THeE2IlUvvbO4NaDUzlvC51LdLlnQ,256
|
|
15
|
+
mdl/core/__init__.py,sha256=BUr4rZXeKtOdrEol8DosJyqvvfOJrtn2W0H0LR8cMn8,254
|
|
16
|
+
mdl/core/config.py,sha256=r1XE-butXZjGRyZn4h6glRGLdz18BF7RdOssITxT5as,3119
|
|
17
|
+
mdl/core/config_store.py,sha256=0AsZlxLJMv7icdY-1lKCZAT_ieX2bN3xC1Ch4eNyCHs,5284
|
|
18
|
+
mdl/core/options.py,sha256=sDXotvtQF_FcJtjZPWh6oZW8RxoXcukmRJVR3roRqt8,2554
|
|
19
|
+
mdl/core/playlist.py,sha256=F5uEsXH0GJcC-qtD_2VRd0qlBzyZSJlAMYzU9AOC-R4,965
|
|
20
|
+
mdl/core/resolve.py,sha256=9DLjKOJB1AEIpRHgsE2XGHhkX9wklqedCmiBVfXsefo,2518
|
|
21
|
+
mdl/infra/__init__.py,sha256=BwTI-hcx54GhGDV_Cn7HCtaoD-9xzu4dqPSdatKy0K0,132
|
|
22
|
+
mdl/infra/runner.py,sha256=qOOcihmFcSaEGLFNXrSpYhH-p10WtJFQ-SjRjDq1U4s,1784
|
|
23
|
+
mdl/services/__init__.py,sha256=mA-LZnHeLheDppY5M9rv6CTmq94Ca61c8BogOVVJRhs,259
|
|
24
|
+
mdl/services/download_service.py,sha256=_OrJqqa9kOs2oZz049sCwWgFi5DzIZe0XKszvLsLTpA,2053
|
|
25
|
+
mdl_cli-0.3.0.dist-info/licenses/LICENSE,sha256=C8VyrqwLAOtNBP_TH_AlBzitg0vcM0tZGm0TDDdI570,1080
|
|
26
|
+
mdl_cli-0.3.0.dist-info/METADATA,sha256=rFajLOAECejXQK8vjK8oxclIZo3-lcbxkby93nw_Vvw,3485
|
|
27
|
+
mdl_cli-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
28
|
+
mdl_cli-0.3.0.dist-info/entry_points.txt,sha256=7u1H60O6FKjpQHfk6r9ft-Tt6RHYyOcoFI4lgBqgW2s,37
|
|
29
|
+
mdl_cli-0.3.0.dist-info/top_level.txt,sha256=XzUJeQdNE2K7a6zWv5T4yj0nrs0wTr7KloMHefVnvr8,4
|
|
30
|
+
mdl_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomás Ezequiel Aguirre
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mdl
|