diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 8bde55d..a2b9b14 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -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=[^&]+)?)" ], "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": { "name": "rapidgator", @@ -339,7 +339,7 @@ "(file\\.al/[0-9a-zA-Z]{12})" ], "regexp": "(file\\.al/[0-9a-zA-Z]{12})", - "status": false + "status": true }, "filedot": { "name": "filedot", @@ -477,7 +477,7 @@ "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": [ "isra\\.cloud/([0-9a-zA-Z]{12})" ] @@ -507,7 +507,7 @@ "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": { "name": "mexashare", diff --git a/SYS/config.py b/SYS/config.py index 0833f95..fadd9f8 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -126,8 +126,17 @@ def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: provider_cfg = config.get("provider") if not isinstance(provider_cfg, dict): return {} - block = provider_cfg.get(str(name).strip().lower()) - return block if isinstance(block, dict) else {} + normalized = _normalize_provider_name(name) + 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]: @@ -334,6 +343,77 @@ def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]: path = Path.cwd() / 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]: entries: Dict[Tuple[str, str, str, str], Any] = {} @@ -372,6 +452,7 @@ def load_config() -> Dict[str, Any]: # Load strictly from database db_config = get_config_all() if db_config: + _sync_alldebrid_api_key(db_config) _CONFIG_CACHE = db_config _LAST_SAVED_CONFIG = deepcopy(db_config) return db_config @@ -387,6 +468,7 @@ def reload_config() -> Dict[str, Any]: def save_config(config: Dict[str, Any]) -> int: global _CONFIG_CACHE, _LAST_SAVED_CONFIG + _sync_alldebrid_api_key(config) previous_config = deepcopy(_LAST_SAVED_CONFIG) changed_count = _count_changed_entries(previous_config, config) @@ -398,17 +480,23 @@ def save_config(config: Dict[str, Any]) -> int: if key in ('store', 'provider', 'tool'): if isinstance(value, dict): for subtype, instances in value.items(): - if isinstance(instances, dict): - if key == 'store': - for name, settings in instances.items(): - if isinstance(settings, dict): - for k, v in settings.items(): - save_config_value(key, subtype, name, k, v) - count += 1 - else: - for k, v in instances.items(): - save_config_value(key, subtype, "default", k, v) - count += 1 + if not isinstance(instances, dict): + continue + if key == 'store': + for name, settings in instances.items(): + if isinstance(settings, dict): + for k, v in settings.items(): + save_config_value(key, subtype, name, k, v) + count += 1 + else: + normalized_subtype = subtype + if key == 'provider': + normalized_subtype = _normalize_provider_name(subtype) + if not normalized_subtype: + continue + for k, v in instances.items(): + save_config_value(key, normalized_subtype, "default", k, v) + count += 1 else: if not key.startswith("_") and value is not None: save_config_value("global", "none", "none", key, value) diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index d65b531..dc9ecf0 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -1,4 +1,5 @@ import re +from copy import deepcopy from typing import Any, Dict, List, Optional from textual import on, work @@ -122,6 +123,20 @@ class ConfigModal(ModalScreen): self._input_id_map = {} self._matrix_status: Optional[Static] = None 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: with Container(id="config-container"): @@ -541,8 +556,10 @@ class ConfigModal(ModalScreen): if not bid: return if bid == "cancel-btn": + self._revert_unsaved_editor_changes() self.dismiss() elif bid == "back-btn": + self._revert_unsaved_editor_changes() self.editing_item_name = None self.editing_item_type = None self.refresh_view() @@ -550,6 +567,9 @@ class ConfigModal(ModalScreen): self._synchronize_inputs_to_config() if not self.validate_current_editor(): return + if self.editing_item_name and not self._editor_has_changes(): + self.notify("No changes to save", severity="warning") + return try: saved = self.save_all() msg = f"Configuration saved ({saved} entries)" @@ -560,11 +580,13 @@ class ConfigModal(ModalScreen): self.editing_item_name = None self.editing_item_type = None self.refresh_view() + self._editor_snapshot = None except Exception as exc: self.notify(f"Save failed: {exc}", severity="error", timeout=10) elif bid in self._button_id_map: action, itype, name = self._button_id_map[bid] if action == "edit": + self._capture_editor_snapshot() self.editing_item_type = itype self.editing_item_name = name self.refresh_view() @@ -656,6 +678,8 @@ class ConfigModal(ModalScreen): if not stype: return + self._capture_editor_snapshot() + existing_names: set[str] = set() store_block = self.config_data.get("store") if isinstance(store_block, dict): @@ -704,6 +728,7 @@ class ConfigModal(ModalScreen): def on_provider_type_selected(self, ptype: str) -> None: if not ptype: return + self._capture_editor_snapshot() if "provider" not in self.config_data: self.config_data["provider"] = {} @@ -738,51 +763,72 @@ class ConfigModal(ModalScreen): return 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 - processed_value = value - if isinstance(value, str): - low = value.lower() + processed_value = raw_value + if isinstance(raw_value, str): + low = raw_value.lower() if low == "true": processed_value = True elif low == "false": processed_value = False - elif value.isdigit(): - processed_value = int(value) + elif raw_value.isdigit(): + processed_value = int(raw_value) if widget_id.startswith("global-"): self.config_data[key] = processed_value - elif widget_id.startswith("item-") and self.editing_item_name: - it = str(self.editing_item_type or "") - inm = str(self.editing_item_name or "") - - # Handle nested store structure - if it.startswith("store-"): - stype = it.replace("store-", "") + elif widget_id.startswith("item-") and item_name: + if item_type.startswith("store-"): + stype = item_type.replace("store-", "") if "store" not in self.config_data: self.config_data["store"] = {} if stype not in self.config_data["store"]: self.config_data["store"][stype] = {} - if inm not in self.config_data["store"][stype]: - self.config_data["store"][stype][inm] = {} + if item_name not in self.config_data["store"][stype]: + self.config_data["store"][stype][item_name] = {} # Special case: Renaming the store via the NAME field - if key.upper() == "NAME" and processed_value and str(processed_value) != inm: - new_inm = str(processed_value) - # Move the whole dictionary to the new key - self.config_data["store"][stype][new_inm] = self.config_data["store"][stype].pop(inm) - # Update editing_item_name so further changes to this screen hit the new key - self.editing_item_name = new_inm - inm = new_inm + if key.upper() == "NAME" and processed_value and str(processed_value) != item_name: + new_name = str(processed_value) + self.config_data["store"][stype][new_name] = self.config_data["store"][stype].pop(item_name) + self.editing_item_name = new_name + item_name = new_name - self.config_data["store"][stype][inm][key] = processed_value + self.config_data["store"][stype][item_name][key] = processed_value else: - # Provider or other top-level sections - if it not in self.config_data: - self.config_data[it] = {} - if inm not in self.config_data[it]: - self.config_data[it][inm] = {} - self.config_data[it][inm][key] = processed_value + if item_type not in self.config_data: + self.config_data[item_type] = {} + if item_name not in self.config_data[item_type]: + self.config_data[item_type][item_name] = {} + self.config_data[item_type][item_name][key] = processed_value def _synchronize_inputs_to_config(self) -> None: """Capture current input/select values before saving.""" @@ -823,7 +869,17 @@ class ConfigModal(ModalScreen): return self._synchronize_inputs_to_config() 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_background() @@ -885,8 +941,16 @@ class ConfigModal(ModalScreen): return matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) matrix_block["rooms"] = ", ".join(cleaned) + try: + entries = save_config(self.config_data) + except Exception as exc: + if self._matrix_status: + self._matrix_status.update(f"Saving default rooms failed: {exc}") + return + self.config_data = reload_config() if self._matrix_status: - self._matrix_status.update(f"Saved {len(cleaned)} default room(s).") + status = f"Saved {len(cleaned)} default room(s) ({entries} rows persisted)." + self._matrix_status.update(status) self.refresh_view() @on(Input.Changed) diff --git a/TUI/modalscreen/matrix_room_picker.py b/TUI/modalscreen/matrix_room_picker.py index 8e6af20..c2e7162 100644 --- a/TUI/modalscreen/matrix_room_picker.py +++ b/TUI/modalscreen/matrix_room_picker.py @@ -7,6 +7,7 @@ from textual.containers import Container, Horizontal, ScrollableContainer, Verti from textual.screen import ModalScreen from textual.widgets import Static, Button, Checkbox from textual import work +from rich.text import Text class MatrixRoomPicker(ModalScreen[List[str]]): @@ -45,30 +46,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]): .matrix-room-row { border-bottom: solid $surface; - padding: 1 0; + padding: 0.5 0; align: left middle; - background: $surface; - } - - .matrix-room-checkbox { - width: 3; - padding: 0; - margin-right: 1; } .matrix-room-meta { - padding: 0 1; - } - - .matrix-room-name { + padding-left: 1; content-align: left middle; - color: $text; - } - - .matrix-room-id { - content-align: left middle; - text-style: dim; - color: $text-muted; } #matrix-room-actions { @@ -149,17 +133,19 @@ class MatrixRoomPicker(ModalScreen[List[str]]): checkbox = Checkbox( "", id=checkbox_id, - classes="matrix-room-checkbox", value=bool(room_id and room_id in self._existing_ids), ) 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")) - info.mount(Static(room_id or "(no id)", classes="matrix-room-id")) + + label = Text(name or room_id or "Matrix Room") + label.stylize("bold") + label.append("\n") + label.append(room_id or "(no id)", style="dim") + row = Horizontal(classes="matrix-room-row") self._checklist.mount(row) 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.") if self._save_button: self._save_button.disabled = False diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 5a7f2f2..eba88be 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -1063,28 +1063,6 @@ class Download_File(Cmdlet): 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 def _canonicalize_url_for_storage(*, requested_url: str, ytdlp_tool: YtDlpTool, playlist_items: Optional[str]) -> str: if playlist_items: @@ -1929,7 +1907,7 @@ class Download_File(Cmdlet): query_format = str(fmt_candidate).strip() except Exception: query_format = None - + query_audio: Optional[bool] = None try: audio_values = query_keyed.get("audio", []) if isinstance(query_keyed, dict) else [] @@ -1980,20 +1958,20 @@ class Download_File(Cmdlet): formats_cache: Dict[str, Optional[List[Dict[str, Any]]]] = {} playlist_items = str(parsed.get("item")) if parsed.get("item") else None ytdl_format = None + height_selector = None if query_format and not query_wants_audio: try: - height_selector = self._format_selector_for_query_height(query_format) - except ValueError as e: - log(f"Error parsing format selection: {e}", file=sys.stderr) - return 1 + height_selector = ytdlp_tool.resolve_height_selector(query_format) + except Exception: + height_selector = None + if height_selector: + ytdl_format = height_selector + else: + import re - if height_selector: - ytdl_format = height_selector - else: - import re - - if not re.match(r"^\s*#?\d+\s*$", str(query_format)): - ytdl_format = query_format + if not re.match(r"^\s*#?\d+\s*$", str(query_format)): + ytdl_format = query_format + debug(f"DEBUG: [download-file] Using literal query_format '{query_format}' as ytdl_format") playlist_selection_handled = False if len(supported_url) == 1 and not playlist_items: @@ -2594,8 +2572,7 @@ class Download_File(Cmdlet): self, result: Any, args: Sequence[str], - config: Dict[str, - Any] + config: Dict[str, Any] ) -> int: """Main download implementation for direct HTTP files.""" progress = PipelineProgress(pipeline_context) diff --git a/scripts/cli_entry.py b/scripts/cli_entry.py index 956c63a..7c8ded2 100644 --- a/scripts/cli_entry.py +++ b/scripts/cli_entry.py @@ -336,7 +336,7 @@ def main(argv: Optional[List[str]] = None) -> int: # # Examples: # mm "download-file | add-tag 'x' | add-file -store local" - # mm "download-file '' -query 'format:720p' -path 'C:\\out'" + # mm "download-file '' -query 'format:720' -path 'C:\\out'" if len(clean_args) == 1: single = clean_args[0] if "|" in single and not single.startswith("-"): diff --git a/tool/ytdlp.py b/tool/ytdlp.py index 763a526..1af0122 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -671,6 +671,29 @@ class YtDlpTool: pass 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: cfg = self._config @@ -787,7 +810,13 @@ class YtDlpTool: if opts.no_playlist: 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 if opts.mode == "audio":