diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 896ac6d..d2d8920 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -1139,47 +1139,80 @@ local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_second return _run_helper_request_response(req, timeout_seconds) end -local function _refresh_store_cache(timeout_seconds) +local function _store_names_key(names) + if type(names) ~= 'table' or #names == 0 then + return '' + end + local normalized = {} + for _, name in ipairs(names) do + normalized[#normalized + 1] = trim(tostring(name or '')) + end + return table.concat(normalized, '\0') +end + +local function _run_pipeline_request_async(pipeline_cmd, seeds, timeout_seconds, cb) + cb = cb or function() end + pipeline_cmd = trim(tostring(pipeline_cmd or '')) + if pipeline_cmd == '' then + cb(nil, 'empty pipeline command') + return + end + ensure_mpv_ipc_server() + local req = { pipeline = pipeline_cmd } + if seeds then + req.seeds = seeds + end + _run_helper_request_async(req, timeout_seconds or 30, cb) +end + +local function _refresh_store_cache(timeout_seconds, on_complete) ensure_mpv_ipc_server() - -- First, try reading the pre-computed cached property (set by helper at startup). - -- This avoids a request/response timeout if observe_property isn't working. + local prev_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 + local prev_key = _store_names_key(_cached_store_names) + local cached_json = mp.get_property('user-data/medeia-store-choices-cached') _lua_log('stores: cache_read cached_json=' .. tostring(cached_json) .. ' len=' .. tostring(cached_json and #cached_json or 0)) - + if cached_json and cached_json ~= '' then - -- Try to parse as JSON (may fail if not valid JSON) + local function handle_cached(resp) + if not resp or type(resp) ~= 'table' or type(resp.choices) ~= 'table' then + _lua_log('stores: cache_parse result missing choices table; resp_type=' .. tostring(type(resp))) + return false + end + + local out = {} + for _, v in ipairs(resp.choices) do + local name = trim(tostring(v or '')) + if name ~= '' then + out[#out + 1] = name + end + end + _cached_store_names = out + _store_cache_loaded = true + local preview = '' + if #out > 0 then + preview = table.concat(out, ', ') + end + _lua_log('stores: loaded ' .. tostring(#out) .. ' stores from cache: ' .. tostring(preview)) + if type(on_complete) == 'function' then + on_complete(true, _store_names_key(out) ~= prev_key) + end + return true + end + local ok, cached_resp = pcall(utils.parse_json, cached_json) _lua_log('stores: cache_parse ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp))) - - -- Handle both cases: parsed object OR string (if JSON lib returns string) if ok then - -- If parse returned a string, it might still be valid JSON; try parsing again if type(cached_resp) == 'string' then _lua_log('stores: cache_parse returned string, trying again...') ok, cached_resp = pcall(utils.parse_json, cached_resp) _lua_log('stores: cache_parse retry ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp))) end - - -- Now check if we have a table with choices - if type(cached_resp) == 'table' and type(cached_resp.choices) == 'table' then - local out = {} - for _, v in ipairs(cached_resp.choices) do - local name = trim(tostring(v or '')) - if name ~= '' then - out[#out + 1] = name - end + if ok then + if handle_cached(cached_resp) then + return true end - _cached_store_names = out - _store_cache_loaded = true - local preview = '' - if #_cached_store_names > 0 then - preview = table.concat(_cached_store_names, ', ') - end - _lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores from cache: ' .. tostring(preview)) - return true - else - _lua_log('stores: cache_parse final type mismatch resp_type=' .. tostring(type(cached_resp)) .. ' choices_type=' .. tostring(cached_resp and type(cached_resp.choices) or 'n/a')) end else _lua_log('stores: cache_parse failed ok=' .. tostring(ok) .. ' resp=' .. tostring(cached_resp)) @@ -1188,38 +1221,44 @@ local function _refresh_store_cache(timeout_seconds) _lua_log('stores: cache_empty cached_json=' .. tostring(cached_json)) end - -- Fallback: request fresh store-choices from helper (with timeout). _lua_log('stores: requesting store-choices via helper (fallback)') - local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1) - if not resp or not resp.success or type(resp.choices) ~= 'table' then - _lua_log( - 'stores: failed to load store choices via helper; success=' - .. tostring(resp and resp.success or false) - .. ' choices_type=' - .. tostring(resp and type(resp.choices) or 'nil') - .. ' stderr=' - .. tostring(resp and resp.stderr or '') - .. ' error=' - .. tostring(resp and resp.error or '') - ) - return false - end - - local out = {} - for _, v in ipairs(resp.choices) do - local name = trim(tostring(v or '')) - if name ~= '' then - out[#out + 1] = name + _run_helper_request_async({ op = 'store-choices' }, timeout_seconds or 1, function(resp, err) + local success = false + local changed = false + if resp and resp.success and type(resp.choices) == 'table' then + local out = {} + for _, v in ipairs(resp.choices) do + local name = trim(tostring(v or '')) + if name ~= '' then + out[#out + 1] = name + end + end + _cached_store_names = out + _store_cache_loaded = true + local preview = '' + if #out > 0 then + preview = table.concat(out, ', ') + end + _lua_log('stores: loaded ' .. tostring(#out) .. ' stores via helper request: ' .. tostring(preview)) + success = true + changed = (#out ~= prev_count) or (_store_names_key(out) ~= prev_key) + else + _lua_log( + 'stores: failed to load store choices via helper; success=' + .. tostring(resp and resp.success or false) + .. ' choices_type=' + .. tostring(resp and type(resp.choices) or 'nil') + .. ' stderr=' + .. tostring(resp and resp.stderr or '') + .. ' error=' + .. tostring(resp and resp.error or err or '') + ) end - end - _cached_store_names = out - _store_cache_loaded = true - local preview = '' - if #_cached_store_names > 0 then - preview = table.concat(_cached_store_names, ', ') - end - _lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via helper request: ' .. tostring(preview)) - return true + if type(on_complete) == 'function' then + on_complete(success, changed) + end + end) + return false end local function _uosc_open_list_picker(menu_type, title, items) @@ -1286,35 +1325,12 @@ local function _open_store_picker() -- Best-effort refresh; retry briefly to avoid races where the helper isn't -- ready/observing yet at the exact moment the menu opens. local function attempt_refresh(tries_left) - local before_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 - local before_preview = '' - if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then - before_preview = table.concat(_cached_store_names, ', ') - end - - local ok = _refresh_store_cache(1.2) - local after_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 - local after_preview = '' - if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then - after_preview = table.concat(_cached_store_names, ', ') - end - - _lua_log( - 'stores: refresh attempt ok=' - .. tostring(ok) - .. ' before=' - .. tostring(before_count) - .. ' after=' - .. tostring(after_count) - .. ' after=' - .. tostring(after_preview) - ) - - if after_count > 0 and (after_count ~= before_count or after_preview ~= before_preview) then - _lua_log('stores: reopening menu (store list changed)') - _uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items()) - return - end + _refresh_store_cache(1.2, function(success, changed) + if success and changed then + _lua_log('stores: reopening menu (store list changed)') + _uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items()) + end + end) if tries_left > 0 then mp.add_timeout(0.25, function() @@ -1524,13 +1540,11 @@ function FileState:fetch_formats(cb) return end - -- Only applies to plain URLs (not store hash URLs). if _extract_store_hash(url) then if cb then cb(false, 'store-hash url') end return end - -- Cache hit. local cached = _get_cached_formats_table(url) if type(cached) == 'table' then self:set_formats(url, cached) @@ -1538,7 +1552,6 @@ function FileState:fetch_formats(cb) return end - -- In-flight: register waiter. if _formats_inflight[url] then _formats_waiters[url] = _formats_waiters[url] or {} if cb then table.insert(_formats_waiters[url], cb) end @@ -1548,7 +1561,6 @@ function FileState:fetch_formats(cb) _formats_waiters[url] = _formats_waiters[url] or {} if cb then table.insert(_formats_waiters[url], cb) end - -- Async request so the UI never blocks. _run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err) _formats_inflight[url] = nil @@ -1664,12 +1676,26 @@ local function _current_ytdl_format_string() return nil end -local function _run_pipeline_detached(pipeline_cmd) +local function _run_pipeline_detached(pipeline_cmd, on_failure) if not pipeline_cmd or pipeline_cmd == '' then return false end - local resp = _run_helper_request_response({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0) - return (resp and resp.success) and true or false + ensure_mpv_ipc_server() + if not ensure_pipeline_helper_running() then + if type(on_failure) == 'function' then + on_failure(nil, 'helper not running') + end + return false + end + _run_helper_request_async({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0, function(resp, err) + if resp and resp.success then + return + end + if type(on_failure) == 'function' then + on_failure(resp, err) + end + end) + return true end local function _open_save_location_picker_for_pending_download() @@ -1709,13 +1735,11 @@ local function _open_save_location_picker_for_pending_download() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end - local before = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 - if _refresh_store_cache(1.5) then - local after = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 - if after > 0 and after ~= before then + _refresh_store_cache(1.5, function(success, changed) + if success and changed then _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, 'Save location', build_items()) end - end + end) end) end @@ -1769,7 +1793,12 @@ local function _start_download_flow_for_current() return end ensure_mpv_ipc_server() - M.run_pipeline('get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder)) + local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) + M.run_pipeline(pipeline_cmd, nil, function(_, err) + if err then + mp.osd_message('Download failed: ' .. tostring(err), 5) + end + end) mp.osd_message('Download started', 2) return end @@ -1994,9 +2023,18 @@ mp.register_script_message('medios-download-pick-store', function(json) local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt) .. ' | add-file -store ' .. quote_pipeline_arg(store) - if not _run_pipeline_detached(pipeline_cmd) then - -- Fall back to synchronous execution if detached failed. - M.run_pipeline(pipeline_cmd) + local function run_pipeline_direct() + M.run_pipeline(pipeline_cmd, nil, function(_, err) + if err then + mp.osd_message('Download failed: ' .. tostring(err), 5) + end + end) + end + + if not _run_pipeline_detached(pipeline_cmd, function() + run_pipeline_direct() + end) then + run_pipeline_direct() end mp.osd_message('Download started', 3) _pending_download = nil @@ -2022,8 +2060,18 @@ mp.register_script_message('medios-download-pick-path', function() local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt) .. ' | add-file -path ' .. quote_pipeline_arg(folder) - if not _run_pipeline_detached(pipeline_cmd) then - M.run_pipeline(pipeline_cmd) + local function run_pipeline_direct() + M.run_pipeline(pipeline_cmd, nil, function(_, err) + if err then + mp.osd_message('Download failed: ' .. tostring(err), 5) + end + end) + end + + if not _run_pipeline_detached(pipeline_cmd, function() + run_pipeline_direct() + end) then + run_pipeline_direct() end mp.osd_message('Download started', 3) _pending_download = nil @@ -2197,84 +2245,96 @@ local function _call_mpv_api(request) end -- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). --- Returns stdout string on success, or nil on failure. -function M.run_pipeline(pipeline_cmd, seeds) +-- Calls the callback with stdout on success or error message on failure. +function M.run_pipeline(pipeline_cmd, seeds, cb) + cb = cb or function() end pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then - return nil + cb(nil, 'empty pipeline command') + return end - ensure_mpv_ipc_server() - local resp = run_pipeline_via_ipc_response(pipeline_cmd, seeds, 30) - if type(resp) == 'table' and resp.success then - return resp.stdout or '' - end - - local err = '' - if type(resp) == 'table' then - if resp.error and tostring(resp.error) ~= '' then - err = tostring(resp.error) - elseif resp.stderr and tostring(resp.stderr) ~= '' then - err = tostring(resp.stderr) + _run_pipeline_request_async(pipeline_cmd, seeds, 30, function(resp, err) + if resp and resp.success then + cb(resp.stdout or '', nil) + return end - end - if err ~= '' then - _lua_log('pipeline failed cmd=' .. tostring(pipeline_cmd) .. ' err=' .. err) - else - _lua_log('pipeline failed cmd=' .. tostring(pipeline_cmd) .. ' err=') - end - return nil + local details = err or '' + if details == '' and type(resp) == 'table' then + if resp.error and tostring(resp.error) ~= '' then + details = tostring(resp.error) + elseif resp.stderr and tostring(resp.stderr) ~= '' then + details = tostring(resp.stderr) + end + end + if details == '' then + details = 'unknown' + end + _lua_log('pipeline failed cmd=' .. tostring(pipeline_cmd) .. ' err=' .. details) + cb(nil, details) + end) end -- Helper to run pipeline and parse JSON output -function M.run_pipeline_json(pipeline_cmd, seeds) - -- Append | output-json if not present - if not pipeline_cmd:match("output%-json$") then - pipeline_cmd = pipeline_cmd .. " | output-json" +function M.run_pipeline_json(pipeline_cmd, seeds, cb) + cb = cb or function() end + if not pipeline_cmd:match('output%-json$') then + pipeline_cmd = pipeline_cmd .. ' | output-json' end - - local output = M.run_pipeline(pipeline_cmd, seeds) - if output then - local ok, data = pcall(utils.parse_json, output) - if ok then - return data - else - _lua_log("Failed to parse JSON: " .. output) - return nil + M.run_pipeline(pipeline_cmd, seeds, function(output, err) + if output then + local ok, data = pcall(utils.parse_json, output) + if ok then + cb(data, nil) + return + end + _lua_log('Failed to parse JSON: ' .. output) + cb(nil, 'malformed JSON response') + return end - end - return nil + cb(nil, err) + end) end -- Command: Get info for current file function M.get_file_info() - local path = mp.get_property("path") - if not path then return end - - -- We can pass the path as a seed item - local seed = {{path = path}} - - -- Run pipeline: get-metadata - local data = M.run_pipeline_json("get-metadata", seed) - - if data then - -- Display metadata - _lua_log("Metadata: " .. utils.format_json(data)) - mp.osd_message("Metadata loaded (check console)", 3) + local path = mp.get_property('path') + if not path then + return end + + local seed = {{path = path}} + + M.run_pipeline_json('get-metadata', seed, function(data, err) + if data then + _lua_log('Metadata: ' .. utils.format_json(data)) + mp.osd_message('Metadata loaded (check console)', 3) + return + end + if err then + mp.osd_message('Failed to load metadata: ' .. tostring(err), 3) + end + end) end -- Command: Delete current file function M.delete_current_file() - local path = mp.get_property("path") - if not path then return end - + local path = mp.get_property('path') + if not path then + return + end + local seed = {{path = path}} - - M.run_pipeline("delete-file", seed) - mp.osd_message("File deleted", 3) - mp.command("playlist-next") + + M.run_pipeline('delete-file', seed, function(_, err) + if err then + mp.osd_message('Delete failed: ' .. tostring(err), 3) + return + end + mp.osd_message('File deleted', 3) + mp.command('playlist-next') + end) end -- Command: Load a URL via pipeline (Ctrl+Enter in prompt) @@ -2619,14 +2679,18 @@ mp.register_script_message('medios-load-url-event', function(json) end ensure_mpv_ipc_server() - local out = M.run_pipeline('.mpv -url ' .. quote_pipeline_arg(url) .. ' -play') - if out ~= nil then + local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play' + M.run_pipeline(pipeline_cmd, nil, function(_, err) + if err then + mp.osd_message('Load URL failed: ' .. tostring(err), 3) + return + end if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) else _lua_log('menu: uosc not available; cannot close-menu') end - end + end) end) -- Menu integration with UOSC diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf index 4077249..7606177 100644 --- a/MPV/portable_config/script-opts/medeia.conf +++ b/MPV/portable_config/script-opts/medeia.conf @@ -1,2 +1,2 @@ # Medeia MPV script options -store=tutorial +store=rpi diff --git a/Provider/HIFI.py b/Provider/HIFI.py index 3bd1c95..db1f41e 100644 --- a/Provider/HIFI.py +++ b/Provider/HIFI.py @@ -10,14 +10,34 @@ import time import sys from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.parse import urlparse + from API.hifi import HifiApiClient -from ProviderCore.base import Provider, SearchResult +from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments +from Provider.tidal_shared import ( + build_track_tags, + coerce_duration_seconds, + extract_artists, + stringify, +) +from SYS import pipeline as pipeline_context from SYS.logger import debug, log -DEFAULT_API_URLS = ( +URL_API = ( + "https://tidal-api.binimum.org", + "https://triton.squid.wtf", + "https://wolf.qqdl.site", + "https://maus.qqdl.site", + "https://vogel.qqdl.site", + "https://katze.qqdl.site", + "https://hund.qqdl.site", + "https://tidal.kinoplus.online", "https://tidal-api.binimum.org", ) + + + _KEY_TO_PARAM: Dict[str, str] = { "album": "al", "artist": "a", @@ -49,6 +69,20 @@ class HIFI(Provider): TABLE_AUTO_STAGES = { "hifi.track": ["download-file"], } + QUERY_ARG_CHOICES = { + "album": (), + "artist": (), + "playlist": (), + "track": (), + "title": (), + "video": (), + } + INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES + URL_DOMAINS = ( + "tidal.com", + "listen.tidal.com", + ) + URL = URL_DOMAINS """Provider that targets the HiFi-RestAPI (Tidal proxy) search endpoint. The CLI can supply a list of fail-over URLs via ``provider.hifi.api_urls`` or @@ -65,6 +99,14 @@ class HIFI(Provider): self.api_timeout = 10.0 self.api_clients = [HifiApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls] + def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: + normalized, parsed = parse_inline_query_arguments(query) + filtered: Dict[str, Any] = {} + for key, value in parsed.items(): + if key in self.QUERY_ARG_CHOICES: + filtered[key] = value + return normalized, filtered + def validate(self) -> bool: return bool(self.api_urls) @@ -77,8 +119,16 @@ class HIFI(Provider): ) -> List[SearchResult]: if limit <= 0: return [] - view = self._get_view_from_query(query) - params = self._build_search_params(query) + normalized_query, inline_args = self.extract_query_arguments(query) + raw_query = str(query or "").strip() + search_query = normalized_query or raw_query + if not search_query and inline_args: + search_query = " ".join(f"{k}:{v}" for k, v in inline_args.items()) + if not search_query: + return [] + + view = self._determine_view(search_query, inline_args) + params = self._build_search_params(search_query) if not params: return [] @@ -126,6 +176,18 @@ class HIFI(Provider): return "album" return "track" + def _determine_view(self, query: str, inline_args: Dict[str, Any]) -> str: + if inline_args: + if "artist" in inline_args: + return "artist" + if "album" in inline_args: + return "album" + if "track" in inline_args or "title" in inline_args: + return "track" + if "video" in inline_args or "playlist" in inline_args: + return "track" + return self._get_view_from_query(query) + @staticmethod def _safe_filename(value: Any, *, fallback: str = "hifi") -> str: text = str(value or "").strip() @@ -169,6 +231,56 @@ class HIFI(Provider): return None return num if num > 0 else None + def _parse_tidal_url(self, url: str) -> Tuple[str, Optional[int]]: + try: + parsed = urlparse(str(url)) + except Exception: + return "", None + + parts = [segment for segment in (parsed.path or "").split("/") if segment] + if not parts: + return "", None + + idx = 0 + if parts[0].lower() == "browse": + idx = 1 + if idx >= len(parts): + return "", None + + view = parts[idx].lower() + if view not in {"album", "track"}: + return "", None + + for segment in parts[idx + 1:]: + identifier = self._parse_int(segment) + if identifier is not None: + return view, identifier + return view, None + + def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult: + if isinstance(detail, dict): + candidate = self._item_to_result(detail) + if candidate is not None: + try: + candidate.full_metadata = dict(detail) + except Exception: + pass + return candidate + + title = f"Track {track_id}" + if isinstance(detail, dict): + title = self._stringify(detail.get("title")) or title + + return SearchResult( + table="hifi", + title=title, + path=f"hifi://track/{track_id}", + detail=f"id:{track_id}", + annotations=["tidal", "track"], + media_kind="audio", + full_metadata=dict(detail) if isinstance(detail, dict) else {}, + ) + def _extract_artist_selection_context(self, selected_items: List[Any]) -> List[Tuple[int, str]]: contexts: List[Tuple[int, str]] = [] seen: set[int] = set() @@ -589,6 +701,65 @@ class HIFI(Provider): return results + def _present_album_tracks( + self, + track_results: List[SearchResult], + *, + album_id: Optional[int], + album_title: str, + artist_name: str, + ) -> None: + if not track_results: + return + + try: + from SYS.rich_display import stdout_console + from SYS.result_table import ResultTable + except Exception: + return + + label = album_title or "Album" + if artist_name: + label = f"{artist_name} - {label}" + + table = ResultTable(f"HIFI Tracks: {label}").set_preserve_order(True) + table.set_table("hifi.track") + try: + table.set_table_metadata( + { + "provider": "hifi", + "view": "track", + "album_id": album_id, + "album_title": album_title, + "artist_name": artist_name, + } + ) + except Exception: + pass + + results_payload: List[Dict[str, Any]] = [] + for result in track_results: + table.add_result(result) + try: + results_payload.append(result.to_dict()) + except Exception: + results_payload.append( + { + "table": getattr(result, "table", "hifi.track"), + "title": getattr(result, "title", ""), + "path": getattr(result, "path", ""), + } + ) + + pipeline_context.set_last_result_table(table, results_payload) + pipeline_context.set_current_stage_table(table) + + try: + stdout_console().print() + stdout_console().print(table) + except Exception: + pass + def _album_item_to_result(self, album: Dict[str, Any], *, artist_name: str) -> Optional[SearchResult]: if not isinstance(album, dict): return None @@ -1080,6 +1251,73 @@ class HIFI(Provider): ) return materialized + def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]: + view, identifier = self._parse_tidal_url(url) + if not view: + return False, None + + if view == "track": + if not identifier or output_dir is None: + return False, None + + try: + detail = self._fetch_track_details(identifier) + except Exception: + detail = None + + result = self._track_detail_to_result(detail, identifier) + try: + downloaded = self.download(result, output_dir) + except Exception: + return False, None + + if downloaded: + return True, downloaded + return False, None + + if view == "album": + if not identifier: + return False, None + + try: + track_results = self._tracks_for_album( + album_id=identifier, + album_title="", + artist_name="", + limit=200, + ) + except Exception: + return False, None + + if not track_results: + return False, None + + album_title = "" + artist_name = "" + metadata = getattr(track_results[0], "full_metadata", None) + if isinstance(metadata, dict): + album_obj = metadata.get("album") + if isinstance(album_obj, dict): + album_title = self._stringify(album_obj.get("title")) + else: + album_title = self._stringify(album_obj or metadata.get("album")) + artists = self._extract_artists(metadata) + if artists: + artist_name = artists[0] + + if not album_title: + album_title = f"Album {identifier}" + + self._present_album_tracks( + track_results, + album_id=identifier, + album_title=album_title, + artist_name=artist_name, + ) + return True, None + + return False, None + def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]: base = base_url.rstrip("/") for client in self.api_clients: @@ -1180,7 +1418,7 @@ class HIFI(Provider): urls.append(raw.strip()) cleaned = [u.rstrip("/") for u in urls if isinstance(u, str) and u.strip()] if not cleaned: - cleaned = [DEFAULT_API_URLS[0]] + cleaned = [URL_API[0]] return cleaned def _build_search_params(self, query: str) -> Dict[str, str]: @@ -1342,58 +1580,15 @@ class HIFI(Provider): @staticmethod def _coerce_duration_seconds(value: Any) -> Optional[int]: - candidates = [] - candidates.append(value) - try: - if isinstance(value, dict): - for key in ("duration", - "durationSeconds", - "duration_sec", - "duration_ms", - "durationMillis"): - if key in value: - candidates.append(value.get(key)) - except Exception: - pass - - for cand in candidates: - try: - if cand is None: - continue - if isinstance(cand, str) and cand.strip().endswith("ms"): - cand = cand.strip()[:-2] - v = float(cand) - if v <= 0: - continue - if v > 10_000: # treat as milliseconds - v = v / 1000.0 - return int(round(v)) - except Exception: - continue - return None + return coerce_duration_seconds(value) @staticmethod def _stringify(value: Any) -> str: - text = str(value or "").strip() - return text + return stringify(value) @staticmethod def _extract_artists(item: Dict[str, Any]) -> List[str]: - names: List[str] = [] - artists = item.get("artists") - if isinstance(artists, list): - for artist in artists: - if isinstance(artist, dict): - name = str(artist.get("name") or "").strip() - if name and name not in names: - names.append(name) - if not names: - primary = item.get("artist") - if isinstance(primary, dict): - name = str(primary.get("name") or "").strip() - if name: - names.append(name) - return names + return extract_artists(item) def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]: if not isinstance(item, dict): @@ -1619,52 +1814,7 @@ class HIFI(Provider): return [(name, value) for name, value in values if value] def _build_track_tags(self, metadata: Dict[str, Any]) -> set[str]: - tags: set[str] = {"tidal"} - - audio_quality = self._stringify(metadata.get("audioQuality")) - if audio_quality: - tags.add(f"quality:{audio_quality.lower()}") - - media_md = metadata.get("mediaMetadata") - if isinstance(media_md, dict): - tag_values = media_md.get("tags") or [] - for tag in tag_values: - if isinstance(tag, str): - candidate = tag.strip() - if candidate: - tags.add(candidate.lower()) - - title_text = self._stringify(metadata.get("title")) - if title_text: - tags.add(f"title:{title_text}") - - artists = self._extract_artists(metadata) - for artist in artists: - artist_clean = self._stringify(artist) - if artist_clean: - tags.add(f"artist:{artist_clean}") - - album_title = "" - album_obj = metadata.get("album") - if isinstance(album_obj, dict): - album_title = self._stringify(album_obj.get("title")) - else: - album_title = self._stringify(metadata.get("album")) - if album_title: - tags.add(f"album:{album_title}") - - track_no_val = metadata.get("trackNumber") or metadata.get("track_number") - if track_no_val is not None: - try: - track_int = int(track_no_val) - if track_int > 0: - tags.add(f"track:{track_int}") - except Exception: - track_text = self._stringify(track_no_val) - if track_text: - tags.add(f"track:{track_text}") - - return tags + return build_track_tags(metadata) def selector( self, diff --git a/Provider/alldebrid.py b/Provider/alldebrid.py index 36abb20..bb2c9e9 100644 --- a/Provider/alldebrid.py +++ b/Provider/alldebrid.py @@ -543,9 +543,72 @@ def adjust_output_dir_for_alldebrid( class AllDebrid(Provider): # Magnet URIs should be routed through this provider. + TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]} URL = ("magnet:",) URL_DOMAINS = () + @staticmethod + def _resolve_magnet_spec_from_result(result: Any) -> Optional[str]: + table = getattr(result, "table", None) + media_kind = getattr(result, "media_kind", None) + tags = getattr(result, "tag", None) + full_metadata = getattr(result, "full_metadata", None) + target = getattr(result, "path", None) or getattr(result, "url", None) + + if not table or str(table).strip().lower() != "alldebrid": + return None + + kind_val = str(media_kind or "").strip().lower() + is_folder = kind_val == "folder" + if not is_folder and isinstance(tags, (list, set)): + for tag in tags: + if str(tag or "").strip().lower() == "folder": + is_folder = True + break + if not is_folder: + return resolve_magnet_spec(str(target or "")) if isinstance(target, str) else None + + metadata = full_metadata if isinstance(full_metadata, dict) else {} + candidates: List[str] = [] + + def _maybe_add(value: Any) -> None: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + candidates.append(cleaned) + + magnet_block = metadata.get("magnet") + if isinstance(magnet_block, dict): + for inner in ("magnet", "magnet_link", "link", "url"): + _maybe_add(magnet_block.get(inner)) + for inner in ("hash", "info_hash", "torrenthash", "magnethash"): + _maybe_add(magnet_block.get(inner)) + else: + _maybe_add(magnet_block) + + for extra in ("magnet_link", "magnet_url", "magnet_spec"): + _maybe_add(metadata.get(extra)) + _maybe_add(metadata.get("hash")) + _maybe_add(metadata.get("info_hash")) + + for candidate in candidates: + spec = resolve_magnet_spec(candidate) + if spec: + return spec + return resolve_magnet_spec(str(target)) if isinstance(target, str) else None + + def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]: + spec = resolve_magnet_spec(url) + if not spec: + return False, None + + cfg = self.config if isinstance(self.config, dict) else {} + try: + prepare_magnet(spec, cfg) + return True, None + except Exception: + return False, None + @classmethod def url_patterns(cls) -> Tuple[str, ...]: # Combine static patterns with cached host domains. @@ -744,11 +807,42 @@ class AllDebrid(Provider): except Exception: return None + def download_items( + self, + result: SearchResult, + output_dir: Path, + *, + emit: Callable[[Path, str, str, Dict[str, Any]], None], + progress: Any, + quiet_mode: bool, + path_from_result: Callable[[Any], Path], + config: Optional[Dict[str, Any]] = None, + ) -> int: + spec = self._resolve_magnet_spec_from_result(result) + if not spec: + return 0 + + cfg = config if isinstance(config, dict) else (self.config or {}) + + def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: + emit(path, file_url, relpath, metadata) + + downloaded, _ = download_magnet( + spec, + str(getattr(result, "path", "") or ""), + output_dir, + cfg, + progress, + quiet_mode, + path_from_result, + _on_emit, + ) + return downloaded + @staticmethod def _flatten_files(items: Any, *, - _prefix: Optional[List[str]] = None) -> Iterable[Dict[str, - Any]]: + _prefix: Optional[List[str]] = None) -> Iterable[Dict[str, Any]]: """Flatten AllDebrid magnet file tree into file dicts, preserving relative paths. API commonly returns: @@ -784,9 +878,7 @@ class AllDebrid(Provider): name = node.get("n") or node.get("name") link = node.get("l") or node.get("link") - if isinstance(name, - str) and name.strip() and isinstance(link, - str) and link.strip(): + if isinstance(name, str) and name.strip() and isinstance(link, str) and link.strip(): rel_parts = prefix + [name.strip()] relpath = "/".join([p for p in rel_parts if p]) enriched = dict(node) @@ -932,6 +1024,19 @@ class AllDebrid(Provider): except Exception: size_bytes = None + metadata = { + "magnet": magnet_status, + "magnet_id": magnet_id, + "magnet_name": magnet_name, + "relpath": relpath, + "file": file_node, + "provider": "alldebrid", + "provider_view": "files", + } + if file_url: + metadata["_selection_args"] = ["-url", file_url] + metadata["_selection_action"] = ["download-file", "-url", file_url] + results.append( SearchResult( table="alldebrid", @@ -952,15 +1057,7 @@ class AllDebrid(Provider): ("ID", str(magnet_id)), ], - full_metadata={ - "magnet": magnet_status, - "magnet_id": magnet_id, - "magnet_name": magnet_name, - "relpath": relpath, - "file": file_node, - "provider": "alldebrid", - "provider_view": "files", - }, + full_metadata=metadata, ) ) if len(results) >= max(1, limit): diff --git a/Provider/metadata_provider.py b/Provider/metadata_provider.py index 4a088e5..d78a3e4 100644 --- a/Provider/metadata_provider.py +++ b/Provider/metadata_provider.py @@ -11,6 +11,15 @@ import subprocess from API.HTTP import HTTPClient from ProviderCore.base import SearchResult +try: + from Provider.HIFI import HIFI +except ImportError: # pragma: no cover - optional + HIFI = None +from Provider.tidal_shared import ( + build_track_tags, + extract_artists, + stringify, +) try: # Optional dependency for IMDb scraping from imdbinfo.services import search_title # type: ignore except ImportError: # pragma: no cover - optional @@ -1416,6 +1425,95 @@ except Exception: # Registry --------------------------------------------------------------- +class TidalMetadataProvider(MetadataProvider): + """Metadata provider that reuses the HIFI search provider for tidal info.""" + + @property + def name(self) -> str: # type: ignore[override] + return "tidal" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + if HIFI is None: + raise RuntimeError("HIFI provider unavailable for tidal metadata") + super().__init__(config) + self._provider = HIFI(self.config) + + def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + normalized = str(query or "").strip() + if not normalized: + return [] + + try: + results = self._provider.search(normalized, limit=limit) + except Exception as exc: + debug(f"[tidal-meta] search failed for '{normalized}': {exc}") + return [] + + items: List[Dict[str, Any]] = [] + for result in results: + metadata = getattr(result, "full_metadata", {}) or {} + if not isinstance(metadata, dict): + metadata = {} + + title = stringify(metadata.get("title") or result.title) + if not title: + continue + + artists = extract_artists(metadata) + artist_display = ", ".join(artists) if artists else stringify(metadata.get("artist")) + + album_obj = metadata.get("album") + album = "" + if isinstance(album_obj, dict): + album = stringify(album_obj.get("title")) + else: + album = stringify(metadata.get("album")) + + year = stringify(metadata.get("releaseDate") or metadata.get("year") or metadata.get("date")) + + track_id = self._provider._parse_track_id(metadata.get("trackId") or metadata.get("id")) + lyrics_data = None + if track_id is not None: + try: + lyrics_data = self._provider._fetch_track_lyrics(track_id) + except Exception as exc: + debug(f"[tidal-meta] lyrics lookup failed for {track_id}: {exc}") + + lyrics = None + if isinstance(lyrics_data, dict): + lyrics = stringify(lyrics_data.get("lyrics") or lyrics_data.get("text")) + subtitles = stringify(lyrics_data.get("subtitles")) + if subtitles: + metadata.setdefault("_tidal_lyrics", {})["subtitles"] = subtitles + + tags = sorted(build_track_tags(metadata)) + items.append({ + "title": title, + "artist": artist_display, + "album": album, + "year": year, + "lyrics": lyrics, + "tags": tags, + "provider": self.name, + "path": getattr(result, "path", ""), + "track_id": track_id, + "full_metadata": metadata, + }) + return items + + def to_tags(self, item: Dict[str, Any]) -> List[str]: + tags: List[str] = [] + for value in item.get("tags", []): + value_text = stringify(value) + if value_text: + normalized = value_text.lower() + if normalized in {"tidal", "lossless"}: + continue + if normalized.startswith("quality:lossless"): + continue + tags.append(value_text) + return tags + _METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = { "itunes": ITunesProvider, @@ -1426,6 +1524,7 @@ _METADATA_PROVIDERS: Dict[str, "musicbrainz": MusicBrainzMetadataProvider, "imdb": ImdbMetadataProvider, "ytdlp": YtdlpMetadataProvider, + "tidal": TidalMetadataProvider, } diff --git a/Provider/tidal_shared.py b/Provider/tidal_shared.py new file mode 100644 index 0000000..44b3a5d --- /dev/null +++ b/Provider/tidal_shared.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Set + + +def stringify(value: Any) -> str: + text = str(value or "").strip() + return text + + +def extract_artists(item: Dict[str, Any]) -> List[str]: + names: List[str] = [] + artists = item.get("artists") + if isinstance(artists, list): + for artist in artists: + if isinstance(artist, dict): + name = stringify(artist.get("name")) + if name and name not in names: + names.append(name) + if not names: + primary = item.get("artist") + if isinstance(primary, dict): + name = stringify(primary.get("name")) + if name: + names.append(name) + return names + + +def build_track_tags(metadata: Dict[str, Any]) -> Set[str]: + tags: Set[str] = {"tidal"} + + audio_quality = stringify(metadata.get("audioQuality")) + if audio_quality: + tags.add(f"quality:{audio_quality.lower()}") + + media_md = metadata.get("mediaMetadata") + if isinstance(media_md, dict): + tag_values = media_md.get("tags") or [] + for tag in tag_values: + if isinstance(tag, str): + candidate = tag.strip() + if candidate: + tags.add(candidate.lower()) + + title_text = stringify(metadata.get("title")) + if title_text: + tags.add(f"title:{title_text}") + + artists = extract_artists(metadata) + for artist in artists: + artist_clean = stringify(artist) + if artist_clean: + tags.add(f"artist:{artist_clean}") + + album_title = "" + album_obj = metadata.get("album") + if isinstance(album_obj, dict): + album_title = stringify(album_obj.get("title")) + else: + album_title = stringify(metadata.get("album")) + if album_title: + tags.add(f"album:{album_title}") + + track_no_val = metadata.get("trackNumber") or metadata.get("track_number") + if track_no_val is not None: + try: + track_int = int(track_no_val) + if track_int > 0: + tags.add(f"track:{track_int}") + except Exception: + track_text = stringify(track_no_val) + if track_text: + tags.add(f"track:{track_text}") + + return tags + + +def coerce_duration_seconds(value: Any) -> Optional[int]: + candidates = [value] + try: + if isinstance(value, dict): + for key in ( + "duration", + "durationSeconds", + "duration_sec", + "duration_ms", + "durationMillis", + ): + if key in value: + candidates.append(value.get(key)) + except Exception: + pass + + for cand in candidates: + try: + if cand is None: + continue + text = str(cand).strip() + if text.lower().endswith("ms"): + text = text[:-2].strip() + num = float(text) + if num <= 0: + continue + if num > 10_000: + num = num / 1000.0 + return int(round(num)) + except Exception: + continue + return None diff --git a/ProviderCore/base.py b/ProviderCore/base.py index cdc2f45..9f310c9 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -5,7 +5,7 @@ import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, Callable @dataclass @@ -62,6 +62,22 @@ class SearchResult: if selection_args: out["_selection_args"] = selection_args + try: + selection_action = getattr(self, "selection_action", None) + except Exception: + selection_action = None + if selection_action is None: + try: + fm = getattr(self, "full_metadata", None) + if isinstance(fm, dict): + selection_action = fm.get("_selection_action") or fm.get("selection_action") + except Exception: + selection_action = None + if selection_action: + normalized = [str(x) for x in selection_action if x is not None] + if normalized: + out["_selection_action"] = normalized + return out @@ -167,6 +183,35 @@ class Provider(ABC): return None + def download_items( + self, + result: SearchResult, + output_dir: Path, + *, + emit: Callable[[Path, str, str, Dict[str, Any]], None], + progress: Any, + quiet_mode: bool, + path_from_result: Callable[[Any], Path], + config: Optional[Dict[str, Any]] = None, + ) -> int: + """Optional multi-item download hook (default no-op).""" + + _ = result + _ = output_dir + _ = emit + _ = progress + _ = quiet_mode + _ = path_from_result + _ = config + return 0 + + def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]: + """Optional provider override to parse and act on URLs.""" + + _ = url + _ = output_dir + return False, None + def upload(self, file_path: str, **kwargs: Any) -> str: """Upload a file and return a URL or identifier.""" raise NotImplementedError(f"Provider '{self.name}' does not support upload") diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 6ee4e39..3604a4a 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -135,6 +135,8 @@ class HydrusNetwork(Store): instance_name=self.NAME ) + self._service_key_cache: Dict[str, Optional[str]] = {} + # Best-effort total count (used for startup diagnostics). Avoid heavy payloads. # Some Hydrus setups appear to return no count via the CBOR client for this endpoint, # so prefer a direct JSON request with a short timeout. @@ -143,6 +145,30 @@ class HydrusNetwork(Store): except Exception: pass + def _get_service_key(self, service_name: str, *, refresh: bool = False) -> Optional[str]: + """Resolve (and cache) the Hydrus service key for the given service name.""" + normalized = str(service_name or "my tags").strip() + if not normalized: + normalized = "my tags" + cache_key = normalized.lower() + if not refresh and cache_key in self._service_key_cache: + return self._service_key_cache[cache_key] + + client = self._client + if client is None: + self._service_key_cache[cache_key] = None + return None + + try: + from API import HydrusNetwork as hydrus_wrapper + + resolved = hydrus_wrapper.get_tag_service_key(client, normalized) + except Exception: + resolved = None + + self._service_key_cache[cache_key] = resolved + return resolved + def get_total_count(self, *, refresh: bool = False) -> Optional[int]: """Best-effort total file count for this Hydrus instance. @@ -1404,8 +1430,6 @@ class HydrusNetwork(Store): where source is always "hydrus" """ try: - from API import HydrusNetwork as hydrus_wrapper - file_hash = str(file_identifier or "").strip().lower() if len(file_hash) != 64 or not all(ch in "0123456789abcdef" for ch in file_hash): @@ -1441,9 +1465,8 @@ class HydrusNetwork(Store): ) return [], "unknown" - # Extract tags using service name - service_name = "my tags" - service_key = hydrus_wrapper.get_tag_service_key(client, service_name) + service_name = kwargs.get("service_name") or "my tags" + service_key = self._get_service_key(service_name) # Extract tags from metadata tags = self._extract_tags_from_hydrus_meta(meta, service_key, service_name) @@ -1495,14 +1518,7 @@ class HydrusNetwork(Store): return True service_key: Optional[str] = None - try: - from API import HydrusNetwork as hydrus_wrapper - - service_key = hydrus_wrapper.get_tag_service_key( - client, service_name - ) - except Exception: - service_key = None + service_key = self._get_service_key(service_name) mutate_success = False if service_key: diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 0705833..befefe9 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -17,7 +17,6 @@ from contextlib import AbstractContextManager, nullcontext import requests -from API.alldebrid import is_magnet_link from Provider import internetarchive as ia_provider from Provider import alldebrid as ad_provider from Provider import openlibrary as ol_provider @@ -279,15 +278,34 @@ class Download_File(Cmdlet): except Exception: pass - if (provider_name - and str(provider_name).lower() == "alldebrid" - and is_magnet_link(str(url))): - magnet_spec = ad_provider.resolve_magnet_spec(str(url)) - if magnet_spec: - _, magnet_id = ad_provider.prepare_magnet(magnet_spec, config) - if magnet_id is not None: + provider_for_url = None + if provider_name and get_provider is not None: + provider_for_url = get_provider(provider_name, config) + + if provider_for_url is not None: + try: + handled, handled_path = provider_for_url.handle_url( + str(url), + output_dir=final_output_dir, + ) + except Exception as exc: + raise DownloadError(str(exc)) + if handled: + if handled_path: + downloaded_path = Path(handled_path) + self._emit_local_file( + downloaded_path=downloaded_path, + source=str(url), + title_hint=downloaded_path.stem, + tags_hint=None, + media_kind_hint="file", + full_metadata=None, + provider_hint=str(provider_name), + progress=progress, + config=config, + ) downloaded_count += 1 - continue + continue if provider_name and get_provider is not None and SearchResult is not None: # OpenLibrary URLs should be handled by the OpenLibrary provider. @@ -841,16 +859,16 @@ class Download_File(Cmdlet): return expanded_items def _process_provider_items(self, - *, - piped_items: Sequence[Any], - final_output_dir: Path, - config: Dict[str, - Any], - quiet_mode: bool, - registry: Dict[str, - Any], - progress: PipelineProgress, - ) -> tuple[int, int]: + *, + piped_items: Sequence[Any], + final_output_dir: Path, + config: Dict[str, + Any], + quiet_mode: bool, + registry: Dict[str, + Any], + progress: PipelineProgress, + ) -> tuple[int, int]: downloaded_count = 0 queued_magnet_submissions = 0 get_search_provider = registry.get("get_search_provider") @@ -916,9 +934,10 @@ class Download_File(Cmdlet): downloaded_path: Optional[Path] = None attempted_provider_download = False provider_sr = None + provider_obj = None if table and get_search_provider and SearchResult: - provider = get_search_provider(str(table), config) - if provider is not None: + provider_obj = get_search_provider(str(table), config) + if provider_obj is not None: attempted_provider_download = True sr = SearchResult( table=str(table), @@ -944,9 +963,53 @@ class Download_File(Cmdlet): except Exception: output_dir = final_output_dir - downloaded_path = provider.download(sr, output_dir) + downloaded_path = provider_obj.download(sr, output_dir) provider_sr = sr + if downloaded_path is None: + download_items = getattr(provider_obj, "download_items", None) + if callable(download_items): + + def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: + title_hint = metadata.get("name") or relpath or title + self._emit_local_file( + downloaded_path=path, + source=file_url or target, + title_hint=title_hint, + tags_hint=tags_list, + media_kind_hint="file", + full_metadata=metadata, + progress=progress, + config=config, + provider_hint=str(table) if table else None, + ) + + try: + downloaded_extra = download_items( + sr, + output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=self._path_from_download_result, + config=config, + ) + except TypeError: + downloaded_extra = download_items( + sr, + output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=self._path_from_download_result, + ) + except Exception: + downloaded_extra = 0 + + if downloaded_extra: + downloaded_count += int(downloaded_extra) + continue + # OpenLibrary: if provider download failed, do NOT try to download the OpenLibrary page HTML. if (downloaded_path is None and attempted_provider_download and str(table or "").lower() == "openlibrary"): @@ -1044,45 +1107,6 @@ class Download_File(Cmdlet): continue - # Magnet targets (e.g., torrent provider results) -> submit/download via AllDebrid - if downloaded_path is None and isinstance(target, str) and is_magnet_link(str(target)): - magnet_spec = ad_provider.resolve_magnet_spec(str(target)) - if magnet_spec: - - def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: - title_hint = metadata.get("name") or relpath or title - self._emit_local_file( - downloaded_path=path, - source=file_url or target, - title_hint=title_hint, - tags_hint=None, - media_kind_hint="file", - full_metadata=metadata, - progress=progress, - config=config, - provider_hint="alldebrid", - ) - - downloaded, magnet_id = ad_provider.download_magnet( - magnet_spec, - str(target), - final_output_dir, - config, - progress, - quiet_mode, - self._path_from_download_result, - _on_emit, - ) - - if downloaded > 0: - downloaded_count += downloaded - continue - - # If queued but not yet ready, skip the generic unsupported-target error. - if magnet_id is not None: - queued_magnet_submissions += 1 - continue - # Fallback: if we have a direct HTTP URL, download it directly if (downloaded_path is None and isinstance(target, str) diff --git a/scripts/requirements.txt b/scripts/requirements.txt index a045b31..0c4f92f 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -3,11 +3,10 @@ typer>=0.9.0 rich>=13.7.0 prompt-toolkit>=3.0.0 textual>=0.30.0 -pip-system-certs # Media processing and downloading -yt-dlp[default]>=2023.11.0 +yt-dlp[default] requests>=2.31.0 httpx>=0.25.0 # Ensure requests can detect encodings and ship certificates