This commit is contained in:
2026-01-27 14:56:01 -08:00
parent 334841dcfa
commit a44b80fd1d
7 changed files with 253 additions and 109 deletions

View File

@@ -22,7 +22,7 @@
"((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)" "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)"
], ],
"regexp": "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)", "regexp": "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)",
"status": false "status": true
}, },
"rapidgator": { "rapidgator": {
"name": "rapidgator", "name": "rapidgator",
@@ -339,7 +339,7 @@
"(file\\.al/[0-9a-zA-Z]{12})" "(file\\.al/[0-9a-zA-Z]{12})"
], ],
"regexp": "(file\\.al/[0-9a-zA-Z]{12})", "regexp": "(file\\.al/[0-9a-zA-Z]{12})",
"status": false "status": true
}, },
"filedot": { "filedot": {
"name": "filedot", "name": "filedot",
@@ -477,7 +477,7 @@
"isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12})" "isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12})"
], ],
"regexp": "((isra\\.cloud/[0-9a-zA-Z]{12}))|(isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12}))", "regexp": "((isra\\.cloud/[0-9a-zA-Z]{12}))|(isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12}))",
"status": false, "status": true,
"hardRedirect": [ "hardRedirect": [
"isra\\.cloud/([0-9a-zA-Z]{12})" "isra\\.cloud/([0-9a-zA-Z]{12})"
] ]
@@ -507,7 +507,7 @@
"mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})" "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})"
], ],
"regexp": "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})", "regexp": "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})",
"status": true "status": false
}, },
"mexashare": { "mexashare": {
"name": "mexashare", "name": "mexashare",

View File

@@ -126,8 +126,17 @@ def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
provider_cfg = config.get("provider") provider_cfg = config.get("provider")
if not isinstance(provider_cfg, dict): if not isinstance(provider_cfg, dict):
return {} return {}
block = provider_cfg.get(str(name).strip().lower()) normalized = _normalize_provider_name(name)
return block if isinstance(block, dict) else {} if normalized:
block = provider_cfg.get(normalized)
if isinstance(block, dict):
return block
for key, block in provider_cfg.items():
if not isinstance(block, dict):
continue
if _normalize_provider_name(key) == normalized:
return block
return {}
def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]: def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]:
@@ -334,6 +343,77 @@ def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]:
path = Path.cwd() / path path = Path.cwd() / path
return path return path
def _normalize_provider_name(value: Any) -> Optional[str]:
candidate = str(value or "").strip().lower()
return candidate if candidate else None
def _extract_api_key(value: Any) -> Optional[str]:
if isinstance(value, dict):
for key in ("api_key", "API_KEY", "apikey", "APIKEY"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
elif isinstance(value, str):
trimmed = value.strip()
if trimmed:
return trimmed
return None
def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None:
if not isinstance(config, dict):
return
providers = config.get("provider")
if not isinstance(providers, dict):
providers = {}
config["provider"] = providers
provider_entry = providers.get("alldebrid")
provider_section: Dict[str, Any] | None = None
provider_key = None
if isinstance(provider_entry, dict):
provider_section = provider_entry
provider_key = _extract_api_key(provider_section)
elif isinstance(provider_entry, str):
provider_key = provider_entry.strip()
if provider_key:
provider_section = {"api_key": provider_key}
providers["alldebrid"] = provider_section
store_block = config.get("store")
if not isinstance(store_block, dict):
store_block = {}
config["store"] = store_block
debrid_block = store_block.get("debrid")
store_key = None
if isinstance(debrid_block, dict):
service_entry = debrid_block.get("all-debrid")
if isinstance(service_entry, dict):
store_key = _extract_api_key(service_entry)
elif isinstance(service_entry, str):
store_key = service_entry.strip()
if store_key:
debrid_block["all-debrid"] = {"api_key": store_key}
else:
debrid_block = None
if provider_key:
if debrid_block is None:
debrid_block = {}
store_block["debrid"] = debrid_block
service_section = debrid_block.get("all-debrid")
if not isinstance(service_section, dict):
service_section = {}
debrid_block["all-debrid"] = service_section
service_section["api_key"] = provider_key
elif store_key:
if provider_section is None:
provider_section = {}
providers["alldebrid"] = provider_section
provider_section["api_key"] = store_key
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]: def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
entries: Dict[Tuple[str, str, str, str], Any] = {} entries: Dict[Tuple[str, str, str, str], Any] = {}
@@ -372,6 +452,7 @@ def load_config() -> Dict[str, Any]:
# Load strictly from database # Load strictly from database
db_config = get_config_all() db_config = get_config_all()
if db_config: if db_config:
_sync_alldebrid_api_key(db_config)
_CONFIG_CACHE = db_config _CONFIG_CACHE = db_config
_LAST_SAVED_CONFIG = deepcopy(db_config) _LAST_SAVED_CONFIG = deepcopy(db_config)
return db_config return db_config
@@ -387,6 +468,7 @@ def reload_config() -> Dict[str, Any]:
def save_config(config: Dict[str, Any]) -> int: def save_config(config: Dict[str, Any]) -> int:
global _CONFIG_CACHE, _LAST_SAVED_CONFIG global _CONFIG_CACHE, _LAST_SAVED_CONFIG
_sync_alldebrid_api_key(config)
previous_config = deepcopy(_LAST_SAVED_CONFIG) previous_config = deepcopy(_LAST_SAVED_CONFIG)
changed_count = _count_changed_entries(previous_config, config) changed_count = _count_changed_entries(previous_config, config)
@@ -398,7 +480,8 @@ def save_config(config: Dict[str, Any]) -> int:
if key in ('store', 'provider', 'tool'): if key in ('store', 'provider', 'tool'):
if isinstance(value, dict): if isinstance(value, dict):
for subtype, instances in value.items(): for subtype, instances in value.items():
if isinstance(instances, dict): if not isinstance(instances, dict):
continue
if key == 'store': if key == 'store':
for name, settings in instances.items(): for name, settings in instances.items():
if isinstance(settings, dict): if isinstance(settings, dict):
@@ -406,8 +489,13 @@ def save_config(config: Dict[str, Any]) -> int:
save_config_value(key, subtype, name, k, v) save_config_value(key, subtype, name, k, v)
count += 1 count += 1
else: else:
normalized_subtype = subtype
if key == 'provider':
normalized_subtype = _normalize_provider_name(subtype)
if not normalized_subtype:
continue
for k, v in instances.items(): for k, v in instances.items():
save_config_value(key, subtype, "default", k, v) save_config_value(key, normalized_subtype, "default", k, v)
count += 1 count += 1
else: else:
if not key.startswith("_") and value is not None: if not key.startswith("_") and value is not None:

View File

@@ -1,4 +1,5 @@
import re import re
from copy import deepcopy
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from textual import on, work from textual import on, work
@@ -122,6 +123,20 @@ class ConfigModal(ModalScreen):
self._input_id_map = {} self._input_id_map = {}
self._matrix_status: Optional[Static] = None self._matrix_status: Optional[Static] = None
self._matrix_test_running = False self._matrix_test_running = False
self._editor_snapshot: Optional[Dict[str, Any]] = None
def _capture_editor_snapshot(self) -> None:
self._editor_snapshot = deepcopy(self.config_data)
def _revert_unsaved_editor_changes(self) -> None:
if self._editor_snapshot is not None:
self.config_data = deepcopy(self._editor_snapshot)
self._editor_snapshot = None
def _editor_has_changes(self) -> bool:
if self._editor_snapshot is None:
return True
return self.config_data != self._editor_snapshot
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(id="config-container"): with Container(id="config-container"):
@@ -541,8 +556,10 @@ class ConfigModal(ModalScreen):
if not bid: return if not bid: return
if bid == "cancel-btn": if bid == "cancel-btn":
self._revert_unsaved_editor_changes()
self.dismiss() self.dismiss()
elif bid == "back-btn": elif bid == "back-btn":
self._revert_unsaved_editor_changes()
self.editing_item_name = None self.editing_item_name = None
self.editing_item_type = None self.editing_item_type = None
self.refresh_view() self.refresh_view()
@@ -550,6 +567,9 @@ class ConfigModal(ModalScreen):
self._synchronize_inputs_to_config() self._synchronize_inputs_to_config()
if not self.validate_current_editor(): if not self.validate_current_editor():
return return
if self.editing_item_name and not self._editor_has_changes():
self.notify("No changes to save", severity="warning")
return
try: try:
saved = self.save_all() saved = self.save_all()
msg = f"Configuration saved ({saved} entries)" msg = f"Configuration saved ({saved} entries)"
@@ -560,11 +580,13 @@ class ConfigModal(ModalScreen):
self.editing_item_name = None self.editing_item_name = None
self.editing_item_type = None self.editing_item_type = None
self.refresh_view() self.refresh_view()
self._editor_snapshot = None
except Exception as exc: except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10) self.notify(f"Save failed: {exc}", severity="error", timeout=10)
elif bid in self._button_id_map: elif bid in self._button_id_map:
action, itype, name = self._button_id_map[bid] action, itype, name = self._button_id_map[bid]
if action == "edit": if action == "edit":
self._capture_editor_snapshot()
self.editing_item_type = itype self.editing_item_type = itype
self.editing_item_name = name self.editing_item_name = name
self.refresh_view() self.refresh_view()
@@ -656,6 +678,8 @@ class ConfigModal(ModalScreen):
if not stype: if not stype:
return return
self._capture_editor_snapshot()
existing_names: set[str] = set() existing_names: set[str] = set()
store_block = self.config_data.get("store") store_block = self.config_data.get("store")
if isinstance(store_block, dict): if isinstance(store_block, dict):
@@ -704,6 +728,7 @@ class ConfigModal(ModalScreen):
def on_provider_type_selected(self, ptype: str) -> None: def on_provider_type_selected(self, ptype: str) -> None:
if not ptype: return if not ptype: return
self._capture_editor_snapshot()
if "provider" not in self.config_data: if "provider" not in self.config_data:
self.config_data["provider"] = {} self.config_data["provider"] = {}
@@ -738,51 +763,72 @@ class ConfigModal(ModalScreen):
return return
key = self._input_id_map[widget_id] key = self._input_id_map[widget_id]
raw_value = value
is_blank_string = isinstance(raw_value, str) and not raw_value.strip()
existing_value: Any = None
item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "")
if widget_id.startswith("global-"):
existing_value = self.config_data.get(key)
elif widget_id.startswith("item-") and item_name:
if item_type.startswith("store-"):
stype = item_type.replace("store-", "")
store_block = self.config_data.get("store")
if isinstance(store_block, dict):
type_block = store_block.get(stype)
if isinstance(type_block, dict):
section = type_block.get(item_name)
if isinstance(section, dict):
existing_value = section.get(key)
else:
section_block = self.config_data.get(item_type)
if isinstance(section_block, dict):
section = section_block.get(item_name)
if isinstance(section, dict):
existing_value = section.get(key)
if is_blank_string and existing_value is None:
return
# Try to preserve boolean/integer types # Try to preserve boolean/integer types
processed_value = value processed_value = raw_value
if isinstance(value, str): if isinstance(raw_value, str):
low = value.lower() low = raw_value.lower()
if low == "true": if low == "true":
processed_value = True processed_value = True
elif low == "false": elif low == "false":
processed_value = False processed_value = False
elif value.isdigit(): elif raw_value.isdigit():
processed_value = int(value) processed_value = int(raw_value)
if widget_id.startswith("global-"): if widget_id.startswith("global-"):
self.config_data[key] = processed_value self.config_data[key] = processed_value
elif widget_id.startswith("item-") and self.editing_item_name: elif widget_id.startswith("item-") and item_name:
it = str(self.editing_item_type or "") if item_type.startswith("store-"):
inm = str(self.editing_item_name or "") stype = item_type.replace("store-", "")
# Handle nested store structure
if it.startswith("store-"):
stype = it.replace("store-", "")
if "store" not in self.config_data: if "store" not in self.config_data:
self.config_data["store"] = {} self.config_data["store"] = {}
if stype not in self.config_data["store"]: if stype not in self.config_data["store"]:
self.config_data["store"][stype] = {} self.config_data["store"][stype] = {}
if inm not in self.config_data["store"][stype]: if item_name not in self.config_data["store"][stype]:
self.config_data["store"][stype][inm] = {} self.config_data["store"][stype][item_name] = {}
# Special case: Renaming the store via the NAME field # Special case: Renaming the store via the NAME field
if key.upper() == "NAME" and processed_value and str(processed_value) != inm: if key.upper() == "NAME" and processed_value and str(processed_value) != item_name:
new_inm = str(processed_value) new_name = str(processed_value)
# Move the whole dictionary to the new key self.config_data["store"][stype][new_name] = self.config_data["store"][stype].pop(item_name)
self.config_data["store"][stype][new_inm] = self.config_data["store"][stype].pop(inm) self.editing_item_name = new_name
# Update editing_item_name so further changes to this screen hit the new key item_name = new_name
self.editing_item_name = new_inm
inm = new_inm
self.config_data["store"][stype][inm][key] = processed_value self.config_data["store"][stype][item_name][key] = processed_value
else: else:
# Provider or other top-level sections if item_type not in self.config_data:
if it not in self.config_data: self.config_data[item_type] = {}
self.config_data[it] = {} if item_name not in self.config_data[item_type]:
if inm not in self.config_data[it]: self.config_data[item_type][item_name] = {}
self.config_data[it][inm] = {} self.config_data[item_type][item_name][key] = processed_value
self.config_data[it][inm][key] = processed_value
def _synchronize_inputs_to_config(self) -> None: def _synchronize_inputs_to_config(self) -> None:
"""Capture current input/select values before saving.""" """Capture current input/select values before saving."""
@@ -823,7 +869,17 @@ class ConfigModal(ModalScreen):
return return
self._synchronize_inputs_to_config() self._synchronize_inputs_to_config()
if self._matrix_status: if self._matrix_status:
self._matrix_status.update("Testing Matrix connection") self._matrix_status.update("Saving configuration before testing")
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status:
self._matrix_status.update(f"Saving configuration failed: {exc}")
self._matrix_test_running = False
return
self.config_data = reload_config()
if self._matrix_status:
self._matrix_status.update(f"Saved configuration ({entries} entries). Testing Matrix connection…")
self._matrix_test_running = True self._matrix_test_running = True
self._matrix_test_background() self._matrix_test_background()
@@ -885,8 +941,16 @@ class ConfigModal(ModalScreen):
return return
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["rooms"] = ", ".join(cleaned) matrix_block["rooms"] = ", ".join(cleaned)
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status: if self._matrix_status:
self._matrix_status.update(f"Saved {len(cleaned)} default room(s).") self._matrix_status.update(f"Saving default rooms failed: {exc}")
return
self.config_data = reload_config()
if self._matrix_status:
status = f"Saved {len(cleaned)} default room(s) ({entries} rows persisted)."
self._matrix_status.update(status)
self.refresh_view() self.refresh_view()
@on(Input.Changed) @on(Input.Changed)

View File

@@ -7,6 +7,7 @@ from textual.containers import Container, Horizontal, ScrollableContainer, Verti
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox from textual.widgets import Static, Button, Checkbox
from textual import work from textual import work
from rich.text import Text
class MatrixRoomPicker(ModalScreen[List[str]]): class MatrixRoomPicker(ModalScreen[List[str]]):
@@ -45,30 +46,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
.matrix-room-row { .matrix-room-row {
border-bottom: solid $surface; border-bottom: solid $surface;
padding: 1 0; padding: 0.5 0;
align: left middle; align: left middle;
background: $surface;
}
.matrix-room-checkbox {
width: 3;
padding: 0;
margin-right: 1;
} }
.matrix-room-meta { .matrix-room-meta {
padding: 0 1; padding-left: 1;
}
.matrix-room-name {
content-align: left middle; content-align: left middle;
color: $text;
}
.matrix-room-id {
content-align: left middle;
text-style: dim;
color: $text-muted;
} }
#matrix-room-actions { #matrix-room-actions {
@@ -149,17 +133,19 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
checkbox = Checkbox( checkbox = Checkbox(
"", "",
id=checkbox_id, id=checkbox_id,
classes="matrix-room-checkbox",
value=bool(room_id and room_id in self._existing_ids), value=bool(room_id and room_id in self._existing_ids),
) )
self._checkbox_map[checkbox_id] = room_id self._checkbox_map[checkbox_id] = room_id
info = Vertical(classes="matrix-room-meta")
info.mount(Static(name or room_id or "Matrix Room", classes="matrix-room-name")) label = Text(name or room_id or "Matrix Room")
info.mount(Static(room_id or "(no id)", classes="matrix-room-id")) label.stylize("bold")
label.append("\n")
label.append(room_id or "(no id)", style="dim")
row = Horizontal(classes="matrix-room-row") row = Horizontal(classes="matrix-room-row")
self._checklist.mount(row) self._checklist.mount(row)
row.mount(checkbox) row.mount(checkbox)
row.mount(info) row.mount(Static(label, classes="matrix-room-meta"))
self._set_status("Loaded rooms. Select one or more and save.") self._set_status("Loaded rooms. Select one or more and save.")
if self._save_button: if self._save_button:
self._save_button.disabled = False self._save_button.disabled = False

View File

@@ -1063,28 +1063,6 @@ class Download_File(Cmdlet):
return selection_format_id return selection_format_id
@staticmethod
def _format_selector_for_query_height(query_format: str) -> Optional[str]:
import re
if query_format is None:
return None
s = str(query_format).strip().lower()
m = re.match(r"^(\d{2,5})p$", s)
if not m:
return None
try:
height = int(m.group(1))
except Exception:
return None
if height <= 0:
raise ValueError(f"Invalid height selection: {query_format}")
return f"bv*[height<={height}]+ba"
@staticmethod @staticmethod
def _canonicalize_url_for_storage(*, requested_url: str, ytdlp_tool: YtDlpTool, playlist_items: Optional[str]) -> str: def _canonicalize_url_for_storage(*, requested_url: str, ytdlp_tool: YtDlpTool, playlist_items: Optional[str]) -> str:
if playlist_items: if playlist_items:
@@ -1980,13 +1958,12 @@ class Download_File(Cmdlet):
formats_cache: Dict[str, Optional[List[Dict[str, Any]]]] = {} formats_cache: Dict[str, Optional[List[Dict[str, Any]]]] = {}
playlist_items = str(parsed.get("item")) if parsed.get("item") else None playlist_items = str(parsed.get("item")) if parsed.get("item") else None
ytdl_format = None ytdl_format = None
height_selector = None
if query_format and not query_wants_audio: if query_format and not query_wants_audio:
try: try:
height_selector = self._format_selector_for_query_height(query_format) height_selector = ytdlp_tool.resolve_height_selector(query_format)
except ValueError as e: except Exception:
log(f"Error parsing format selection: {e}", file=sys.stderr) height_selector = None
return 1
if height_selector: if height_selector:
ytdl_format = height_selector ytdl_format = height_selector
else: else:
@@ -1994,6 +1971,7 @@ class Download_File(Cmdlet):
if not re.match(r"^\s*#?\d+\s*$", str(query_format)): if not re.match(r"^\s*#?\d+\s*$", str(query_format)):
ytdl_format = query_format ytdl_format = query_format
debug(f"DEBUG: [download-file] Using literal query_format '{query_format}' as ytdl_format")
playlist_selection_handled = False playlist_selection_handled = False
if len(supported_url) == 1 and not playlist_items: if len(supported_url) == 1 and not playlist_items:
@@ -2594,8 +2572,7 @@ class Download_File(Cmdlet):
self, self,
result: Any, result: Any,
args: Sequence[str], args: Sequence[str],
config: Dict[str, config: Dict[str, Any]
Any]
) -> int: ) -> int:
"""Main download implementation for direct HTTP files.""" """Main download implementation for direct HTTP files."""
progress = PipelineProgress(pipeline_context) progress = PipelineProgress(pipeline_context)

View File

@@ -336,7 +336,7 @@ def main(argv: Optional[List[str]] = None) -> int:
# #
# Examples: # Examples:
# mm "download-file <url> | add-tag 'x' | add-file -store local" # mm "download-file <url> | add-tag 'x' | add-file -store local"
# mm "download-file '<url>' -query 'format:720p' -path 'C:\\out'" # mm "download-file '<url>' -query 'format:720' -path 'C:\\out'"
if len(clean_args) == 1: if len(clean_args) == 1:
single = clean_args[0] single = clean_args[0]
if "|" in single and not single.startswith("-"): if "|" in single and not single.startswith("-"):

View File

@@ -671,6 +671,29 @@ class YtDlpTool:
pass pass
return None return None
def resolve_height_selector(self, format_str: Optional[str]) -> Optional[str]:
"""Resolve numeric heights (720, 1080p) to yt-dlp height selectors.
Examples:
"720" -> "bv*[height<=720]+ba"
"1080p" -> "bv*[height<=1080]+ba"
"""
if not format_str or not isinstance(format_str, str):
return None
s = format_str.strip().lower()
if not s:
return None
# Strip trailing 'p' if present (e.g. 720p -> 720)
if s.endswith('p'):
s = s[:-1]
if s.isdigit():
height = int(s)
if height >= 144:
return f"bv*[height<={height}]+ba"
return None
def _load_defaults(self) -> YtDlpDefaults: def _load_defaults(self) -> YtDlpDefaults:
cfg = self._config cfg = self._config
@@ -787,7 +810,13 @@ class YtDlpTool:
if opts.no_playlist: if opts.no_playlist:
base_options["noplaylist"] = True base_options["noplaylist"] = True
fmt = opts.ytdl_format or self.default_format(opts.mode) ytdl_format = opts.ytdl_format
if ytdl_format and opts.mode != "audio":
resolved = self.resolve_height_selector(ytdl_format)
if resolved:
ytdl_format = resolved
fmt = ytdl_format or self.default_format(opts.mode)
base_options["format"] = fmt base_options["format"] = fmt
if opts.mode == "audio": if opts.mode == "audio":