From 8d8a2637d5c8d0b4d75708ef078ab0f69d708e76 Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 27 Dec 2025 06:05:07 -0800 Subject: [PATCH] dfdfdd --- API/HydrusNetwork.py | 4 +- CLI.py | 128 ++++++++++- MPV/LUA/main.lua | 128 ++++++++++- MPV/LUA/sleep_timer.lua | 116 ++++++++++ MPV/LUA/trim.lua | 6 +- MPV/portable_config/script-opts/uosc.conf | 2 +- USAGE_ADD_FILE_BATCH.md | 132 ++++++++++++ cmdlet/add_file.py | 202 +++++++++++++++++- cmdlet/add_tag.py | 248 +++++++++++++++++++++- 9 files changed, 943 insertions(+), 23 deletions(-) create mode 100644 MPV/LUA/sleep_timer.lua create mode 100644 USAGE_ADD_FILE_BATCH.md diff --git a/API/HydrusNetwork.py b/API/HydrusNetwork.py index 819f966..a6d6095 100644 --- a/API/HydrusNetwork.py +++ b/API/HydrusNetwork.py @@ -159,7 +159,9 @@ class HydrusNetwork: from models import ProgressBar bar = ProgressBar() - label = f"{self._log_prefix().strip('[]')} upload" + # Keep the PipelineLiveProgress transfer line clean: show the file name. + # (The hydrus instance/service is already visible in the logs above.) + label = str(getattr(file_path, "name", None) or "upload") start_t = time.time() last_render_t = [start_t] sent = [0] diff --git a/CLI.py b/CLI.py index 8b42a67..0a595dc 100644 --- a/CLI.py +++ b/CLI.py @@ -1052,6 +1052,28 @@ class CmdletExecutor: if cmd_name_norm in {"get-relationship", "get-rel", ".pipe", ".matrix", ".telegram", "telegram", "delete-file", "del-file"}: return + # add-file directory selector mode: show only the selection table, no Live progress. + if cmd_name_norm in {"add-file", "add_file"}: + try: + from pathlib import Path as _Path + + toks = list(filtered_args or []) + i = 0 + while i < len(toks): + t = str(toks[i]) + low = t.lower().strip() + if low in {"-path", "--path", "-p"} and i + 1 < len(toks): + nxt = str(toks[i + 1]) + if nxt and ("," not in nxt): + p = _Path(nxt) + if p.exists() and p.is_dir(): + return + i += 2 + continue + i += 1 + except Exception: + pass + try: quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False except Exception: @@ -1097,6 +1119,20 @@ class CmdletExecutor: while i < len(toks): t = str(toks[i]) low = t.lower().strip() + if cmd_name_norm in {"add-file", "add_file"} and low in {"-path", "--path", "-p"} and i + 1 < len(toks): + nxt = str(toks[i + 1]) + if nxt: + if "," in nxt: + parts = [p.strip().strip('"\'') for p in nxt.split(",")] + parts = [p for p in parts if p] + if parts: + preview.extend(parts) + i += 2 + continue + else: + preview.append(nxt) + i += 2 + continue if low in {"-url", "--url"} and i + 1 < len(toks): nxt = str(toks[i + 1]) if nxt and not nxt.startswith("-"): @@ -1845,15 +1881,49 @@ class PipelineExecutor: else: selected_row_args: List[str] = [] skip_pipe_expansion = source_cmd == ".pipe" and len(stages) > 0 - # Only perform @N command expansion for *single-item* selections. - # For multi-item selections (e.g. @*, @1-5), expanding to a single - # row would silently drop items. In those cases we pipe the selected - # items downstream instead. - if source_cmd and not skip_pipe_expansion and len(selection_indices) == 1: - idx = selection_indices[0] - row_args = ctx.get_current_stage_table_row_selection_args(idx) - if row_args: - selected_row_args.extend(row_args) + # Command expansion via @N: + # - Default behavior: expand ONLY for single-row selections. + # - Special case: allow multi-row expansion for add-file directory tables by + # combining selected rows into a single `-path file1,file2,...` argument. + if source_cmd and not skip_pipe_expansion: + src = str(source_cmd).replace("_", "-").strip().lower() + + if src == "add-file" and selection_indices: + row_args_list: List[List[str]] = [] + for idx in selection_indices: + try: + row_args = ctx.get_current_stage_table_row_selection_args(idx) + except Exception: + row_args = None + if isinstance(row_args, list) and row_args: + row_args_list.append([str(x) for x in row_args if x is not None]) + + # Combine `['-path', ]` from each row into one `-path` arg. + paths: List[str] = [] + can_merge = bool(row_args_list) and (len(row_args_list) == len(selection_indices)) + if can_merge: + for ra in row_args_list: + if len(ra) == 2 and str(ra[0]).strip().lower() in {"-path", "--path", "-p"}: + p = str(ra[1]).strip() + if p: + paths.append(p) + else: + can_merge = False + break + + if can_merge and paths: + selected_row_args.extend(["-path", ",".join(paths)]) + elif len(selection_indices) == 1 and row_args_list: + selected_row_args.extend(row_args_list[0]) + else: + # Only perform @N command expansion for *single-item* selections. + # For multi-item selections (e.g. @*, @1-5), expanding to one row + # would silently drop items. In those cases we pipe items downstream. + if len(selection_indices) == 1: + idx = selection_indices[0] + row_args = ctx.get_current_stage_table_row_selection_args(idx) + if row_args: + selected_row_args.extend(row_args) if selected_row_args: if isinstance(source_cmd, list): @@ -2026,6 +2096,32 @@ class PipelineExecutor: name = str(stage_tokens[0]).replace("_", "-").lower() if name == "@" or name.startswith("@"): continue + + # add-file directory selector stage: avoid Live progress so the + # selection table renders cleanly. + if name in {"add-file", "add_file"}: + try: + from pathlib import Path as _Path + + toks = list(stage_tokens[1:]) + i = 0 + while i < len(toks): + t = str(toks[i]) + low = t.lower().strip() + if low in {"-path", "--path", "-p"} and i + 1 < len(toks): + nxt = str(toks[i + 1]) + if nxt and ("," not in nxt): + p = _Path(nxt) + if p.exists() and p.is_dir(): + name = "" # mark as skipped + break + i += 2 + continue + i += 1 + except Exception: + pass + if not name: + continue # Display-only: avoid Live progress for relationship viewing. # This keeps `@1 | get-relationship` clean and prevents progress UI # from interfering with Rich tables/panels. @@ -2352,6 +2448,20 @@ class PipelineExecutor: while i < len(toks): t = str(toks[i]) low = t.lower().strip() + if cmd_name == "add-file" and low in {"-path", "--path", "-p"} and i + 1 < len(toks): + nxt = str(toks[i + 1]) + if nxt: + if "," in nxt: + parts = [p.strip().strip('"\'') for p in nxt.split(",")] + parts = [p for p in parts if p] + if parts: + preview.extend(parts) + i += 2 + continue + else: + preview.append(nxt) + i += 2 + continue if low in {"-url", "--url"} and i + 1 < len(toks): nxt = str(toks[i + 1]) if nxt and not nxt.startswith("-"): diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 8ce5950..39c862e 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -688,9 +688,114 @@ local function _reset_pan_zoom() _show_image_status('Zoom reset') end +local function _sanitize_filename_component(s) + s = trim(tostring(s or '')) + if s == '' then + return 'screenshot' + end + -- Windows-unfriendly characters: <>:"/\|?* and control chars + s = s:gsub('[%c]', '') + s = s:gsub('[<>:"/\\|%?%*]', '_') + s = trim(s) + s = s:gsub('[%.%s]+$', '') + if s == '' then + return 'screenshot' + end + return s +end + +local function _strip_title_extension(title, path) + title = trim(tostring(title or '')) + if title == '' then + return title + end + path = tostring(path or '') + local ext = path:match('%.([%w%d]+)$') + if not ext or ext == '' then + return title + end + ext = ext:lower() + local suffix = '.' .. ext + if title:lower():sub(-#suffix) == suffix then + return trim(title:sub(1, #title - #suffix)) + end + return title +end + local function _capture_screenshot() - mp.commandv('screenshot') - mp.osd_message('Screenshot captured', 0.7) + local function _format_time_label(seconds) + local total = math.max(0, math.floor(tonumber(seconds or 0) or 0)) + local hours = math.floor(total / 3600) + local minutes = math.floor(total / 60) % 60 + local secs = total % 60 + local parts = {} + if hours > 0 then + table.insert(parts, ('%dh'):format(hours)) + end + if minutes > 0 or hours > 0 then + table.insert(parts, ('%dm'):format(minutes)) + end + table.insert(parts, ('%ds'):format(secs)) + return table.concat(parts) + end + + local time = mp.get_property_number('time-pos') or mp.get_property_number('time') or 0 + local label = _format_time_label(time) + + local raw_title = trim(tostring(mp.get_property('media-title') or '')) + local raw_path = tostring(mp.get_property('path') or '') + if raw_title == '' then + raw_title = 'screenshot' + end + raw_title = _strip_title_extension(raw_title, raw_path) + local safe_title = _sanitize_filename_component(raw_title) + + local filename = safe_title .. '_' .. label .. '.png' + local temp_dir = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp' + local out_path = utils.join_path(temp_dir, filename) + + local ok = pcall(function() + mp.commandv('screenshot-to-file', out_path, 'video') + end) + if not ok then + mp.osd_message('Screenshot failed', 2) + return + end + + _ensure_selected_store_loaded() + local selected_store = _get_selected_store() + selected_store = trim(tostring(selected_store or '')) + selected_store = selected_store:gsub('^\"', ''):gsub('\"$', '') + + if selected_store == '' then + mp.osd_message('Select a store first (Store button)', 2) + return + end + + local python_exe = _resolve_python_exe(true) + if not python_exe or python_exe == '' then + mp.osd_message('Screenshot saved; Python not found', 3) + return + end + + local start_dir = mp.get_script_directory() or '' + local cli_py = find_file_upwards(start_dir, 'CLI.py', 8) + if not cli_py or cli_py == '' or not utils.file_info(cli_py) then + mp.osd_message('Screenshot saved; CLI.py not found', 3) + return + end + + local res = utils.subprocess({ + args = { python_exe, cli_py, 'add-file', '-store', selected_store, '-path', out_path }, + cancellable = false, + }) + + if res and res.status == 0 then + mp.osd_message('Screenshot saved to store: ' .. selected_store, 3) + else + local stderr = (res and res.stderr) or 'unknown error' + mp.osd_message('Screenshot upload failed: ' .. tostring(stderr), 5) + end end mp.register_script_message('medeia-image-screenshot', function() @@ -2528,6 +2633,25 @@ mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle) mp.add_timeout(0, function() pcall(ensure_mpv_ipc_server) pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION) + + -- Load optional modules (kept in separate files). + pcall(function() + local script_dir = mp.get_script_directory() or '' + local candidates = {} + if script_dir ~= '' then + table.insert(candidates, script_dir .. '/sleep_timer.lua') + table.insert(candidates, script_dir .. '/LUA/sleep_timer.lua') + table.insert(candidates, script_dir .. '/../sleep_timer.lua') + end + table.insert(candidates, 'C:/medios/Medios-Macina/MPV/LUA/sleep_timer.lua') + for _, p in ipairs(candidates) do + local ok, chunk = pcall(loadfile, p) + if ok and chunk then + pcall(chunk) + break + end + end + end) end) return M diff --git a/MPV/LUA/sleep_timer.lua b/MPV/LUA/sleep_timer.lua new file mode 100644 index 0000000..755dd48 --- /dev/null +++ b/MPV/LUA/sleep_timer.lua @@ -0,0 +1,116 @@ +local mp = require 'mp' +local utils = require 'mp.utils' + +local SLEEP_MENU_TYPE = 'medeia_sleep_timer_prompt' + +local _timer = nil + +local function _trim(s) + s = tostring(s or '') + s = s:gsub('^%s+', '') + s = s:gsub('%s+$', '') + return s +end + +local function _cancel_timer() + if _timer ~= nil then + pcall(function() + _timer:kill() + end) + _timer = nil + end +end + +local function _parse_minutes(text) + local s = _trim(text) + if s == '' then + return nil + end + + local lower = s:lower() + + -- allow: 15, 15m, 1h, 1.5h + local hours = lower:match('^([%d%.]+)%s*h$') + if hours then + local v = tonumber(hours) + if v and v > 0 then + return v * 60 + end + return nil + end + + local mins = lower:match('^([%d%.]+)%s*m$') + if mins then + local v = tonumber(mins) + if v and v > 0 then + return v + end + return nil + end + + local v = tonumber(lower) + if v and v > 0 then + return v + end + + return nil +end + +local function _open_prompt() + local menu_data = { + type = SLEEP_MENU_TYPE, + title = 'Sleep Timer', + search_style = 'palette', + search_debounce = 'submit', + on_search = 'callback', + footnote = 'Enter minutes (e.g. 30) then press Enter.', + callback = { mp.get_script_name(), 'medeia-sleep-timer-event' }, + items = {}, + } + + local json = utils.format_json(menu_data) + local ok = pcall(function() + mp.commandv('script-message-to', 'uosc', 'open-menu', json) + end) + if not ok then + mp.osd_message('Sleep timer: uosc not available', 2.0) + end +end + +local function _handle_event(json) + local ok, ev = pcall(utils.parse_json, json) + if not ok or type(ev) ~= 'table' then + return + end + if ev.type ~= 'search' then + return + end + + local minutes = _parse_minutes(ev.query or '') + if not minutes then + mp.osd_message('Sleep timer cancelled', 1.0) + _cancel_timer() + return + end + + _cancel_timer() + + local seconds = math.floor(minutes * 60) + _timer = mp.add_timeout(seconds, function() + mp.osd_message('Sleep timer: closing mpv', 1.5) + mp.commandv('quit') + end) + + mp.osd_message(string.format('Sleep timer set: %d min', math.floor(minutes + 0.5)), 1.5) + + pcall(function() + mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_MENU_TYPE) + end) +end + +mp.register_script_message('medeia-sleep-timer', _open_prompt) +mp.register_script_message('medeia-sleep-timer-event', _handle_event) + +return { + open_prompt = _open_prompt, +} diff --git a/MPV/LUA/trim.lua b/MPV/LUA/trim.lua index 8ea2959..71e7dbe 100644 --- a/MPV/LUA/trim.lua +++ b/MPV/LUA/trim.lua @@ -10,10 +10,8 @@ local trim = {} -- Configuration for trim presets trim.config = { output_dir = os.getenv('TEMP') or os.getenv('TMP') or '/tmp', -- use temp dir by default - video_codec = "copy", -- lossless by default - audio_codec = "copy", container = "auto", - audio_bitrate = "", + scale = "640:-2", -- Scale to 640 width, -2 ensures even height for codec osd_duration = 2000, } @@ -26,7 +24,7 @@ trim.presets = { tiny = { video_codec="libx264", crf="28", preset="ultrafast", audio_codec="aac", audio_bitrate="64k" }, } -trim.current_quality = "copy" +trim.current_quality = "medium" -- Get active preset with current quality local function _get_active_preset() diff --git a/MPV/portable_config/script-opts/uosc.conf b/MPV/portable_config/script-opts/uosc.conf index ef324ba..3422f7c 100644 --- a/MPV/portable_config/script-opts/uosc.conf +++ b/MPV/portable_config/script-opts/uosc.conf @@ -84,7 +84,7 @@ progress_line_width=20 # fullscreen = cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen # loop-playlist = cycle:repeat:loop-playlist:no/inf!?Loop playlist # toggle:{icon}:{prop} = cycle:{icon}:{prop}:no/yes! -controls=menu,gap,subtitles,audio,video,editions,gap,shuffle,gap,prev,items,next,space,command:photo_camera:script-message medeia-image-screenshot?Screenshot,command:content_cut:script-message medeia-image-clip?Clip Marker,command:headset:script-message medeia-audio-only?Audio,command:store:script-message medeia-store-picker?Store +controls=menu,gap,subtitles,audio,video,editions,gap,shuffle,gap,prev,items,next,space,command:photo_camera:script-message medeia-image-screenshot?Screenshot,command:content_cut:script-message medeia-image-clip?Clip Marker,command:headset:script-message medeia-audio-only?Audio,command:store:script-message medeia-store-picker?Store,command:schedule:script-message medeia-sleep-timer?Sleep controls_size=32 controls_margin=8 controls_spacing=2 diff --git a/USAGE_ADD_FILE_BATCH.md b/USAGE_ADD_FILE_BATCH.md new file mode 100644 index 0000000..56078d3 --- /dev/null +++ b/USAGE_ADD_FILE_BATCH.md @@ -0,0 +1,132 @@ +# Add-File Batch Directory Mode + +## Overview +The `add-file` cmdlet now supports scanning directories for batch file operations. When you provide a directory path with the `-path` argument and specify a `-store` location, add-file will: + +1. **Scan** the directory for all supported media files +2. **Hash** each file (SHA256) +3. **Display** a result table with filename, hash, size, and extension +4. **Wait** for your selection using `@N` syntax +5. **Add** only the selected files to your store + +## Usage + +### Basic Syntax +```bash +add-file -path -store +``` + +### Step-by-Step Example + +#### Step 1: Scan directory and show table +```bash +add-file -path "C:\Users\Admin\Downloads\test_add_file" -store mystore +``` + +Output: +``` +✓ Found 3 files in directory. Use @N syntax to select (e.g., @1 or @1-3) + +Files in Directory +───────────────────────────────────────────────────────── + # │ name │ hash │ size │ ext +──────────────────────────────────────────────────────────── + 1 │ image1.jpg │ a3f9b2c1... │ 2.3 MB │ .jpg + 2 │ video1.mp4 │ d4e8f7a3... │ 45.2 MB │ .mp4 + 3 │ audio1.mp3 │ b1c9d2e3... │ 3.8 MB │ .mp3 +``` + +The command **stops here** and waits for your selection - no files are processed yet. + +#### Step 2: Select files using @N syntax +After the table is displayed, use one of these selection syntaxes: + +```bash +# Add file 1 only +@1 + +# Add files 1 through 3 +@1-3 + +# Add files 1, 2, and 3 +@1,@2,@3 + +# Add files 2 and 3 +@2-3 +``` + +The selected file(s) are then piped back to `add-file` for processing and added to your store. + +## Workflow Diagram + +``` +User Command: +┌─────────────────────────────────────────────────────┐ +│ add-file -path "D:\media" -store mystore │ +└─────────────────────────────────────────────────────┘ + ↓ + ┌───────────────────────┐ + │ STEP 1: Scan & Display│ + │ - Scan directory │ + │ - Compute hashes │ + │ - Show result table │ + │ - WAIT for @N input │ + └───────────────────────┘ + ↓ +User Response: +┌─────────────────────────────────────────────────────┐ +│ @1,@3 (select files 1 and 3) │ +└─────────────────────────────────────────────────────┘ + ↓ + ┌───────────────────────┐ + │ STEP 2: Process Files │ + │ - Get selected items │ + │ - Copy to store │ + │ - Show results │ + └───────────────────────┘ + ↓ + Files added successfully! +``` + +## Supported File Types +The directory scanner supports all media files defined in `SUPPORTED_MEDIA_EXTENSIONS`: +- **Images**: .jpg, .jpeg, .png, .gif, .webp, .bmp, .tiff +- **Videos**: .mp4, .mkv, .webm, .mov, .avi, .flv, .mpg, .mpeg, .ts, .m4v, .wmv +- **Audio**: .mp3, .flac, .wav, .m4a, .aac, .ogg, .opus, .wma, .mka +- **Documents**: .pdf, .epub, .txt, .mobi, .azw3, .cbz, .cbr, .doc, .docx + +## Key Behavior Notes + +1. **No immediate processing**: Directory scan shows table and returns without copying/adding any files +2. **User control**: Nothing happens until the user makes an `@N` selection +3. **Batch selection**: Multiple files can be selected with comma or range syntax +4. **Hash display**: Each file's SHA256 is displayed (first 12 chars in table) +5. **Error handling**: Unsupported file types are automatically filtered out + +## Implementation Details + +### New Methods Added +- **`_scan_directory_for_files(directory: Path)`**: Static method that scans a directory and returns a list of dicts with: + - `path`: Path object to the file + - `name`: Filename + - `hash`: SHA256 hash + - `size`: File size in bytes + - `ext`: File extension + +### Modified Methods +- **`run()`**: + - Detects when `-path` is a directory AND `-store` is provided + - Calls `_scan_directory_for_files()` to build the file list + - Displays result table + - **Returns early (return 0) without processing** - this is key! + - User selection via `@N` pipes selected items back to add-file for processing + +- **`_resolve_source()`**: + - Added priority for directory scan results (path + hash keys) + - Handles items coming from @N selection seamlessly + +## Error Handling +- If directory doesn't exist or isn't readable, returns error +- If a file fails to hash, it's skipped with debug output logged +- Unsupported file types are automatically filtered out during scan + diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index ff13b7c..38ebd1b 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -117,6 +117,72 @@ class Add_File(Cmdlet): stage_ctx = ctx.get_stage_context() is_last_stage = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False)) + # Directory-mode selector: + # - First pass: `add-file -store X -path ` should ONLY show a selectable table. + # - Second pass (triggered by @ selection expansion): re-run add-file with `-path file1,file2,...` + # and actually ingest/copy. + dir_scan_mode = False + dir_scan_results: Optional[List[Dict[str, Any]]] = None + explicit_path_list_results: Optional[List[Dict[str, Any]]] = None + + if path_arg and location and not provider_name: + # Support comma-separated path lists: -path "file1,file2,file3" + # This is the mechanism used by @N expansion for directory tables. + try: + path_text = str(path_arg) + except Exception: + path_text = "" + + if "," in path_text: + parts = [p.strip().strip('"') for p in path_text.split(",")] + parts = [p for p in parts if p] + + batch: List[Dict[str, Any]] = [] + for p in parts: + try: + file_path = Path(p) + except Exception: + continue + if not file_path.exists() or not file_path.is_file(): + continue + ext = file_path.suffix.lower() + if ext not in SUPPORTED_MEDIA_EXTENSIONS: + continue + try: + hv = sha256_file(file_path) + except Exception: + continue + try: + size = file_path.stat().st_size + except Exception: + size = 0 + batch.append({ + "path": file_path, + "name": file_path.name, + "hash": hv, + "size": size, + "ext": ext, + }) + + if batch: + explicit_path_list_results = batch + # Clear path_arg so add-file doesn't treat it as a single path. + path_arg = None + else: + # Directory scan (selector table, no ingest yet) + try: + candidate_dir = Path(str(path_arg)) + if candidate_dir.exists() and candidate_dir.is_dir(): + dir_scan_mode = True + debug(f"[add-file] Scanning directory for batch add: {candidate_dir}") + dir_scan_results = Add_File._scan_directory_for_files(candidate_dir) + if dir_scan_results: + debug(f"[add-file] Found {len(dir_scan_results)} supported files in directory") + # Clear path_arg so it doesn't trigger single-item mode. + path_arg = None + except Exception as exc: + debug(f"[add-file] Directory scan failed: {exc}") + # Determine if -store targets a registered backend (vs a filesystem export path). is_storage_backend_location = False if location: @@ -127,9 +193,16 @@ class Add_File(Cmdlet): is_storage_backend_location = False # Decide which items to process. + # - If directory scan was performed, use those results # - If user provided -path (and it was not reinterpreted as destination), treat this invocation as single-item. # - Otherwise, if piped input is a list, ingest each item. - if path_arg: + if explicit_path_list_results: + items_to_process = explicit_path_list_results + debug(f"[add-file] Using {len(items_to_process)} files from -path list") + elif dir_scan_results: + items_to_process = dir_scan_results + debug(f"[add-file] Using {len(items_to_process)} files from directory scan") + elif path_arg: items_to_process: List[Any] = [result] elif isinstance(result, list) and result: items_to_process = list(result) @@ -152,6 +225,65 @@ class Add_File(Cmdlet): debug(f"[add-file] INPUT result is list with {len(result)} items") debug(f"[add-file] PARSED args: location={location}, provider={provider_name}, delete={delete_after}") + # If this invocation was directory selector mode, show a selectable table and stop. + # The user then runs @N (optionally piped), which replays add-file with selected paths. + if dir_scan_mode: + try: + from result_table import ResultTable + from pathlib import Path as _Path + + # Build base args to replay: keep everything except the directory -path. + base_args: List[str] = [] + skip_next = False + for tok in list(args or []): + if skip_next: + skip_next = False + continue + t = str(tok) + if t in {"-path", "--path", "-p"}: + skip_next = True + continue + base_args.append(t) + + table = ResultTable(title="Files in Directory", preserve_order=True) + table.set_table("add-file.directory") + table.set_source_command("add-file", base_args) + + rows: List[Dict[str, Any]] = [] + for file_info in (dir_scan_results or []): + p = file_info.get("path") + hp = str(file_info.get("hash") or "") + name = str(file_info.get("name") or "unknown") + try: + clean_title = _Path(name).stem + except Exception: + clean_title = name + ext = str(file_info.get("ext") or "").lstrip(".") + size = file_info.get("size", 0) + + row_item = { + "path": str(p) if p is not None else "", + "hash": hp, + "title": clean_title, + "columns": [ + ("Title", clean_title), + ("Hash", hp), + ("Size", size), + ("Ext", ext), + ], + # Used by @N replay (CLI will combine selected rows into -path file1,file2,...) + "_selection_args": ["-path", str(p) if p is not None else ""], + } + rows.append(row_item) + table.add_result(row_item) + + ctx.set_current_stage_table(table) + ctx.set_last_result_table(table, rows, subject={"table": "add-file.directory"}) + log(f"✓ Found {len(rows)} files. Select with @N (e.g., @1 or @1-3).") + return 0 + except Exception as exc: + debug(f"[add-file] Failed to display directory scan result table: {exc}") + collected_payloads: List[Dict[str, Any]] = [] pending_relationship_pairs: Dict[str, set[tuple[str, str]]] = {} pending_url_associations: Dict[str, List[tuple[str, List[str]]]] = {} @@ -976,7 +1108,23 @@ class Add_File(Cmdlet): Returns (media_path_or_url, file_hash) where media_path_or_url can be a Path object or a URL string. """ - # PRIORITY 1: Try hash+store from result dict (most reliable for @N selections) + # PRIORITY 1a: Try hash+path from directory scan result (has 'path' and 'hash' keys) + if isinstance(result, dict): + result_path = result.get("path") + result_hash = result.get("hash") + # Check if this looks like a directory scan result (has path and hash but no 'store' key) + result_store = result.get("store") + if result_path and result_hash and not result_store: + try: + media_path = Path(result_path) if not isinstance(result_path, Path) else result_path + if media_path.exists() and media_path.is_file(): + debug(f"[add-file] Using path+hash from directory scan: {media_path}") + pipe_obj.path = str(media_path) + return media_path, str(result_hash) + except Exception as exc: + debug(f"[add-file] Failed to use directory scan result: {exc}") + + # PRIORITY 1b: Try hash+store from result dict (most reliable for @N selections) if isinstance(result, dict): result_hash = result.get("hash") result_store = result.get("store") @@ -1104,6 +1252,56 @@ class Add_File(Cmdlet): log("File path could not be resolved") return None, None + @staticmethod + def _scan_directory_for_files(directory: Path) -> List[Dict[str, Any]]: + """Scan a directory for supported media files and return list of file info dicts. + + Each dict contains: + - path: Path object + - name: filename + - hash: sha256 hash + - size: file size in bytes + - ext: file extension + """ + if not directory.exists() or not directory.is_dir(): + return [] + + files_info: List[Dict[str, Any]] = [] + + try: + for item in directory.iterdir(): + if not item.is_file(): + continue + + ext = item.suffix.lower() + if ext not in SUPPORTED_MEDIA_EXTENSIONS: + continue + + # Compute hash + try: + file_hash = sha256_file(item) + except Exception as exc: + debug(f"Failed to hash {item}: {exc}") + continue + + # Get file size + try: + size = item.stat().st_size + except Exception: + size = 0 + + files_info.append({ + "path": item, + "name": item.name, + "hash": file_hash, + "size": size, + "ext": ext, + }) + except Exception as exc: + debug(f"Error scanning directory {directory}: {exc}") + + return files_info + @staticmethod def _fetch_hydrus_path( file_hash: str, diff --git a/cmdlet/add_tag.py b/cmdlet/add_tag.py index b08abc0..fd7b16d 100644 --- a/cmdlet/add_tag.py +++ b/cmdlet/add_tag.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Dict, List, Sequence, Optional from pathlib import Path import sys +import re from SYS.logger import log @@ -26,6 +27,184 @@ from Store import Store from SYS.utils import sha256_file +_FIELD_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def _normalize_title_for_extract(text: str) -> str: + """Normalize common separators in titles for matching. + + Helps when sources use unicode dashes or odd whitespace. + """ + + s = str(text or "").strip() + if not s: + return s + # Common unicode dash variants -> '-' + s = s.replace("\u2013", "-") # en dash + s = s.replace("\u2014", "-") # em dash + s = s.replace("\u2212", "-") # minus sign + s = s.replace("\u2010", "-") # hyphen + s = s.replace("\u2011", "-") # non-breaking hyphen + s = s.replace("\u2012", "-") # figure dash + s = s.replace("\u2015", "-") # horizontal bar + return s + + +def _strip_title_prefix(text: str) -> str: + s = str(text or "").strip() + if s.lower().startswith("title:"): + s = s.split(":", 1)[1].strip() + return s + + +def _literal_to_title_pattern_regex(literal: str) -> str: + """Convert a literal chunk of a template into a regex fragment. + + Keeps punctuation literal, but treats any whitespace run as \\s*. + """ + + out: List[str] = [] + i = 0 + while i < len(literal): + ch = literal[i] + if ch.isspace(): + while i < len(literal) and literal[i].isspace(): + i += 1 + out.append(r"\\s*") + continue + out.append(re.escape(ch)) + i += 1 + return "".join(out) + + +def _compile_extract_template(template: str) -> tuple[re.Pattern[str], List[str]]: + """Compile a simple (field) template into a regex. + + Example template: + (artist) - (album) - (disk)-(track) (title) + + This is *not* user-facing regex: we only support named fields in parentheses. + """ + + tpl = str(template or "").strip() + if not tpl: + raise ValueError("empty extract template") + + matches = list(re.finditer(r"\(([^)]+)\)", tpl)) + if not matches: + raise ValueError("extract template must contain at least one (field)") + + field_names: List[str] = [] + parts: List[str] = [r"^\\s*"] + last_end = 0 + + for idx, m in enumerate(matches): + literal = tpl[last_end : m.start()] + if literal: + parts.append(_literal_to_title_pattern_regex(literal)) + + raw_name = (m.group(1) or "").strip() + if not raw_name or not _FIELD_NAME_RE.fullmatch(raw_name): + raise ValueError(f"invalid field name '{raw_name}' (use A-Z, 0-9, underscore)") + field_names.append(raw_name) + + is_last = idx == (len(matches) - 1) + if is_last: + parts.append(fr"(?P<{raw_name}>.+)") + else: + parts.append(fr"(?P<{raw_name}>.+?)") + + last_end = m.end() + + tail = tpl[last_end:] + if tail: + parts.append(_literal_to_title_pattern_regex(tail)) + parts.append(r"\\s*$") + + rx = "".join(parts) + return re.compile(rx, flags=re.IGNORECASE), field_names + + +def _extract_tags_from_title(title_text: str, template: str) -> List[str]: + """Extract (field)->value from title_text and return ['field:value', ...].""" + + title_clean = _normalize_title_for_extract(_strip_title_prefix(title_text)) + if not title_clean: + return [] + + pattern, field_names = _compile_extract_template(template) + m = pattern.match(title_clean) + if not m: + return [] + + out: List[str] = [] + for name in field_names: + value = (m.group(name) or "").strip() + if not value: + continue + out.append(f"{name}:{value}") + return out + + +def _get_title_candidates_for_extraction(res: Any, existing_tags: Optional[List[str]] = None) -> List[str]: + """Return a list of possible title strings in priority order.""" + + candidates: List[str] = [] + + def add_candidate(val: Any) -> None: + if val is None: + return + s = _normalize_title_for_extract(_strip_title_prefix(str(val))) + if not s: + return + if s not in candidates: + candidates.append(s) + + # 1) Item's title field (may be a display title, not the title: tag) + try: + add_candidate(get_field(res, "title")) + except Exception: + pass + if isinstance(res, dict): + add_candidate(res.get("title")) + + # 2) title: tag from either store tags or piped tags + tags = existing_tags if isinstance(existing_tags, list) else _extract_item_tags(res) + add_candidate(_extract_title_tag(tags) or "") + + # 3) Filename stem + try: + path_val = get_field(res, "path") + if path_val: + p = Path(str(path_val)) + add_candidate((p.stem or "").strip()) + except Exception: + pass + + return candidates + + +def _extract_tags_from_title_candidates(candidates: List[str], template: str) -> tuple[List[str], Optional[str]]: + """Try candidates in order; return (tags, matched_candidate).""" + + for c in candidates: + extracted = _extract_tags_from_title(c, template) + if extracted: + return extracted, c + return [], None + + +def _try_compile_extract_template(template: Optional[str]) -> tuple[Optional[re.Pattern[str]], Optional[str]]: + """Compile template for debug; return (pattern, error_message).""" + if template is None: + return None, None + try: + pattern, _fields = _compile_extract_template(str(template)) + return pattern, None + except Exception as exc: + return None, str(exc) + + def _extract_title_tag(tags: List[str]) -> Optional[str]: """Return the value of the first title: tag if present.""" for t in tags: @@ -242,6 +421,8 @@ class Add_Tag(Cmdlet): CmdletArg("tag", type="string", required=False, description="One or more tag to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tag from pipeline payload.", variadic=True), SharedArgs.QUERY, SharedArgs.STORE, + CmdletArg("-extract", type="string", description="Extract tags from the item's title using a simple template with (field) placeholders. Example: -extract \"(artist) - (album) - (disk)-(track) (title)\" will add artist:, album:, disk:, track:, title: tags."), + CmdletArg("--extract-debug", type="flag", description="Print debug info for -extract matching (matched title source and extracted tags)."), CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"), CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."), CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tag non-temporary files)."), @@ -258,6 +439,7 @@ class Add_Tag(Cmdlet): " Inferred format: -duplicate title,album,artist (first is source, rest are targets)", "- The source namespace must already exist in the file being tagged.", "- Target namespaces that already have a value are skipped (not overwritten).", + "- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.", ], exec=self.run, ) @@ -272,6 +454,13 @@ class Add_Tag(Cmdlet): # Parse arguments parsed = parse_cmdlet_args(args, self) + extract_template = parsed.get("extract") + if extract_template is not None: + extract_template = str(extract_template) + + extract_debug = bool(parsed.get("extract-debug", False)) + extract_debug_rx, extract_debug_err = _try_compile_extract_template(extract_template) + query_hash = sh.parse_single_hash_query(parsed.get("query")) if parsed.get("query") and not query_hash: log("[add_tag] Error: -query must be of the form hash:", file=sys.stderr) @@ -304,8 +493,10 @@ class Add_Tag(Cmdlet): if isinstance(raw_tag, str): raw_tag = [raw_tag] - # Fallback: if no tag provided explicitly, try to pull from first result payload - if not raw_tag and results: + # Fallback: if no tag provided explicitly, try to pull from first result payload. + # IMPORTANT: when -extract is used, users typically want *only* extracted tags, + # not "re-add whatever tags are already in the payload". + if not raw_tag and results and not extract_template: first = results[0] payload_tag = None @@ -341,8 +532,12 @@ class Add_Tag(Cmdlet): tag_to_add = parse_tag_arguments(raw_tag) tag_to_add = expand_tag_groups(tag_to_add) - if not tag_to_add: - log("No tag provided to add", file=sys.stderr) + if not tag_to_add and not extract_template: + log("No tag provided to add (and no -extract template provided)", file=sys.stderr) + return 1 + + if extract_template and extract_debug and extract_debug_err: + log(f"[add_tag] extract template error: {extract_debug_err}", file=sys.stderr) return 1 # Get other flags @@ -355,6 +550,9 @@ class Add_Tag(Cmdlet): store_registry = Store(config) + extract_matched_items = 0 + extract_no_match_items = 0 + for res in results: store_name: Optional[str] raw_hash: Optional[str] @@ -389,6 +587,24 @@ class Add_Tag(Cmdlet): existing_lower = {t.lower() for t in existing_tag_list if isinstance(t, str)} item_tag_to_add = list(tag_to_add) + + if extract_template: + candidates = _get_title_candidates_for_extraction(res, existing_tag_list) + extracted, matched = _extract_tags_from_title_candidates(candidates, extract_template) + if extracted: + extract_matched_items += 1 + if extract_debug: + log(f"[add_tag] extract matched: {matched!r} -> {extracted}", file=sys.stderr) + for new_tag in extracted: + if new_tag.lower() not in existing_lower: + item_tag_to_add.append(new_tag) + else: + extract_no_match_items += 1 + if extract_debug: + rx_preview = extract_debug_rx.pattern if extract_debug_rx else "" + cand_preview = "; ".join([repr(c) for c in candidates[:3]]) + log(f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview!r}. candidates: {cand_preview}", file=sys.stderr) + item_tag_to_add = collapse_namespace_tag(item_tag_to_add, "title", prefer="last") if duplicate_arg: @@ -492,6 +708,24 @@ class Add_Tag(Cmdlet): # Per-item tag list (do not mutate shared list) item_tag_to_add = list(tag_to_add) + + if extract_template: + candidates2 = _get_title_candidates_for_extraction(res, existing_tag_list) + extracted2, matched2 = _extract_tags_from_title_candidates(candidates2, extract_template) + if extracted2: + extract_matched_items += 1 + if extract_debug: + log(f"[add_tag] extract matched: {matched2!r} -> {extracted2}", file=sys.stderr) + for new_tag in extracted2: + if new_tag.lower() not in existing_lower: + item_tag_to_add.append(new_tag) + else: + extract_no_match_items += 1 + if extract_debug: + rx_preview2 = extract_debug_rx.pattern if extract_debug_rx else "" + cand_preview2 = "; ".join([repr(c) for c in candidates2[:3]]) + log(f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview2!r}. candidates: {cand_preview2}", file=sys.stderr) + item_tag_to_add = collapse_namespace_tag(item_tag_to_add, "title", prefer="last") # Handle -duplicate logic (copy existing tag to new namespaces) @@ -563,6 +797,12 @@ class Add_Tag(Cmdlet): f"[add_tag] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr, ) + + if extract_template and extract_matched_items == 0: + log(f"[add_tag] extract: no matches for template '{extract_template}' across {len(results)} item(s)", file=sys.stderr) + elif extract_template and extract_no_match_items > 0 and extract_debug: + log(f"[add_tag] extract: matched {extract_matched_items}, no-match {extract_no_match_items}", file=sys.stderr) + return 0