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 ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("mdl-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
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)
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))
@@ -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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from mdl.core.options import Options, RunOptions
4
+ from mdl.services.download_service import run_audio_download
5
+
6
+
7
+ def handle_audio(opts: Options, run_opts: RunOptions) -> int:
8
+ return run_audio_download(opts, run_opts)
mdl/commands/info.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from mdl.core.options import Options, RunOptions
4
+ from mdl.services.download_service import run_info
5
+
6
+
7
+ def handle_info(opts: Options, run_opts: RunOptions) -> int:
8
+ return run_info(opts, run_opts)
@@ -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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from mdl.core.options import Options, RunOptions
4
+ from mdl.services.download_service import run_smoke
5
+
6
+
7
+ def handle_smoke(opts: Options, run_opts: RunOptions) -> int:
8
+ return run_smoke(opts, run_opts)
mdl/commands/video.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from mdl.core.options import Options, RunOptions
4
+ from mdl.services.download_service import run_video_download
5
+
6
+
7
+ def handle_video(opts: Options, run_opts: RunOptions) -> int:
8
+ return run_video_download(opts, run_opts)
mdl/core/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from mdl.core.options import Options, AppConfig, RunOptions
2
+ from mdl.core.config import Defaults
3
+ from mdl.core.resolve import resolve_run_options
4
+
5
+ __all__ = [
6
+ "Options",
7
+ "AppConfig",
8
+ "RunOptions",
9
+ "Defaults",
10
+ "resolve_run_options",
11
+ ]
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"
@@ -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
@@ -0,0 +1,3 @@
1
+ from mdl.infra.runner import printable_cmd, print_command, run_command
2
+
3
+ __all__ = ["printable_cmd", "print_command", "run_command"]
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
@@ -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
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
51
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
52
+ [![CI](https://github.com/tomieaguirre/mdl-cli/actions/workflows/ci.yml/badge.svg?branch=main)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mdl = mdl.cli:main
@@ -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