k
This commit is contained in:
382
MPV/LUA/main.lua
382
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=<unknown>')
|
||||
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
|
||||
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)
|
||||
end
|
||||
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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# Medeia MPV script options
|
||||
store=tutorial
|
||||
store=rpi
|
||||
|
||||
344
Provider/HIFI.py
344
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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
109
Provider/tidal_shared.py
Normal file
109
Provider/tidal_shared.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user