diff --git a/lib/batch.sh b/lib/batch.sh index ba43b6d..e25835f 100644 --- a/lib/batch.sh +++ b/lib/batch.sh @@ -96,6 +96,32 @@ batch_process_folders() { # BATCH WRAPPERS (for interactive menu) ################################################################################ +# Batch rename multiple folders (interactive) +batch_rename_interactive() { + log_info "Batch Rename Multiple Folders" + echo "" + echo "Enter directories to rename (one per line, empty line to finish):" + echo "" + + local -a folders + while true; do + read -p "Directory: " dir + [[ -z "$dir" ]] && break + + dir=$(validate_directory "$dir") + if [[ $? -eq 0 ]]; then + folders+=("$dir") + fi + done + + if [[ ${#folders[@]} -eq 0 ]]; then + log_warning "No directories specified" + return 1 + fi + + batch_process_folders "rename" "${folders[@]}" +} + # Batch flatten multiple folders (interactive) batch_flatten_interactive() { log_info "Batch Flatten Multiple Folders" diff --git a/lib/catalog.sh b/lib/catalog.sh index 6b9efe9..eaaf487 100644 --- a/lib/catalog.sh +++ b/lib/catalog.sh @@ -115,78 +115,286 @@ init_catalog_db() { fi } -# Register a drive in the drives database +# Register a drive in the drives database without scanning files. +# Usage: register_drive +# Prints the drive_id on success. register_drive() { local mount_point="$1" + if [[ ! -d "$mount_point" ]]; then + log_error "Mount point does not exist: $mount_point" + return 1 + fi + init_catalog_db - local drive_id=$(get_drive_id "$mount_point") - local drive_label=$(get_drive_label "$mount_point") - local drive_type=$(detect_drive_type "$mount_point") - local timestamp=$(date -Iseconds) + local drive_id drive_label drive_type timestamp + drive_id=$(get_drive_id "$mount_point") + drive_label=$(get_drive_label "$mount_point") + drive_type=$(detect_drive_type "$mount_point") + timestamp=$(date -Iseconds) log_info "Registering drive: $drive_label ($drive_id)" - log_verbose " Mount point: $mount_point" - log_verbose " Type: $drive_type" + + local entry + entry=$(jq -n \ + --arg id "$drive_id" \ + --arg label "$drive_label" \ + --arg type "$drive_type" \ + --arg mount "$mount_point" \ + --arg ts "$timestamp" \ + '{"id":$id,"label":$label,"type":$type,"mount_point":$mount, + "file_count":0,"last_scanned":null,"registered_at":$ts}') + + jq --arg id "$drive_id" \ + --argjson entry "$entry" \ + --arg ts "$timestamp" \ + '.drives = ([.drives[] | select(.id != $id)] + [$entry]) | + .last_updated = $ts' \ + "$CATALOG_DRIVES_DB" > "${CATALOG_DRIVES_DB}.tmp" \ + && mv "${CATALOG_DRIVES_DB}.tmp" "$CATALOG_DRIVES_DB" echo "$drive_id" } ################################################################################ -# CATALOG OPERATIONS (STUB IMPLEMENTATIONS) +# CATALOG OPERATIONS ################################################################################ -# Catalog a drive and index all media files -# Note: This is a stub - full implementation not yet available +# Scan a directory/drive and index all media files into the catalog. +# Usage: catalog_drive [recursive=true] catalog_drive() { local mount_point="$1" local recursive="${2:-true}" - log_warning "Drive cataloging feature is not yet implemented" - echo "" - echo -e "${COLOR_YELLOW}This feature would:${COLOR_RESET}" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Scan drive: $mount_point" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Index all media files" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Extract metadata (duration, resolution, etc.)" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Calculate file hashes for duplicate detection" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Store in catalog database" - echo "" - return 1 + if [[ ! -d "$mount_point" ]]; then + log_error "Path does not exist: $mount_point" + return 1 + fi + + if ! command -v jq >/dev/null 2>&1; then + log_error "jq is required. Install with: sudo apt-get install jq" + return 1 + fi + + init_catalog_db + + local drive_id drive_label drive_type timestamp + drive_id=$(get_drive_id "$mount_point") + drive_label=$(get_drive_label "$mount_point") + drive_type=$(detect_drive_type "$mount_point") + timestamp=$(date -Iseconds) + + log_info "Cataloging: $drive_label ($mount_point)" + + # Build find args + local -a find_args=("$mount_point") + [[ "$recursive" != "true" ]] && find_args+=("-maxdepth" "1") + find_args+=("-type" "f" \( \ + -iname "*.mp4" -o -iname "*.mkv" -o -iname "*.avi" -o -iname "*.mov" -o \ + -iname "*.wmv" -o -iname "*.flv" -o -iname "*.webm" -o -iname "*.m4v" -o \ + -iname "*.mpg" -o -iname "*.mpeg" -o -iname "*.3gp" -o \ + -iname "*.mp3" -o -iname "*.flac" -o -iname "*.wav" -o -iname "*.aac" -o \ + -iname "*.m4a" -o -iname "*.ogg" -o \ + -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o \ + -iname "*.gif" -o -iname "*.webp" \ + \)) + + local -a media_files + mapfile -t media_files < <(find "${find_args[@]}" 2>/dev/null | sort) + local total=${#media_files[@]} + + log_info "Found $total media file(s) — indexing..." + + # Build JSON array of file entries + local file_entries="[]" + local processed=0 + + for file in "${media_files[@]}"; do + ((processed++)) + printf "\r${COLOR_CYAN}[%d/%d]${COLOR_RESET} %s" \ + "$processed" "$total" "$(basename "$file")" >&2 + + local filename size mtime media_type file_hash + filename=$(basename "$file") + size=$(stat -c %s "$file" 2>/dev/null || stat -f %z "$file" 2>/dev/null || echo 0) + mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null || echo 0) + media_type=$(get_media_type "$file") + file_hash="" + [[ "${CATALOG_INCLUDE_HASH:-false}" == true ]] && file_hash=$(calculate_file_hash "$file") + + local rel_path="${file#${mount_point}/}" + local video_id + video_id=$(echo -n "$file" | md5sum | awk '{print $1}') + + file_entries=$(echo "$file_entries" | jq \ + --arg path "$file" \ + --arg rel "$rel_path" \ + --arg name "$filename" \ + --arg type "$media_type" \ + --argjson sz "$size" \ + --argjson mt "$mtime" \ + --arg hash "$file_hash" \ + --arg drv "$drive_id" \ + --arg vid "$video_id" \ + --arg ts "$timestamp" \ + '. + [{"video_id":$vid,"path":$path,"relative_path":$rel, + "filename":$name,"media_type":$type, + "file_size":$sz,"file_mtime":$mt, + "hash":$hash,"drive_id":$drv,"last_scanned":$ts}]') + done + echo "" >&2 + + # Replace this drive's entries in catalog DB + jq --arg drv "$drive_id" \ + --argjson entries "$file_entries" \ + --arg ts "$timestamp" \ + '.videos = ([.videos[] | select(.drive_id != $drv)] + $entries) | + .last_updated = $ts' \ + "$CATALOG_DB" > "${CATALOG_DB}.tmp" && mv "${CATALOG_DB}.tmp" "$CATALOG_DB" + + # Upsert drive record + local drive_entry + drive_entry=$(jq -n \ + --arg id "$drive_id" \ + --arg label "$drive_label" \ + --arg type "$drive_type" \ + --arg mount "$mount_point" \ + --argjson n "$total" \ + --arg ts "$timestamp" \ + '{"id":$id,"label":$label,"type":$type,"mount_point":$mount, + "file_count":$n,"last_scanned":$ts,"registered_at":$ts}') + + jq --arg drv "$drive_id" \ + --argjson entry "$drive_entry" \ + --arg ts "$timestamp" \ + '.drives = ([.drives[] | select(.id != $drv)] + [$entry]) | + .last_updated = $ts' \ + "$CATALOG_DRIVES_DB" > "${CATALOG_DRIVES_DB}.tmp" \ + && mv "${CATALOG_DRIVES_DB}.tmp" "$CATALOG_DRIVES_DB" + + log_success "Cataloged $total file(s) from: $drive_label" + STATS[files_processed]=$(( ${STATS[files_processed]:-0} + total )) + return 0 } -# List all cataloged drives -# Note: This is a stub - full implementation not yet available +# List all drives registered in the catalog. list_cataloged_drives() { - log_warning "Drive listing feature is not yet implemented" + init_catalog_db + + if ! command -v jq >/dev/null 2>&1; then + log_error "jq is required. Install with: sudo apt-get install jq" + return 1 + fi + + local drive_count + drive_count=$(jq '.drives | length' "$CATALOG_DRIVES_DB" 2>/dev/null || echo 0) + echo "" - echo -e "${COLOR_YELLOW}This feature would show:${COLOR_RESET}" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} All registered drives" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Drive status (online/offline)" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Number of files cataloged" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Last scan date" + echo -e "${COLOR_BOLD}${COLOR_WHITE}Cataloged Drives${COLOR_RESET} ${COLOR_WHITE}(${drive_count} registered)${COLOR_RESET}" + echo -e "${COLOR_CYAN}────────────────────────────────────────────────────────────────${COLOR_RESET}" + + if [[ "$drive_count" -eq 0 ]]; then + echo -e " ${COLOR_YELLOW}${SYMBOL_INFO} No drives cataloged yet.${COLOR_RESET}" + echo -e " Use ${COLOR_WHITE}[1] Scan & Catalog Drive${COLOR_RESET} to add one." + echo "" + return 0 + fi + + local total_files=0 + + while IFS= read -r drive_json; do + local label type mount file_count last_scanned registered status_str status_color + label=$(echo "$drive_json" | jq -r '.label') + type=$(echo "$drive_json" | jq -r '.type') + mount=$(echo "$drive_json" | jq -r '.mount_point') + file_count=$(echo "$drive_json" | jq -r '.file_count') + last_scanned=$(echo "$drive_json"| jq -r '.last_scanned // "Never"') + registered=$(echo "$drive_json" | jq -r '.registered_at' | cut -c1-10) + + [[ "$last_scanned" != "Never" ]] && last_scanned="${last_scanned:0:10}" + + if [[ -d "$mount" ]] && df "$mount" >/dev/null 2>&1; then + status_str="Online "; status_color="${COLOR_BRIGHT_GREEN}" + else + status_str="Offline"; status_color="${COLOR_RED}" + fi + + total_files=$(( total_files + file_count )) + + printf " ${status_color}${SYMBOL_BULLET}${COLOR_RESET} ${COLOR_BOLD}${COLOR_WHITE}%-20s${COLOR_RESET}" "$label" + printf " ${status_color}[%s]${COLOR_RESET}" "$status_str" + printf " %-8s" "$type" + printf " ${COLOR_CYAN}%5s files${COLOR_RESET}" "$file_count" + printf " Scanned: ${COLOR_WHITE}%s${COLOR_RESET}" "$last_scanned" + printf " Added: ${COLOR_WHITE}%s${COLOR_RESET}\n" "$registered" + echo -e " ${COLOR_WHITE}${mount}${COLOR_RESET}" + done < <(jq -c '.drives[]' "$CATALOG_DRIVES_DB" 2>/dev/null) + + echo -e "${COLOR_CYAN}────────────────────────────────────────────────────────────────${COLOR_RESET}" + echo -e " ${COLOR_WHITE}Total files cataloged: ${COLOR_BRIGHT_CYAN}${total_files}${COLOR_RESET}" echo "" - return 1 } -# Search catalog for media files -# Note: This is a stub - full implementation not yet available +# Search catalog for media files by filename. +# Usage: search_catalog [all|video|image|audio] search_catalog() { local search_term="$1" local media_filter="${2:-all}" - log_warning "Catalog search feature is not yet implemented" - echo "" - echo -e "${COLOR_YELLOW}This feature would allow:${COLOR_RESET}" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Searching across all cataloged drives" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Finding files by name, metadata, or hash" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Filtering by media type (video, image, audio)" - echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Showing file location even if drive is offline" - echo "" - echo -e "${COLOR_WHITE}Search term: ${COLOR_RESET}$search_term" - echo -e "${COLOR_WHITE}Filter: ${COLOR_RESET}$media_filter" + if ! command -v jq >/dev/null 2>&1; then + log_error "jq is required. Install with: sudo apt-get install jq" + return 1 + fi + + if [[ -z "$search_term" ]]; then + log_error "Usage: search_catalog [all|video|image|audio]" + return 1 + fi + + init_catalog_db + + local results count + results=$(jq -c \ + --arg term "${search_term,,}" \ + --arg filter "$media_filter" \ + '[.videos[] | + select((.filename | ascii_downcase) | contains($term)) | + select($filter == "all" or .media_type == $filter)]' \ + "$CATALOG_DB" 2>/dev/null) + + if ! echo "$results" | jq empty 2>/dev/null; then + log_error "Catalog may be corrupt — run a scan to rebuild it" + return 1 + fi + + count=$(echo "$results" | jq 'length') + echo "" - return 1 + echo -e "${COLOR_BOLD}${COLOR_WHITE}Results for \"${search_term}\"${COLOR_RESET} ${COLOR_CYAN}(${count} found)${COLOR_RESET}" + echo -e "${COLOR_CYAN}────────────────────────────────────────────────────────────────${COLOR_RESET}" + + if [[ "$count" -eq 0 ]]; then + echo -e " ${COLOR_YELLOW}No matches found.${COLOR_RESET}" + echo "" + return 0 + fi + + echo "$results" | jq -r '.[] | [.filename, .path, .media_type, (.file_size | tostring)] | @tsv' | \ + while IFS=$'\t' read -r filename path media_type size; do + local size_mb=$(( size / 1048576 )) + local online_tag + if [[ -f "$path" ]]; then + online_tag="${COLOR_BRIGHT_GREEN}[Online] ${COLOR_RESET}" + else + online_tag="${COLOR_RED}[Offline]${COLOR_RESET}" + fi + echo -e " ${online_tag} ${COLOR_BOLD}${COLOR_WHITE}${filename}${COLOR_RESET} ${COLOR_CYAN}${media_type}${COLOR_RESET} ${size_mb}MB" + echo -e " ${COLOR_WHITE}${path}${COLOR_RESET}" + echo "" + done + + return 0 } ################################################################################ diff --git a/lib/core.sh b/lib/core.sh index 53f324f..ae05689 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -92,13 +92,13 @@ BATCH_SIZE=10 # Process files in batches PAUSE_BETWEEN_BATCHES=false # Pause between batches # Subtitle generation configuration -WHISPER_MODEL="base" # tiny, base, small, medium, large +WHISPER_MODEL="medium" # tiny, base, small, medium, large SUBTITLE_FORMAT="srt" # srt, vtt, txt, json SUBTITLE_LANGUAGE="auto" # auto or language code (en, es, fr, etc.) SUBTITLE_SUFFIX=".srt" SUBTITLE_PARALLEL_JOBS=1 # Number of parallel subtitle generation jobs (1-8); keep 1 on CPU to avoid OOM SUBTITLE_TIMEOUT=7200 # Max seconds per file before whisper is killed (default: 2 hours) -SUBTITLE_USE_GPU=false # Use GPU acceleration if available +SUBTITLE_USE_GPU=true # Use GPU acceleration if available SUBTITLE_OPTIMIZE_BATCH=false # Optimize batch processing order SUBTITLE_RECURSIVE=false # Scan subdirectories recursively SUBTITLE_MIN_DEPTH=1 # Minimum subdirectory depth for recursive scan @@ -222,8 +222,17 @@ load_module() { # Generic menu loop handler # Reduces boilerplate for menu-based UI patterns +# Wait for a single keypress before continuing — no Enter required. +pause_for_user() { + echo -n "Press any key to continue..." + read -rsn1 _ 2>/dev/null + echo "" +} + # Args: $1 - menu display function, $2 - choice handler function # $3 - reset_stats (true/false, default true) +# $4 - single_key (true/false, default true): read one keystroke with +# no Enter required. Set false for menus with two-digit options. # The handler function receives the choice and should return: # 0 = continue loop, 1 = break loop, 2 = invalid choice # Example: run_menu_loop show_settings_menu handle_settings_choice true @@ -231,13 +240,20 @@ run_menu_loop() { local menu_func="$1" local handler_func="$2" local reset_stats="${3:-true}" + local single_key="${4:-true}" while true; do # Display the menu "$menu_func" # Read user choice - read -r choice + local choice + if [[ "$single_key" == true ]]; then + read -rsn1 choice + echo "$choice" + else + read -r choice + fi # Handle the choice "$handler_func" "$choice" @@ -248,7 +264,7 @@ run_menu_loop() { 1) break ;; # Exit loop 2) # Invalid choice [[ "$(type -t log_error)" == "function" ]] && log_error "Invalid option" || echo "Invalid option" - read -p "Press Enter to continue..." + pause_for_user ;; esac diff --git a/lib/duplicates.sh b/lib/duplicates.sh index 2f5c077..0cbb79e 100644 --- a/lib/duplicates.sh +++ b/lib/duplicates.sh @@ -153,14 +153,27 @@ find_duplicates() { ################################################################################ # Find duplicates in catalog database (cross-drive support) -# Returns JSON array of duplicate groups +# Returns JSON array of duplicate groups, or empty array if hashing was disabled. find_duplicates_in_catalog() { init_catalog_db - local catalog_json=$(cat "$CATALOG_DB") + local catalog_json + catalog_json=$(cat "$CATALOG_DB") + + # Warn if no entries have hashes — dedup requires CATALOG_INCLUDE_HASH=true at scan time + local hashed_count + hashed_count=$(echo "$catalog_json" | jq '[.videos[] | select(.hash != null and .hash != "")] | length') + if [[ "$hashed_count" -eq 0 ]]; then + local total + total=$(echo "$catalog_json" | jq '.videos | length') + if [[ "$total" -gt 0 ]]; then + echo "Warning: catalog has $total file(s) but no hashes. Set CATALOG_INCLUDE_HASH=true and re-scan to enable duplicate detection." >&2 + fi + echo "[]" + return 0 + fi - # Group by hash and filter only groups with more than 1 file - local duplicates=$(echo "$catalog_json" | jq -r ' + echo "$catalog_json" | jq ' [.videos[] | select(.hash != null and .hash != "")] | group_by(.hash) | map(select(length > 1)) | @@ -175,14 +188,10 @@ find_duplicates_in_catalog() { relative_path: .relative_path, media_type: .media_type, file_size: .file_size, - dimensions: .dimensions, - resolution: .resolution, last_scanned: .last_scanned }) }) - ') - - echo "$duplicates" + ' } # Find similar images by dimensions (potential duplicates without hash match) diff --git a/lib/reddit.sh b/lib/reddit.sh deleted file mode 120000 index d6c303c..0000000 --- a/lib/reddit.sh +++ /dev/null @@ -1 +0,0 @@ -../../lib/reddit.sh \ No newline at end of file diff --git a/lib/reddit.sh b/lib/reddit.sh new file mode 100644 index 0000000..bd02b0c --- /dev/null +++ b/lib/reddit.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# +# Video Manager Ultimate - Reddit Image Downloader Module +# +# Downloads images from public subreddits using the Reddit OAuth2 API. +# Requires a free Reddit "script" app — register at reddit.com/prefs/apps. +# +# Credentials (pick one): +# 1. Env vars: REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET +# 2. Creds file: ~/.vmgr-reddit.conf (key=value, same var names) +# +# Dependencies: core.sh, logging.sh, utils.sh +# Requires: curl, jq +# + +################################################################################ +# OAUTH2 TOKEN MANAGEMENT +################################################################################ + +# Session-scoped token cache (cleared when shell exits) +_REDDIT_TOKEN="" +_REDDIT_TOKEN_EXPIRY=0 + +# Load credentials from ~/.vmgr-reddit.conf if not already in environment. +_reddit_load_creds() { + local creds_file="$HOME/.vmgr-reddit.conf" + + if [[ -z "$REDDIT_CLIENT_ID" || -z "$REDDIT_CLIENT_SECRET" ]] && [[ -f "$creds_file" ]]; then + local owner + owner=$(stat -c '%U' "$creds_file" 2>/dev/null || stat -f '%Su' "$creds_file" 2>/dev/null) + if [[ "$owner" != "$USER" ]]; then + log_error "~/.vmgr-reddit.conf is not owned by $USER — refusing to load" >&2 + return 1 + fi + # shellcheck source=/dev/null + source "$creds_file" + fi + + if [[ -z "$REDDIT_CLIENT_ID" || -z "$REDDIT_CLIENT_SECRET" ]]; then + log_error "Reddit credentials not found." >&2 + log_error "Register a free 'script' app at https://www.reddit.com/prefs/apps then:" >&2 + log_error " echo 'REDDIT_CLIENT_ID=your_id' >> ~/.vmgr-reddit.conf" >&2 + log_error " echo 'REDDIT_CLIENT_SECRET=your_sec' >> ~/.vmgr-reddit.conf" >&2 + log_error " chmod 600 ~/.vmgr-reddit.conf" >&2 + return 1 + fi +} + +# Fetch (or return cached) OAuth2 bearer token using application-only flow. +_reddit_get_token() { + local now + now=$(date +%s) + + # Return cached token if still valid (with 60s buffer) + if [[ -n "$_REDDIT_TOKEN" && "$now" -lt "$(( _REDDIT_TOKEN_EXPIRY - 60 ))" ]]; then + echo "$_REDDIT_TOKEN" + return 0 + fi + + _reddit_load_creds || return 1 + + local response + response=$(curl -sf \ + --max-time 15 \ + -u "${REDDIT_CLIENT_ID}:${REDDIT_CLIENT_SECRET}" \ + -A "vmgr-image-downloader/1.0 (by /u/vmgr_user)" \ + -d "grant_type=client_credentials" \ + "https://www.reddit.com/api/v1/access_token") + + if [[ -z "$response" ]]; then + log_error "Failed to reach Reddit OAuth endpoint" >&2 + return 1 + fi + + if echo "$response" | jq -e '.error' >/dev/null 2>&1; then + local err + err=$(echo "$response" | jq -r '.error // "unknown"') + log_error "Reddit OAuth error: ${err}" >&2 + return 1 + fi + + _REDDIT_TOKEN=$(echo "$response" | jq -r '.access_token') + local expires_in + expires_in=$(echo "$response" | jq -r '.expires_in // 3600') + _REDDIT_TOKEN_EXPIRY=$(( now + expires_in )) + + echo "$_REDDIT_TOKEN" +} + +################################################################################ +# REDDIT IMAGE DOWNLOAD +################################################################################ + +# Fetch one page of posts from a subreddit and print the JSON response. +# Usage: _reddit_fetch_page [after_token] +_reddit_fetch_page() { + local token="$1" + local subreddit="$2" + local limit="$3" + local after="${4:-}" + + local url="https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1" + [[ -n "$after" ]] && url="${url}&after=${after}" + + curl -sf \ + -H "Authorization: Bearer ${token}" \ + -A "vmgr-image-downloader/1.0 (by /u/vmgr_user)" \ + --max-time 15 \ + "$url" +} + +# Extract direct image URLs from a Reddit JSON response (stdin). +# Prints one URL per line. +_reddit_extract_image_urls() { + jq -r ' + .data.children[].data | + select( + (.url | test("\\.(jpg|jpeg|png|gif|webp|gifv)$"; "i")) or + (.url | test("^https?://i\\.redd\\.it/"; "i")) or + (.url | test("^https?://i\\.imgur\\.com/"; "i")) + ) | + .url | + gsub("\\.gifv$"; ".gif") + ' +} + +# Extract the pagination token from a Reddit JSON response (stdin). +_reddit_next_token() { + jq -r '.data.after // empty' +} + +# Download all images from a subreddit. +# Usage: download_subreddit_images [max_images] +download_subreddit_images() { + local subreddit="$1" + local output_dir="$2" + local max_images="${3:-200}" + + # ── Validation ────────────────────────────────────────────────────────── + if [[ -z "$subreddit" || -z "$output_dir" ]]; then + log_error "Usage: download_subreddit_images [max_images]" >&2 + return 1 + fi + + if ! command -v curl >/dev/null 2>&1; then + log_error "curl is required. Install with: sudo apt-get install curl" >&2 + return 1 + fi + + if ! command -v jq >/dev/null 2>&1; then + log_error "jq is required. Install with: sudo apt-get install jq" >&2 + return 1 + fi + + # Strip leading r/ if user typed it + subreddit="${subreddit#r/}" + subreddit="${subreddit#/r/}" + + # ── Auth ───────────────────────────────────────────────────────────────── + local token + token=$(_reddit_get_token) || return 1 + + # ── Setup ──────────────────────────────────────────────────────────────── + mkdir -p "$output_dir" || { + log_error "Cannot create output directory: $output_dir" >&2 + return 1 + } + + log_info "Downloading images from r/${subreddit} → ${output_dir}" + log_info "Max images: ${max_images}" + + local after="" + local downloaded=0 + local skipped=0 + local failed=0 + local page=1 + local per_page=100 + + # ── Pagination loop ────────────────────────────────────────────────────── + while true; do + [[ "$downloaded" -ge "$max_images" ]] && break + + log_info "Fetching page ${page} (after=${after:-start})…" >&2 + + local response + response=$(_reddit_fetch_page "$token" "$subreddit" "$per_page" "$after") + + if [[ -z "$response" ]]; then + log_error "Failed to fetch page ${page} — subreddit may be private or non-existent" >&2 + break + fi + + # Check for Reddit error objects + if echo "$response" | jq -e '.error' >/dev/null 2>&1; then + local err_msg + err_msg=$(echo "$response" | jq -r '.message // "unknown error"') + log_error "Reddit API error: ${err_msg}" >&2 + break + fi + + local urls + mapfile -t urls < <(echo "$response" | _reddit_extract_image_urls) + + if [[ "${#urls[@]}" -eq 0 ]]; then + log_verbose "No image posts on page ${page} — continuing to next page" >&2 + fi + + # ── Download each image ────────────────────────────────────────────── + for url in "${urls[@]}"; do + [[ "$downloaded" -ge "$max_images" ]] && break + + local fname + fname=$(basename "${url%%\?*}") + local dest="${output_dir}/${fname}" + + if [[ -f "$dest" ]]; then + log_verbose "Skipping (exists): ${fname}" >&2 + (( skipped++ )) || true + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY RUN] Would download: ${url} → ${dest}" + (( downloaded++ )) || true + continue + fi + + if curl -sf -L \ + -A "vmgr-image-downloader/1.0 (by /u/vmgr_user)" \ + --max-time 30 \ + -o "$dest" \ + "$url" 2>/dev/null; then + log_success "Downloaded: ${fname}" >&2 + (( downloaded++ )) || true + else + log_warning "Failed: ${url}" >&2 + rm -f "$dest" + (( failed++ )) || true + fi + done + + # ── Advance pagination ─────────────────────────────────────────────── + local next + next=$(echo "$response" | _reddit_next_token) + + if [[ -z "$next" ]]; then + log_info "Reached end of subreddit listing." >&2 + break + fi + + after="$next" + (( page++ )) || true + + # Brief pause to be polite to the API + sleep 1 + done + + # ── Summary ────────────────────────────────────────────────────────────── + echo "" + echo -e "${COLOR_BOLD}${COLOR_WHITE}Download complete for r/${subreddit}${COLOR_RESET}" + echo -e " ${COLOR_BRIGHT_GREEN}${SYMBOL_CHECK}${COLOR_RESET} Downloaded : ${COLOR_WHITE}${downloaded}${COLOR_RESET}" + echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Skipped : ${COLOR_WHITE}${skipped}${COLOR_RESET} (already existed)" + [[ "$failed" -gt 0 ]] && \ + echo -e " ${COLOR_RED}${SYMBOL_CROSS}${COLOR_RESET} Failed : ${COLOR_WHITE}${failed}${COLOR_RESET}" + echo -e " ${COLOR_CYAN}${SYMBOL_BULLET}${COLOR_RESET} Saved to : ${COLOR_WHITE}${output_dir}${COLOR_RESET}" + echo "" + + STATS[files_processed]=$(( ${STATS[files_processed]:-0} + downloaded )) + return 0 +} + +################################################################################ +# MODULE INITIALIZATION +################################################################################ + +return 0 diff --git a/lib/subtitles.sh b/lib/subtitles.sh index bfc7a34..e0d72bf 100644 --- a/lib/subtitles.sh +++ b/lib/subtitles.sh @@ -62,13 +62,9 @@ check_whisper_installation() { # Get whisper command get_whisper_command() { - if command -v whisper &> /dev/null; then - echo "whisper" - elif command -v whisper.cpp &> /dev/null; then - echo "whisper.cpp" - else - echo "" - fi + command -v whisper 2>/dev/null \ + || command -v whisper.cpp 2>/dev/null \ + || echo "" } ################################################################################ @@ -158,7 +154,6 @@ _generate_single_subtitle() { "${lang_args[@]}" \ --output_dir "$output_dir" \ --device "$device" \ - --verbose False \ "${fp16_args[@]}" >/dev/null local rc=$? @@ -336,26 +331,46 @@ generate_subtitles_in_directory() { return 0 } -# Interactively collect directories from the user and run subtitle generation on each. +# Select multiple directories and run subtitle generation on each. +# Uses fzf multi-select when available; falls back to one-per-line read loop. batch_generate_subtitles() { echo -e "${COLOR_BRIGHT_CYAN}Batch Subtitle Generation${COLOR_RESET}" - echo "Enter directories to process (one per line, empty line to start):" + echo "" local -a dirs=() - local dir - while true; do - echo -n "Directory: " - read -r dir - [[ -z "$dir" ]] && break - if [[ -d "$dir" ]]; then - dirs+=("$dir") - else - log_warning "Not found, skipping: $dir" - fi - done + local _fzf_bin + _fzf_bin=$(command -v fzf 2>/dev/null || echo "${HOME}/.fzf/bin/fzf") + + if [[ -x "$_fzf_bin" ]]; then + echo -e "${COLOR_CYAN}Select directories with TAB, press ENTER to confirm:${COLOR_RESET}" + mapfile -t dirs < <( + find . -maxdepth 6 -type d 2>/dev/null | "$_fzf_bin" \ + --multi \ + --prompt="Batch dirs> " \ + --preview='ls -la -- {} 2>/dev/null | head -20' \ + --preview-window=right:40%:wrap \ + --height=70% \ + --border \ + --bind='tab:toggle+down' \ + < /dev/tty + ) + else + echo "Enter directories to process (one per line, empty line to start):" + local dir + while true; do + echo -n "Directory: " + read -r dir + [[ -z "$dir" ]] && break + if [[ -d "$dir" ]]; then + dirs+=("$dir") + else + log_warning "Not found, skipping: $dir" + fi + done + fi if [[ ${#dirs[@]} -eq 0 ]]; then - log_warning "No directories specified" + log_warning "No directories selected" return 0 fi @@ -365,7 +380,7 @@ batch_generate_subtitles() { local i=0 for dir in "${dirs[@]}"; do i=$(( i + 1 )) - log_info "[$i/$total_dirs] Processing: $dir" + log_info "[$i/$total_dirs] $dir" generate_subtitles_in_directory "$dir" \ "$WHISPER_MODEL" "$SUBTITLE_FORMAT" "$SUBTITLE_LANGUAGE" "$DRY_RUN" done diff --git a/lib/ui.sh b/lib/ui.sh index 94bce61..86835fa 100755 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -321,11 +321,11 @@ show_granular_controls_menu() { echo -n "${COLOR_CYAN}${SYMBOL_ARROW}${COLOR_RESET} Select option: " } -# Get directory input +# Get directory input (fzf when available, typed fallback) get_directory_input() { echo "" - echo -n "${COLOR_CYAN}${SYMBOL_ARROW}${COLOR_RESET} Enter directory path: " - read -r dir + local dir + dir=$(pick_directory "Select target directory" "${TARGET_FOLDER:-.}") || return 1 dir=$(validate_directory "$dir") if [[ $? -eq 0 ]]; then @@ -346,7 +346,7 @@ _handle_single_operations_choice() { start_operation "Rename Files (Bracket Notation)" rename_files_in_directory "$TARGET_FOLDER" "$DRY_RUN" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -355,7 +355,7 @@ _handle_single_operations_choice() { start_operation "Remove Dashes" remove_dashes_in_directory "$TARGET_FOLDER" "$DRY_RUN" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -364,7 +364,7 @@ _handle_single_operations_choice() { start_operation "Fix Bracket Spacing" fix_bracket_spacing_in_directory "$TARGET_FOLDER" "$DRY_RUN" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -373,14 +373,14 @@ _handle_single_operations_choice() { start_operation "Flatten Directory" flatten_directory "$TARGET_FOLDER" "$DRY_RUN" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; 5) if get_directory_input; then workflow_deep_clean "$TARGET_FOLDER" - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -390,7 +390,7 @@ _handle_single_operations_choice() { ;; d|D) toggle_flag_with_log DRY_RUN "Dry run mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -413,7 +413,7 @@ _handle_image_ops_choice() { start_operation "Convert JPEG to JPG" convert_jpeg_to_jpg "$TARGET_FOLDER" "$DRY_RUN" "$IMAGE_RECURSIVE" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -422,7 +422,7 @@ _handle_image_ops_choice() { start_operation "Convert PNG to JPG" convert_png_to_jpg "$TARGET_FOLDER" "$DRY_RUN" "$IMAGE_RECURSIVE" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -431,7 +431,7 @@ _handle_image_ops_choice() { start_operation "Convert WebP to JPG" convert_webp_to_jpg "$TARGET_FOLDER" "$DRY_RUN" "$IMAGE_RECURSIVE" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -440,7 +440,7 @@ _handle_image_ops_choice() { start_operation "Convert HEIC to JPG" convert_heic_to_jpg "$TARGET_FOLDER" "$DRY_RUN" "$IMAGE_RECURSIVE" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -449,18 +449,18 @@ _handle_image_ops_choice() { start_operation "Convert All Images to JPG" convert_all_images_to_jpg "$TARGET_FOLDER" "$DRY_RUN" "$IMAGE_RECURSIVE" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; r|R) toggle_flag_with_log IMAGE_RECURSIVE "Recursive mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; d|D) toggle_flag_with_log DRY_RUN "Dry run mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -480,23 +480,23 @@ _handle_batch_choice() { case "$choice" in 1) start_operation "Batch Rename Multiple Folders" - batch_process_folders + batch_rename_interactive end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 2) start_operation "Batch Flatten Multiple Folders" batch_flatten_interactive end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) start_operation "Batch Full Cleanup" batch_cleanup_interactive end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -517,14 +517,14 @@ _handle_workflow_choice() { 1) if get_directory_input; then workflow_new_collection "$TARGET_FOLDER" - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; 2) if get_directory_input; then workflow_deep_clean "$TARGET_FOLDER" - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -547,7 +547,7 @@ _handle_duplicates_choice() { start_operation "Find Duplicates (Report Only)" find_duplicates "$TARGET_FOLDER" "report" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -563,7 +563,7 @@ _handle_duplicates_choice() { log_warning "Operation cancelled" fi end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -574,7 +574,7 @@ _handle_duplicates_choice() { start_operation "Find Duplicates (Dry Run)" find_duplicates "$TARGET_FOLDER" "delete" end_operation - read -p "Press Enter to continue..." + pause_for_user fi DRY_RUN="$old_dry_run" return 0 @@ -599,7 +599,7 @@ _handle_subtitles_choice() { start_operation "Generate Subtitles" generate_subtitles_in_directory "$TARGET_FOLDER" "$WHISPER_MODEL" "$SUBTITLE_FORMAT" "$SUBTITLE_LANGUAGE" "$DRY_RUN" end_operation - read -p "Press Enter to continue..." + pause_for_user fi return 0 ;; @@ -607,7 +607,7 @@ _handle_subtitles_choice() { start_operation "Batch Subtitle Generation" batch_generate_subtitles end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) @@ -624,7 +624,9 @@ _handle_subtitles_choice() { echo " [4] medium - Better accuracy" echo " [5] large - Best accuracy, slowest" echo -n "Choice [1-5]: " - read -r model_choice + local model_choice + read -rsn1 model_choice + echo "$model_choice" case "$model_choice" in 1) WHISPER_MODEL="tiny" ;; @@ -641,7 +643,9 @@ _handle_subtitles_choice() { echo " [3] txt - Plain text" echo " [4] json - JSON format" echo -n "Choice [1-4]: " - read -r format_choice + local format_choice + read -rsn1 format_choice + echo "$format_choice" case "$format_choice" in 1) SUBTITLE_FORMAT="srt" ;; @@ -665,7 +669,7 @@ _handle_subtitles_choice() { log_info "Model: $WHISPER_MODEL" log_info "Format: $SUBTITLE_FORMAT" log_info "Language: $SUBTITLE_LANGUAGE" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) @@ -685,11 +689,13 @@ _handle_subtitles_choice() { echo -e "${COLOR_YELLOW}Recursive Scanning:${COLOR_RESET}" echo -e "${COLOR_WHITE}[8]${COLOR_RESET} Toggle Recursive Mode (Current: $([[ "$SUBTITLE_RECURSIVE" == true ]] && echo "${COLOR_GREEN}ON${COLOR_RESET}" || echo "${COLOR_RED}OFF${COLOR_RESET}"))" echo -e "${COLOR_WHITE}[9]${COLOR_RESET} Set Max Depth (Current: ${COLOR_CYAN}$SUBTITLE_MAX_DEPTH${COLOR_RESET})" - echo -e "${COLOR_WHITE}[10]${COLOR_RESET} Set Max Files (Current: ${COLOR_CYAN}$SUBTITLE_MAX_FILES${COLOR_RESET})" - echo -e "${COLOR_WHITE}[11]${COLOR_RESET} Configure Filters & Selection" + echo -e "${COLOR_WHITE}[M]${COLOR_RESET} Set Max Files (Current: ${COLOR_CYAN}$SUBTITLE_MAX_FILES${COLOR_RESET})" + echo -e "${COLOR_WHITE}[F]${COLOR_RESET} Configure Filters & Selection" echo "" echo -n "Select option (or Enter to skip): " - read -r adv_choice + local adv_choice + read -rsn1 adv_choice + echo "$adv_choice" case "$adv_choice" in 1) @@ -776,7 +782,7 @@ _handle_subtitles_choice() { log_error "Invalid depth. Must be 1-50" fi ;; - 10) + m|M) echo -n "Enter maximum files to process (1-10000): " read -r max_files if [[ $max_files -ge 1 && $max_files -le 10000 ]]; then @@ -786,7 +792,7 @@ _handle_subtitles_choice() { log_error "Invalid number. Must be 1-10000" fi ;; - 11) + f|F) # Filters & Selection submenu clear echo -e "${COLOR_BRIGHT_CYAN}Advanced Filters & Selection${COLOR_RESET}" @@ -812,7 +818,9 @@ _handle_subtitles_choice() { echo -e "${COLOR_WHITE}[9]${COLOR_RESET} Reset All Filters to Default" echo "" echo -n "Select option: " - read -r filter_choice + local filter_choice + read -rsn1 filter_choice + echo "$filter_choice" case "$filter_choice" in 1) @@ -910,10 +918,10 @@ _handle_subtitles_choice() { log_success "All filters reset to default values" ;; esac - read -p "Press Enter to continue..." + pause_for_user ;; esac - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 5) @@ -921,15 +929,16 @@ _handle_subtitles_choice() { clear echo -e "${COLOR_BRIGHT_CYAN}Edit Existing Subtitle${COLOR_RESET}" echo "" - echo -n "Enter subtitle file path: " - read -r subtitle_path + local subtitle_path + subtitle_path=$(pick_file "Select subtitle file" "." "*.srt") \ + || { log_error "No file selected"; pause_for_user; return 0; } if [[ -f "$subtitle_path" ]]; then edit_subtitle_interactive "$subtitle_path" else log_error "File not found: $subtitle_path" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 6) @@ -963,7 +972,7 @@ _handle_subtitles_choice() { echo " # Add to PATH or create symlink" echo "" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -995,7 +1004,7 @@ _handle_catalog_choice() { if [[ -z "$mount_point" ]]; then log_error "No mount point specified" - read -p "Press Enter to continue..." + pause_for_user return 0 fi @@ -1007,7 +1016,7 @@ _handle_catalog_choice() { start_operation "Catalog Drive: $mount_point" catalog_drive "$mount_point" "$recursive" end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 2) @@ -1016,7 +1025,7 @@ _handle_catalog_choice() { start_operation "List Cataloged Drives" list_cataloged_drives end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) @@ -1048,7 +1057,7 @@ _handle_catalog_choice() { search_catalog "$search_term" "$media_filter" end_operation fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) @@ -1057,7 +1066,7 @@ _handle_catalog_choice() { start_operation "Find Duplicate Files" show_duplicates_report end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 5) @@ -1077,7 +1086,7 @@ _handle_catalog_choice() { if [[ ${#mount_points[@]} -eq 0 ]]; then log_warning "No mount points specified" - read -p "Press Enter to continue..." + pause_for_user return 0 fi @@ -1092,7 +1101,7 @@ _handle_catalog_choice() { catalog_drive "$mp" "$recursive" done end_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 6) @@ -1119,7 +1128,7 @@ _handle_catalog_choice() { } > "$report_file" log_success "Report exported to: $report_file" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 7) @@ -1145,7 +1154,9 @@ _handle_catalog_choice() { echo -e "${COLOR_WHITE}[6]${COLOR_RESET} Set Offline Retention Days" echo "" echo -n "Select option (or Enter to skip): " - read -r setting_choice + local setting_choice + read -rsn1 setting_choice + echo "$setting_choice" case "$setting_choice" in 1) @@ -1184,7 +1195,7 @@ _handle_catalog_choice() { fi ;; esac - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1198,7 +1209,7 @@ handle_catalog() { if ! command -v jq >/dev/null 2>&1; then log_error "jq is not installed. Please install it first:" echo " sudo apt-get install jq" - read -p "Press Enter to continue..." + pause_for_user return 1 fi @@ -1220,32 +1231,31 @@ _handle_utilities_choice() { echo " 3. Move matching files into their respective subfolders" echo " 4. Skip files in 'full' folders or already in place" echo "" - echo -n "Enter target folder with subfolders (or . for current): " - read -r target_folder - target_folder="${target_folder:-.}" + local target_folder search_path + target_folder=$(pick_directory "Select target folder (with subfolders)" ".") \ + || target_folder="." - echo -n "Enter search path (or . for current, or enter for target): " - read -r search_path - search_path="${search_path:-$target_folder}" + search_path=$(pick_directory "Select search path (where files live)" "$target_folder") \ + || search_path="$target_folder" organize_by_subfolder_names "$target_folder" "$search_path" echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 2) clear list_undo_operations echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) clear undo_organize_operation echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) @@ -1254,7 +1264,7 @@ _handle_utilities_choice() { echo "" tail -n 50 "$LOG_FILE" 2>/dev/null || echo "No log entries found" echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 5) @@ -1265,7 +1275,7 @@ _handle_utilities_choice() { else log_info "Log directory: $LOG_DIR" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 6) @@ -1288,7 +1298,7 @@ _handle_utilities_choice() { echo -e "${COLOR_WHITE}Final result:${COLOR_RESET} $result" echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 7) @@ -1317,7 +1327,7 @@ _handle_utilities_choice() { command -v identify &> /dev/null && echo -e " ${COLOR_GREEN}${SYMBOL_CHECK}${COLOR_RESET} identify (ImageMagick)" || echo -e " ${COLOR_RED}${SYMBOL_CROSS}${COLOR_RESET} identify (ImageMagick)" command -v whisper &> /dev/null && echo -e " ${COLOR_GREEN}${SYMBOL_CHECK}${COLOR_RESET} whisper" || echo -e " ${COLOR_RED}${SYMBOL_CROSS}${COLOR_RESET} whisper" echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1336,40 +1346,32 @@ _handle_organize_settings_choice() { case "$choice" in 1) echo "" - echo -n "Enter default target folder path: " - read -r target_path - if [[ -d "$target_path" ]]; then - ORGANIZE_DEFAULT_TARGET="$target_path" - log_success "Default target set to: $target_path" - else - log_warning "Path does not exist, but setting anyway: $target_path" - ORGANIZE_DEFAULT_TARGET="$target_path" - fi - read -p "Press Enter to continue..." + local target_path + target_path=$(pick_directory "Select default target folder" "${ORGANIZE_DEFAULT_TARGET:-.}") \ + || { pause_for_user; return 0; } + ORGANIZE_DEFAULT_TARGET="$target_path" + log_success "Default target set to: $target_path" + pause_for_user return 0 ;; 2) echo "" - echo -n "Enter default search path: " - read -r search_path - if [[ -d "$search_path" ]]; then - ORGANIZE_DEFAULT_SEARCH="$search_path" - log_success "Default search path set to: $search_path" - else - log_warning "Path does not exist, but setting anyway: $search_path" - ORGANIZE_DEFAULT_SEARCH="$search_path" - fi - read -p "Press Enter to continue..." + local search_path + search_path=$(pick_directory "Select default search path" "${ORGANIZE_DEFAULT_SEARCH:-.}") \ + || { pause_for_user; return 0; } + ORGANIZE_DEFAULT_SEARCH="$search_path" + log_success "Default search path set to: $search_path" + pause_for_user return 0 ;; 3) toggle_flag_with_log ORGANIZE_SHOW_PROGRESS "Progress display" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) toggle_flag_with_log ORGANIZE_LOG_OPERATIONS "Operation logging" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1388,12 +1390,12 @@ _handle_settings_choice() { case "$choice" in 1) toggle_flag_with_log DRY_RUN "Dry run mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 2) toggle_flag_with_log VERBOSE "Verbose output" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) @@ -1404,7 +1406,7 @@ _handle_settings_choice() { echo " ${SYMBOL_BULLET} .$ext" done echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) @@ -1422,7 +1424,7 @@ _handle_settings_choice() { read -p "Enter profile name (default): " profile_name profile_name=${profile_name:-default} save_config "$profile_name" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 7) @@ -1433,13 +1435,13 @@ _handle_settings_choice() { read -p "Enter profile name to load (default): " profile_name profile_name=${profile_name:-default} load_config "$profile_name" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 8) clear list_config_profiles - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 9) @@ -1454,7 +1456,7 @@ _handle_settings_choice() { delete_config_profile "$profile_name" fi fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1473,17 +1475,17 @@ _handle_granular_controls_choice() { case "$choice" in 1) toggle_flag_with_log INTERACTIVE_CONFIRM "Per-file confirmation" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 2) toggle_flag_with_log SHOW_PREVIEW "Preview mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 3) toggle_flag_with_log STEP_BY_STEP "Step-by-step mode" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 4) @@ -1501,7 +1503,7 @@ _handle_granular_controls_choice() { FILTER_BY_SIZE=false log_success "Size filter disabled" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 5) @@ -1518,7 +1520,7 @@ _handle_granular_controls_choice() { FILTER_BY_DATE=false log_success "Date filter disabled" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 6) @@ -1536,12 +1538,12 @@ _handle_granular_controls_choice() { FILTER_PATTERN="" log_success "Pattern filter disabled" fi - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 7) toggle_flag_with_log ENABLE_UNDO "Undo system" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 8) @@ -1559,13 +1561,13 @@ _handle_granular_controls_choice() { echo "No undo history file found" fi echo "" - read -p "Press Enter to continue..." + pause_for_user return 0 ;; 9) clear undo_last_operation - read -p "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1645,11 +1647,12 @@ _handle_reddit_choice() { read -rp "Subreddit name (e.g. EarthPorn): " subreddit if [[ -z "$subreddit" ]]; then log_error "No subreddit specified" - read -rp "Press Enter to continue..." + pause_for_user return 0 fi local default_dir="$HOME/Pictures/reddit/${subreddit}" + local output_dir read -rp "Output directory [${default_dir}]: " output_dir [[ -z "$output_dir" ]] && output_dir="$default_dir" @@ -1660,7 +1663,7 @@ _handle_reddit_choice() { start_operation "Reddit Download: r/${subreddit}" download_subreddit_images "$subreddit" "$output_dir" "$max_images" end_operation - read -rp "Press Enter to continue..." + pause_for_user return 0 ;; b|B) return 1 ;; @@ -1676,7 +1679,9 @@ handle_reddit() { interactive_menu() { while true; do show_main_menu - read -r choice + local choice + read -rsn1 choice + echo "$choice" _handle_main_menu_choice "$choice" done } diff --git a/lib/utils.sh b/lib/utils.sh index 8b29242..238f01c 100644 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -157,6 +157,85 @@ get_safe_filename() { } +################################################################################ +# INTERACTIVE PICKERS (fzf when available, read fallback) +################################################################################ + +# Pick a directory interactively. +# Usage: pick_directory [prompt] [start_dir] +# Prints selected path to stdout; returns 1 if nothing selected. +pick_directory() { + local prompt="${1:-Select directory}" + local start_dir="${2:-.}" + local result + + local _fzf_bin; _fzf_bin=$(command -v fzf 2>/dev/null || echo "${HOME}/.fzf/bin/fzf") + if [[ -x "$_fzf_bin" ]]; then + local fzf_out + fzf_out=$(find "$start_dir" -maxdepth 6 -type d 2>/dev/null | "$_fzf_bin" \ + --print-query \ + --prompt="$prompt> " \ + --preview='ls -la -- {} 2>/dev/null | head -20' \ + --preview-window=right:40%:wrap \ + --height=60% \ + --border \ + --ansi \ + --no-multi \ + < /dev/tty) + # --print-query: line 1 = typed query, line 2 = selected item (may be absent) + local query selected + query=$(printf '%s\n' "$fzf_out" | head -1) + selected=$(printf '%s\n' "$fzf_out" | tail -n +2 | head -1) + if [[ -n "$selected" ]]; then + result="$selected" + elif [[ -n "$query" && -d "$query" ]]; then + # User typed a valid absolute (or relative) path — use it directly + result="$query" + else + return 1 + fi + else + echo -n "${COLOR_CYAN}${SYMBOL_ARROW}${COLOR_RESET} $prompt: " >&2 + read -r result < /dev/tty + fi + + if [[ -z "$result" ]]; then + return 1 + fi + echo "$result" +} + +# Pick a file interactively. +# Usage: pick_file [prompt] [start_dir] [name_glob] +# Prints selected path to stdout; returns 1 if nothing selected. +pick_file() { + local prompt="${1:-Select file}" + local start_dir="${2:-.}" + local glob="${3:-*}" + local result + + local _fzf_bin; _fzf_bin=$(command -v fzf 2>/dev/null || echo "${HOME}/.fzf/bin/fzf") + if [[ -x "$_fzf_bin" ]]; then + result=$(find "$start_dir" -maxdepth 6 -type f -name "$glob" 2>/dev/null | "$_fzf_bin" \ + --prompt="$prompt> " \ + --preview='head -20 -- {} 2>/dev/null' \ + --preview-window=right:40%:wrap \ + --height=60% \ + --border \ + --ansi \ + --no-multi \ + < /dev/tty) + else + echo -n "${COLOR_CYAN}${SYMBOL_ARROW}${COLOR_RESET} $prompt: " >&2 + read -r result < /dev/tty + fi + + if [[ -z "$result" ]]; then + return 1 + fi + echo "$result" +} + ################################################################################ # MODULE INITIALIZATION ################################################################################ diff --git a/tests/integration/test_catalog.sh b/tests/integration/test_catalog.sh new file mode 100644 index 0000000..d4cd74a --- /dev/null +++ b/tests/integration/test_catalog.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Integration tests for catalog.sh: +# init_catalog_db, register_drive, catalog_drive, list_cataloged_drives, +# search_catalog, find_duplicates_in_catalog (hash-warning path) + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/helpers.sh" +load_vmgr_core +load_module_under_test "utils.sh" +load_module_under_test "platform.sh" +load_module_under_test "duplicates.sh" +load_module_under_test "catalog.sh" + +_setup() { + TEST_TMPDIR="$(mktemp -d)" + CATALOG_DB="$TEST_TMPDIR/catalog.json" + CATALOG_DRIVES_DB="$TEST_TMPDIR/drives.json" + CATALOG_INCLUDE_HASH=false +} +_teardown() { [[ -n "$TEST_TMPDIR" && -d "$TEST_TMPDIR" ]] && rm -rf "$TEST_TMPDIR"; } + +# ── init_catalog_db ─────────────────────────────────────────────────────────── + +test_init_creates_catalog_file() { + _setup + init_catalog_db >/dev/null 2>&1 + assert_file_exists "$CATALOG_DB" "init: catalog.json created" + _teardown +} + +test_init_creates_drives_file() { + _setup + init_catalog_db >/dev/null 2>&1 + assert_file_exists "$CATALOG_DRIVES_DB" "init: drives.json created" + _teardown +} + +test_init_catalog_is_valid_json() { + _setup + init_catalog_db >/dev/null 2>&1 + local rc=0 + jq empty "$CATALOG_DB" 2>/dev/null || rc=$? + assert_equals "0" "$rc" "init: catalog.json is valid JSON" + _teardown +} + +test_init_idempotent() { + _setup + init_catalog_db >/dev/null 2>&1 + init_catalog_db >/dev/null 2>&1 + local rc=0 + jq empty "$CATALOG_DB" 2>/dev/null || rc=$? + assert_equals "0" "$rc" "init: second call leaves valid JSON" + _teardown +} + +# ── register_drive ──────────────────────────────────────────────────────────── + +test_register_drive_adds_entry() { + _setup + register_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.drives | length' "$CATALOG_DRIVES_DB") + assert_equals "1" "$count" "register: one drive entry created" + _teardown +} + +test_register_drive_nonexistent_fails() { + _setup + local rc=0 + register_drive "/nonexistent/vmgr_test_path" >/dev/null 2>&1 || rc=$? + assert_equals "1" "$rc" "register: returns 1 for nonexistent path" + _teardown +} + +test_register_drive_idempotent() { + _setup + register_drive "$TEST_TMPDIR" >/dev/null 2>&1 + register_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.drives | length' "$CATALOG_DRIVES_DB") + assert_equals "1" "$count" "register: re-registering same drive doesn't duplicate" + _teardown +} + +test_register_drive_stores_mount_point() { + _setup + register_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local mount + mount=$(jq -r '.drives[0].mount_point' "$CATALOG_DRIVES_DB") + assert_equals "$TEST_TMPDIR" "$mount" "register: mount_point stored correctly" + _teardown +} + +# ── catalog_drive ───────────────────────────────────────────────────────────── + +test_catalog_drive_nonexistent_fails() { + _setup + local rc=0 + catalog_drive "/nonexistent/vmgr_test_path" >/dev/null 2>&1 || rc=$? + assert_equals "1" "$rc" "catalog: returns 1 for nonexistent path" + _teardown +} + +test_catalog_drive_indexes_video_files() { + _setup + touch "$TEST_TMPDIR/movie.mp4" "$TEST_TMPDIR/clip.mkv" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.videos | length' "$CATALOG_DB") + assert_equals "2" "$count" "catalog: 2 video files indexed" + _teardown +} + +test_catalog_drive_indexes_image_files() { + _setup + touch "$TEST_TMPDIR/photo.jpg" "$TEST_TMPDIR/pic.png" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.videos | length' "$CATALOG_DB") + assert_equals "2" "$count" "catalog: image files indexed" + _teardown +} + +test_catalog_drive_empty_dir_exits_zero() { + _setup + local rc=0 + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 || rc=$? + assert_equals "0" "$rc" "catalog: empty dir exits 0" + _teardown +} + +test_catalog_drive_stores_filename() { + _setup + touch "$TEST_TMPDIR/myvideo.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local name + name=$(jq -r '.videos[0].filename' "$CATALOG_DB") + assert_equals "myvideo.mp4" "$name" "catalog: filename stored" + _teardown +} + +test_catalog_drive_stores_drive_id() { + _setup + touch "$TEST_TMPDIR/clip.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local drive_id + drive_id=$(jq -r '.videos[0].drive_id' "$CATALOG_DB") + assert_not_equals "" "$drive_id" "catalog: drive_id stored" + _teardown +} + +test_catalog_drive_stores_relative_path() { + _setup + touch "$TEST_TMPDIR/clip.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local rel + rel=$(jq -r '.videos[0].relative_path' "$CATALOG_DB") + assert_equals "clip.mp4" "$rel" "catalog: relative_path stored correctly" + _teardown +} + +test_catalog_drive_upserts_on_rescan() { + _setup + touch "$TEST_TMPDIR/a.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + touch "$TEST_TMPDIR/b.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.videos | length' "$CATALOG_DB") + assert_equals "2" "$count" "catalog: rescan replaces old entries, no duplicates" + _teardown +} + +test_catalog_drive_updates_drives_db() { + _setup + touch "$TEST_TMPDIR/x.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local count + count=$(jq '.drives | length' "$CATALOG_DRIVES_DB") + assert_equals "1" "$count" "catalog: drive registered in drives.json" + _teardown +} + +test_catalog_drive_updates_file_count() { + _setup + touch "$TEST_TMPDIR/a.mp4" "$TEST_TMPDIR/b.mp4" "$TEST_TMPDIR/c.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local n + n=$(jq '.drives[0].file_count' "$CATALOG_DRIVES_DB") + assert_equals "3" "$n" "catalog: file_count in drives.json matches" + _teardown +} + +# ── search_catalog ──────────────────────────────────────────────────────────── + +test_search_catalog_empty_term_fails() { + _setup + local rc=0 + search_catalog "" >/dev/null 2>&1 || rc=$? + assert_equals "1" "$rc" "search: empty term returns 1" + _teardown +} + +test_search_catalog_finds_match() { + _setup + touch "$TEST_TMPDIR/nature_walk.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local out + out=$(search_catalog "nature" 2>/dev/null) + assert_contains "nature_walk.mp4" "$out" "search: finds file by name" + _teardown +} + +test_search_catalog_case_insensitive() { + _setup + touch "$TEST_TMPDIR/SummerTrip.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local out + out=$(search_catalog "summertrip" 2>/dev/null) + assert_contains "SummerTrip.mp4" "$out" "search: case-insensitive match" + _teardown +} + +test_search_catalog_no_match() { + _setup + touch "$TEST_TMPDIR/beach.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local out + out=$(search_catalog "zzznomatch" 2>/dev/null) + assert_contains "0 found" "$out" "search: no match shows 0 results" + _teardown +} + +test_search_catalog_media_filter_video() { + _setup + touch "$TEST_TMPDIR/file.mp4" "$TEST_TMPDIR/file.jpg" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local out + out=$(search_catalog "file" "video" 2>/dev/null) + assert_contains "file.mp4" "$out" "search: video filter includes mp4" + _teardown +} + +# ── list_cataloged_drives ───────────────────────────────────────────────────── + +test_list_drives_empty() { + _setup + local out + out=$(list_cataloged_drives 2>/dev/null) + assert_contains "0 registered" "$out" "list drives: shows 0 when empty" + _teardown +} + +test_list_drives_shows_registered_drive() { + _setup + touch "$TEST_TMPDIR/vid.mp4" + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local out + out=$(list_cataloged_drives 2>/dev/null) + assert_contains "1 registered" "$out" "list drives: shows 1 after catalog" + _teardown +} + +# ── find_duplicates_in_catalog (hash-warning path) ──────────────────────────── + +test_find_duplicates_empty_catalog_returns_array() { + _setup + init_catalog_db >/dev/null 2>&1 + local out + out=$(find_duplicates_in_catalog 2>/dev/null) + assert_equals "[]" "$out" "dupes: empty catalog returns []" + _teardown +} + +test_find_duplicates_warns_when_no_hashes() { + _setup + touch "$TEST_TMPDIR/clip.mp4" + CATALOG_INCLUDE_HASH=false + catalog_drive "$TEST_TMPDIR" >/dev/null 2>&1 + local warn + warn=$(find_duplicates_in_catalog 2>&1 >/dev/null) + assert_contains "CATALOG_INCLUDE_HASH" "$warn" "dupes: warns when hashes missing" + _teardown +} + +test_find_duplicates_no_warn_on_empty_catalog() { + _setup + init_catalog_db >/dev/null 2>&1 + local warn + warn=$(find_duplicates_in_catalog 2>&1 >/dev/null) + assert_equals "" "$warn" "dupes: no warning on empty catalog" + _teardown +} diff --git a/tests/integration/test_subtitles.sh b/tests/integration/test_subtitles.sh index 0034aaa..dbe1ca7 100644 --- a/tests/integration/test_subtitles.sh +++ b/tests/integration/test_subtitles.sh @@ -48,6 +48,7 @@ while [[ $# -gt 0 ]]; do --output_dir) output_dir="$2"; shift 2 ;; --output_format) format="$2"; shift 2 ;; --model|--language|--device) shift 2 ;; + --fp16|--verbose) shift 2 ;; *) input_file="$1"; shift ;; esac done @@ -195,6 +196,7 @@ while [[ \$# -gt 0 ]]; do --output_dir) output_dir="\$2"; shift 2 ;; --output_format) format="\$2"; shift 2 ;; --model|--language|--device) shift 2 ;; + --fp16|--verbose) shift 2 ;; *) input_file="\$1"; shift ;; esac done diff --git a/video-manager-ultimate.sh b/video-manager-ultimate.sh index ee90184..6011d0c 100755 --- a/video-manager-ultimate.sh +++ b/video-manager-ultimate.sh @@ -111,7 +111,7 @@ ${COLOR_BOLD}COMMANDS:${COLOR_RESET} subtitles Generate subtitles for videos workflow-new New collection setup workflow workflow-clean Deep clean workflow - batch Batch process multiple folders + batch Batch process multiple folders (interactive menu only) --organize Organize files by subfolder names --undo-organize [id] Undo organize operation (optional: operation ID) --list-undo List available undo operations @@ -370,9 +370,8 @@ parse_arguments() { workflow_deep_clean "$directory" ;; batch) - start_operation "Batch Processing" - batch_process_folders - end_operation + log_error "Batch processing requires the interactive menu (run without a command, then choose Batch)" + exit 1 ;; organize) organize_by_subfolder_names "$ORGANIZE_DEFAULT_TARGET" "$ORGANIZE_DEFAULT_SEARCH" diff --git a/vmgr-completion.bash b/vmgr-completion.bash index cc5aed0..48d2a64 100644 --- a/vmgr-completion.bash +++ b/vmgr-completion.bash @@ -9,7 +9,7 @@ _vmgr_completion() { prev="${COMP_WORDS[COMP_CWORD-1]}" # Main commands - local commands="rename flatten cleanup duplicates subtitles workflow-new workflow-clean batch" + local commands="rename flatten cleanup duplicates subtitles workflow-new workflow-clean batch reddit" # Special commands (prefixed with --) local special_commands="--organize --undo-organize --list-undo" @@ -73,7 +73,7 @@ _vmgr_completion() { COMPREPLY=( $(compgen -W "${commands} ${options}" -- ${cur}) ) return 0 ;; - rename|flatten|cleanup|duplicates|subtitles|workflow-new|workflow-clean) + rename|flatten|cleanup|duplicates|subtitles|workflow-new|workflow-clean|reddit|batch) # After a command, offer directory completion COMPREPLY=( $(compgen -d -- ${cur}) ) return 0