diff --git a/README.md b/README.md index fb07848..9841137 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Lua script that replicates and extends the YouTube "Add to Queue" feature for - **Interactive Queue Management:** A menu-driven interface for adding, removing, and rearranging videos in your queue - **yt-dlp Integration:** Gathers video info and allows downloading with any link supported by [yt-dlp](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md "yd-dlp supported sites page") +- **External Stream Fallbacks:** When rich extractor metadata is unavailable, playlist items can still be queued using mpv metadata such as `media-title` - **Internal Playlist Integration:** Seamlessly integrates with mpv's internal playlist for a unified playback experience - **Customizable Keybindings:** Assign your preferred hotkeys to interact with the currently playing video and queue @@ -25,9 +26,9 @@ This script requires the following software to be installed on the system ## Installation -- Copy `mpv-youtube-queue.lua` script to your `~~/scripts` directory - - `~/.config/mpv/scripts` on Linux - - `%APPDATA%\mpv\scripts` on Windows +- Copy the `mpv-youtube-queue/` directory to your `~~/scripts` directory + - Result on Linux: `~/.config/mpv/scripts/mpv-youtube-queue/main.lua` + - Result on Windows: `%APPDATA%\mpv\scripts\mpv-youtube-queue\main.lua` - Optionally copy `mpv-youtube-queue.conf` to the `~~/script-opts` directory - `~/.config/mpv/script-opts` on Linux - `%APPDATA%\mpv\script-opts` on Windows @@ -42,9 +43,7 @@ This script requires the following software to be installed on the system - `download_selected_video - ctrl+D`: Download the currently selected video in the queue - `move_cursor_down - ctrl+j`: Move the cursor down one row in the queue -- `move_cursor_up - ctrl+k`- Move the cursor up one row in the queue -- `load_queue - ctrl+l` - Appends the videos from the most recent save point to the - queue +- `move_cursor_up - ctrl+k`: Move the cursor up one row in the queue - `move_video - ctrl+m`: Mark/move the selected video in the queue - `play_next_in_queue - ctrl+n`: Play the next video in the queue - `open_video_in_browser - ctrl+o`: Open the currently playing video in the browser @@ -54,10 +53,6 @@ This script requires the following software to be installed on the system - `print_current_video - ctrl+P`: Print the name and channel of the currently playing video to the OSD - `print_queue - ctrl+q`: Print the contents of the queue to the OSD -- `save_queue - ctrl+s`: Saves the queue using the chosen method in - `default_save_method` -- `save_queue_alt - ctrl+S`: Saves the queue using the method not chosen in - `default_save_method` - `remove_from_queue - ctrl+x`: Remove the currently selected video from the queue - `play_selected_video - ctrl+ENTER`: Play the currently selected video in @@ -65,14 +60,11 @@ This script requires the following software to be installed on the system ### Default Options -- `default_save_method - unwatched`: The default method to use when saving the - queue. Valid options are `unwatched` or `all`. Defaults to `unwatched` - - Whichever option is chosen is the default method for the `save_queue` - binding, and the other method will be bound to `save_queue_alt` - `browser - firefox`: The browser to use when opening a video or channel page - `clipboard_command - xclip -o`: The command to use to get the contents of the clipboard - `cursor_icon - ➤`: The icon to use for the cursor - `display_limit - 10`: The maximum amount of videos to show on the OSD at once +- `max_title_length - 60`: Maximum OSD title length before truncation - `download_directory - ~/videos/YouTube`: The directory to use when downloading a video - `download_quality 720p`: The maximum download quality - `downloader - curl`: The name of the program to use to download the video @@ -84,7 +76,7 @@ This script requires the following software to be installed on the system - `ytdlp_file_format - mp4`: The preferred file format for downloaded videos - `ytdlp_output_template - %(uploader)s/%(title)s.%(ext)s`: The [yt-dlp output template string](https://github.com/yt-dlp/yt-dlp#output-template) - Full path with the default `download_directory` is: `~/videos/YouTube//.<ext>` -- `use_history_db - no`: Enable watch history tracking and remote video queuing through integration with [mpv-youtube-queue-server](https://gitea.suda.codes/sudacode/mpv-youtube-queue-server) +- `use_history_db - no`: Enable watch history tracking through integration with [mpv-youtube-queue-server](https://gitea.suda.codes/sudacode/mpv-youtube-queue-server) - `backend_host`: ip or hostname of the backend server - `backend_port`: port to connect to for the backend server diff --git a/docs/plans/2026-03-06-stream-metadata-design.md b/docs/plans/2026-03-06-stream-metadata-design.md new file mode 100644 index 0000000..88d0db7 --- /dev/null +++ b/docs/plans/2026-03-06-stream-metadata-design.md @@ -0,0 +1,62 @@ +# Stream Metadata Fallback Design + +**Context** + +`mpv-youtube-queue.lua` currently imports externally opened playlist items by calling `sync_with_playlist()`. For non-YouTube streams such as Jellyfin or custom extractor URLs, `yt-dlp --dump-single-json` can fail. The current listener flow also retries that import path on `playback-restart`, which fires during seeks, causing repeated metadata fetch attempts and repeated failures. + +**Goal** + +Keep externally opened streams in the queue while preventing seek-triggered metadata retries. When extractor metadata is unavailable, use mpv metadata, preferring `media-title`. + +**Chosen Approach** + +1. Stop using `playback-restart` as the trigger for queue import. +2. Import external items on real file loads and startup sync only. +3. Add a metadata fallback path for playlist items: + - use cached metadata first + - try `yt-dlp` once + - if that fails, build queue metadata from mpv properties, preferring `media-title` +4. Cache fallback metadata too so later syncs do not retry `yt-dlp` for the same URL. + +**Why This Approach** + +- Fixes root cause instead of hiding repeated failures behind a negative cache alone. +- Preserves current rich metadata for URLs that `yt-dlp` understands. +- Keeps Jellyfin/custom extractor streams visible in the queue with a usable title. + +**Metadata Resolution** + +For a playlist URL, resolve metadata in this order: + +1. Existing cached metadata entry +2. `yt-dlp` metadata +3. mpv fallback metadata using: + - `media-title` + - then filename/path-derived title + - placeholder values for channel/category fields + +Fallback entries should be marked so the script can distinguish rich extractor metadata from mpv-derived metadata if needed later. + +**Listener Changes** + +- Keep startup `on_load` sync. +- Keep `file-loaded` handling. +- Remove external queue bootstrap from `playback-restart`, because seeks trigger it. +- Keep existing index-tracking listeners that do not rebuild queue state. + +**Error Handling** + +- Failing extractor metadata should no longer drop the playlist item. +- Missing uploader/channel data should not be treated as fatal for fallback entries. +- Queue sync should remain best-effort per item: one bad URL should not abort the whole playlist import. + +**Regression Coverage** + +- Non-extractor stream gets queued with `media-title` fallback. +- Repeated sync for the same URL reuses cached fallback metadata instead of calling extractor again. +- Standard supported URLs still keep extractor metadata. + +**Risks** + +- mpv properties available during playlist sync may differ by source; fallback builder must handle missing values safely. +- The repo currently has no obvious test harness, so regression coverage may require a small isolated Lua test scaffold. diff --git a/docs/plans/2026-03-06-stream-metadata-fix.md b/docs/plans/2026-03-06-stream-metadata-fix.md new file mode 100644 index 0000000..0ba9f52 --- /dev/null +++ b/docs/plans/2026-03-06-stream-metadata-fix.md @@ -0,0 +1,176 @@ +# Stream Metadata Fallback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Stop seek-triggered repeated metadata lookups for external streams while still queueing Jellyfin/custom-extractor items using mpv `media-title` fallback metadata. + +**Architecture:** Remove queue bootstrap work from the seek-sensitive `playback-restart` path. Refactor metadata resolution into helpers that can use cached data, `yt-dlp`, or mpv-derived fallback values, then reuse those helpers during playlist sync and queue insertion. + +**Tech Stack:** Lua, mpv scripting API, `yt-dlp`, minimal Lua regression test harness if needed + +--- + +### Task 1: Add regression test scaffold for metadata resolution + +**Files:** +- Create: `tests/metadata_resolution_test.lua` +- Test: `tests/metadata_resolution_test.lua` + +**Step 1: Write the failing test** + +Create a small Lua test file that loads the metadata helper surface and asserts: + +```lua +local result = subject.build_fallback_video_info({ + video_url = "https://example.invalid/stream", + media_title = "Jellyfin Episode 1", +}) + +assert(result.video_name == "Jellyfin Episode 1") +``` + +Add a second test that simulates a failed extractor lookup followed by a second resolution for the same URL and asserts the extractor path is not called twice. + +**Step 2: Run test to verify it fails** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: FAIL because helper surface does not exist yet. + +**Step 3: Write minimal implementation** + +Extract or add pure helper functions in `mpv-youtube-queue.lua` for: + +```lua +build_fallback_video_info(url, props) +resolve_video_info(url, context) +``` + +Keep the interface small enough that the test can stub extractor results and mpv properties. + +**Step 4: Run test to verify it passes** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/metadata_resolution_test.lua mpv-youtube-queue.lua +git commit -m "test: add stream metadata fallback regression coverage" +``` + +### Task 2: Remove seek-triggered queue bootstrap + +**Files:** +- Modify: `mpv-youtube-queue.lua` +- Test: `tests/metadata_resolution_test.lua` + +**Step 1: Write the failing test** + +Add a regression that models the previous bad behavior: + +```lua +subject.on_playback_restart() +assert(sync_calls == 0) +``` + +or equivalent coverage around the listener registration/dispatch split if direct listener export is simpler. + +**Step 2: Run test to verify it fails** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: FAIL because `playback-restart` still triggers sync/bootstrap behavior. + +**Step 3: Write minimal implementation** + +Change listener behavior so `playback-restart` no longer calls `sync_with_playlist()` for queue bootstrap. Keep startup and `file-loaded` flows responsible for real import work. + +**Step 4: Run test to verify it passes** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/metadata_resolution_test.lua mpv-youtube-queue.lua +git commit -m "fix: avoid seek-triggered queue metadata refresh" +``` + +### Task 3: Use fallback metadata during playlist sync + +**Files:** +- Modify: `mpv-youtube-queue.lua` +- Test: `tests/metadata_resolution_test.lua` + +**Step 1: Write the failing test** + +Add a test that simulates `sync_with_playlist()` for a URL whose extractor metadata fails and asserts the resulting queue entry is still created with: + +```lua +assert(video.video_name == "Jellyfin Episode 1") +assert(video.video_url == test_url) +``` + +**Step 2: Run test to verify it fails** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: FAIL because sync currently drops entries when `yt-dlp` fails. + +**Step 3: Write minimal implementation** + +Refactor playlist import to call the new metadata resolution helper. Cache fallback metadata the same way extractor metadata is cached, and relax the fatal-field check so fallback entries can omit channel URL/uploader. + +**Step 4: Run test to verify it passes** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/metadata_resolution_test.lua mpv-youtube-queue.lua +git commit -m "fix: fallback to mpv metadata for external streams" +``` + +### Task 4: Verify end-to-end behavior and docs + +**Files:** +- Modify: `README.md` +- Modify: `docs/plans/2026-03-06-stream-metadata-design.md` +- Modify: `docs/plans/2026-03-06-stream-metadata-fix.md` + +**Step 1: Write the failing test** + +Document the expected behavior change before code handoff: + +```text +External streams should stay queued and should not re-fetch metadata on seek. +``` + +**Step 2: Run test to verify it fails** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: Existing coverage should fail if the final behavior regresses. + +**Step 3: Write minimal implementation** + +Update `README.md` with a short note that unsupported extractor sources fall back to mpv metadata such as `media-title`. + +**Step 4: Run test to verify it passes** + +Run: `lua tests/metadata_resolution_test.lua` +Expected: PASS + +If practical, also run a syntax check: + +```bash +lua -e 'assert(loadfile("mpv-youtube-queue.lua"))' +``` + +**Step 5: Commit** + +```bash +git add README.md docs/plans/2026-03-06-stream-metadata-design.md docs/plans/2026-03-06-stream-metadata-fix.md tests/metadata_resolution_test.lua mpv-youtube-queue.lua +git commit -m "docs: document stream metadata fallback behavior" +``` diff --git a/mpv-youtube-queue.conf b/mpv-youtube-queue.conf index 94acb36..450109d 100644 --- a/mpv-youtube-queue.conf +++ b/mpv-youtube-queue.conf @@ -1,10 +1,8 @@ add_to_queue=ctrl+a -default_save_method=unwatched download_current_video=ctrl+d download_selected_video=ctrl+D move_cursor_down=ctrl+j move_cursor_up=ctrl+k -load_queue=ctrl+l move_video=ctrl+m play_next_in_queue=ctrl+n open_video_in_browser=ctrl+o @@ -12,18 +10,17 @@ open_channel_in_browser=ctrl+O play_previous_in_queue=ctrl+p print_current_video=ctrl+P print_queue=ctrl+q -save_queue=ctrl+s -save_full_alt=ctrl+S remove_from_queue=ctrl+x play_selected_video=ctrl+ENTER browser=firefox clipboard_command=xclip -o cursor_icon=➤ display_limit=10 +max_title_length=60 download_directory=~/videos/YouTube download_quality=720p downloader=curl -font_name=JetBrainsMono +font_name=JetBrains Mono font_size=12 marked_icon=⇅ menu_timeout=5 diff --git a/mpv-youtube-queue.lua b/mpv-youtube-queue.lua deleted file mode 100644 index 32f6a07..0000000 --- a/mpv-youtube-queue.lua +++ /dev/null @@ -1,1236 +0,0 @@ --- mpv-youtube-queue.lua --- --- YouTube 'Add To Queue' for mpv --- --- Copyright (C) 2023 sudacode --- This program is free software: you can redistribute it and/or modify --- it under the terms of the GNU General Public License as published by --- the Free Software Foundation, either version 3 of the License, or --- (at your option) any later version. --- This program is distributed in the hope that it will be useful, --- but WITHOUT ANY WARRANTY; without even the implied warranty of --- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --- GNU General Public License for more details. --- You should have received a copy of the GNU General Public License --- along with this program. If not, see <https://www.gnu.org/licenses/>. -local mp = require("mp") -mp.options = require("mp.options") -local utils = require("mp.utils") -local assdraw = require("mp.assdraw") -local styleOn = mp.get_property("osd-ass-cc/0") -local styleOff = mp.get_property("osd-ass-cc/1") -local YouTubeQueue = {} -local video_queue = {} -local MSG_DURATION = 1.5 -local index = 0 -local selected_index = 1 -local display_offset = 0 -local marked_index = nil -local current_video = nil -local destroyer = nil -local timeout -local debug = false - --- LRU cache for video info with bounded size -local VIDEO_INFO_CACHE_MAX_SIZE = 100 -local video_info_cache = {} -local video_info_cache_order = {} -- Tracks access order for LRU eviction - -local options = { - add_to_queue = "ctrl+a", - download_current_video = "ctrl+d", - download_selected_video = "ctrl+D", - move_cursor_down = "ctrl+j", - move_cursor_up = "ctrl+k", - move_video = "ctrl+m", - play_next_in_queue = "ctrl+n", - open_video_in_browser = "ctrl+o", - open_channel_in_browser = "ctrl+O", - play_previous_in_queue = "ctrl+p", - print_current_video = "ctrl+P", - print_queue = "ctrl+q", - remove_from_queue = "ctrl+x", - play_selected_video = "ctrl+ENTER", - browser = "firefox", - clipboard_command = "xclip -o", - cursor_icon = "➤", - display_limit = 10, - download_directory = "~/videos/YouTube", - download_quality = "720p", - downloader = "curl", - font_name = "JetBrains Mono", - font_size = 12, - marked_icon = "⇅", - menu_timeout = 5, - show_errors = true, - ytdlp_file_format = "mp4", - ytdlp_output_template = "%(uploader)s/%(title)s.%(ext)s", - use_history_db = false, - backend_host = "http://localhost", - backend_port = "42069", - save_queue = "ctrl+s", - save_queue_alt = "ctrl+S", - default_save_method = "unwatched", - load_queue = "ctrl+l", - -- Title truncation - max_title_length = 60, -} -mp.options.read_options(options, "mpv-youtube-queue") - -local function destroy() - timeout:kill() - mp.set_osd_ass(0, 0, "") - destroyer = nil -end - -timeout = mp.add_periodic_timer(options.menu_timeout, destroy) - --- STYLE {{{ --- Catppuccin Macchiato color palette (BGR format for ASS) -local colors = { - error = "9687ED", -- Red (#ed8796) - selected = "F5BDE6", -- Pink (#f5bde6) - hover_selected = "C6C6F0", -- Flamingo (#f0c6c6) - cursor = "9FD4EE", -- Yellow (#eed49f) - header = "CAD58B", -- Teal (#8bd5ca) - hover = "F8BDB7", -- Lavender (#b7bdf8) - text = "E0C0B8", -- Subtext1 (#b8c0e0) - marked = "F6A0C6", -- Mauve (#c6a0f6) -} - -local notransparent = "\\alpha&H00&" -local semitransparent = "\\alpha&H40&" -local sortoftransparent = "\\alpha&H59&" - -local style = { - error = "{\\c&" .. colors.error .. "&" .. notransparent .. "}", - selected = "{\\c&" .. colors.selected .. "&" .. semitransparent .. "}", - hover_selected = "{\\c&" .. colors.hover_selected .. "&\\alpha&H33&}", - cursor = "{\\c&" .. colors.cursor .. "&" .. notransparent .. "}", - marked = "{\\c&" .. colors.marked .. "&" .. notransparent .. "}", - reset = "{\\c&" .. colors.text .. "&" .. sortoftransparent .. "}", - header = "{\\fn" - .. options.font_name - .. "\\fs" - .. options.font_size * 1.5 - .. "\\u1\\b1\\c&" - .. colors.header - .. "&" - .. notransparent - .. "}", - hover = "{\\c&" .. colors.hover .. "&" .. semitransparent .. "}", - font = "{\\fn" .. options.font_name .. "\\fs" .. options.font_size .. "{" .. sortoftransparent .. "}", -} --- }}} - --- HELPERS {{{ - ---- Adds an item to the LRU cache, evicting the oldest entry if cache is full ---- @param url string - the URL key ---- @param info table - the video info to cache -local function cache_video_info(url, info) - -- If already in cache, remove from order list (will be re-added at end) - if video_info_cache[url] then - for i, cached_url in ipairs(video_info_cache_order) do - if cached_url == url then - table.remove(video_info_cache_order, i) - break - end - end - end - - -- Evict oldest entry if cache is full - while #video_info_cache_order >= VIDEO_INFO_CACHE_MAX_SIZE do - local oldest_url = table.remove(video_info_cache_order, 1) - video_info_cache[oldest_url] = nil - if debug then - print("LRU cache evicted: " .. oldest_url) - end - end - - -- Add to cache and order list - video_info_cache[url] = info - table.insert(video_info_cache_order, url) -end - ---- Gets an item from the LRU cache, updating access order ---- @param url string - the URL key ---- @return table | nil - the cached video info, or nil if not found -local function get_cached_video_info(url) - local info = video_info_cache[url] - if info then - -- Move to end of order list (most recently used) - for i, cached_url in ipairs(video_info_cache_order) do - if cached_url == url then - table.remove(video_info_cache_order, i) - table.insert(video_info_cache_order, url) - break - end - end - end - return info -end - ---- Truncates a string to a maximum length, adding ellipsis if truncated ---- @param s string - the string to truncate ---- @param max_len number - the maximum length ---- @return string - the truncated string -local function truncate_string(s, max_len) - if not s or max_len <= 0 then - return s or "" - end - if #s <= max_len then - return s - end - if max_len <= 3 then - return string.sub(s, 1, max_len) - end - return string.sub(s, 1, max_len - 3) .. "..." -end - ---- surround string with single quotes if it does not already have them ---- @param s string - the string to surround with quotes ---- @return string | nil - the string surrounded with quotes -local function surround_with_quotes(s) - if string.sub(s, 0, 1) == '"' and string.sub(s, -1) == '"' then - return nil - else - return '"' .. s .. '"' - end -end - ---- return true if the input is null, empty, or 0 ---- @param s any - the input to check for nullity ---- @return boolean - true if the input is null, false otherwise -local function isnull(s) - if s == nil then - return true - elseif type(s) == "string" and s:match("^%s*$") then - return true - elseif type(s) == "number" and s == 0 then - return true - elseif type(s) == "table" and next(s) == nil then - return true - elseif type(s) == "boolean" and not s then - return true - end - return false -end - --- remove single quotes, newlines, and carriage returns from a string -local function strip(s) - return string.gsub(s, "['\n\r]", "") -end - --- print a message to the OSD ----@param message string - the message to print ----@param duration number - the duration to display the message ----@param s string - the style to use for the message -local function print_osd_message(message, duration, s) - if s == style.error and not options.show_errors then - return - end - destroy() - if s == nil then - s = style.font .. "{" .. notransparent .. "}" - end - if duration == nil then - duration = MSG_DURATION - end - mp.osd_message(styleOn .. s .. message .. style.reset .. styleOff .. "\n", duration) -end - ----returns true if the provided path exists and is a file ----@param filepath string - the path to check ----@return boolean - true if the path is a file, false otherwise -local function is_file(filepath) - local result = utils.file_info(filepath) - if debug and type(result) == "table" then - print("IS_FILE() check: " .. tostring(result.is_file)) - end - if result == nil or type(result) ~= "table" then - return false - end - return true -end - ----returns the filename given a path (eg. /home/user/file.txt -> file.txt) ----@param filepath string - the path to extract the filename from ----@return string | nil - the filename -local function split_path(filepath) - if is_file(filepath) then - return utils.split_path(filepath) - end -end - ---- returns the expanded path of a file. eg. ~/file.txt -> /home/user/file.txt ---- @param path string - the path to expand ---- @return string - the expanded path -local function expanduser(path) - -- remove trailing slash if it exists - if string.sub(path, -1) == "/" then - path = string.sub(path, 1, -2) - end - if path:sub(1, 1) == "~" then - local home = os.getenv("HOME") - if home then - return home .. path:sub(2) - else - return path - end - else - return path - end -end - ----Open a URL in the browser ----@param url string -local function open_url_in_browser(url) - local command = options.browser .. " " .. surround_with_quotes(url) - os.execute(command) -end - ---- Opens the current video in the browser -local function open_video_in_browser() - if current_video and current_video.video_url then - open_url_in_browser(current_video.video_url) - end -end - ---- Opens the channel of the current video in the browser -local function open_channel_in_browser() - if current_video and current_video.channel_url then - open_url_in_browser(current_video.channel_url) - end -end - --- Internal function to print the contents of the internal playlist to the console -local function print_internal_playlist() - local count = mp.get_property_number("playlist-count") - print("Playlist contents:") - for i = 0, count - 1 do - local uri = mp.get_property(string.format("playlist/%d/filename", i)) - print(string.format("%d: %s", i, uri)) - end -end - ---- Helper function to build the OSD row for the queue ---- @param prefix string - the prefix to add to the row ---- @param s string - the style to apply to the row ---- @param i number - the index of the row ---- @param video_name string - the title of the video ---- @param channel_name string - the name of the channel ---- @return string - the OSD row -local function build_osd_row(prefix, s, i, video_name, channel_name) - local truncated_name = truncate_string(video_name, options.max_title_length) - return prefix .. s .. i .. ". " .. truncated_name .. " - (" .. channel_name .. ")" -end - ---- Helper function to determine display range for queue items ---- @param queue_length number Total number of items in queue ---- @param selected number Currently selected index ---- @param limit number Maximum items to display ---- @return number, number start and end indices -local function get_display_range(queue_length, selected, limit) - local half_limit = math.floor(limit / 2) - local start_index = selected <= half_limit and 1 or selected - half_limit - local end_index = math.min(start_index + limit - 1, queue_length) - return start_index, end_index -end - ---- Helper function to get the style for a queue item ---- @param i number Current item index ---- @param current number Currently playing index ---- @param selected number Selected index ---- @return string Style to apply -local function get_item_style(i, current, selected) - if i == current and i == selected then - return style.hover_selected - elseif i == current then - return style.selected - elseif i == selected then - return style.hover - end - return style.reset -end - ---- Toggle queue visibility -local function toggle_print() - if destroyer ~= nil then - destroyer() - else - YouTubeQueue.print_queue() - end -end - --- Function to remove leading and trailing quotes from the first and last arguments of a command table in-place -local function remove_command_quotes(s) - -- if the first character of the first argument is a quote, remove it - if string.sub(s[1], 1, 1) == "'" or string.sub(s[1], 1, 1) == '"' then - s[1] = string.sub(s[1], 2) - end - -- if the last character of the last argument is a quote, remove it - if string.sub(s[#s], -1) == "'" or string.sub(s[#s], -1) == '"' then - s[#s] = string.sub(s[#s], 1, -2) - end -end - ---- Function to split the clipboard_command into it's parts and return as a table ---- @param cmd string - the command to split ---- @return table - the split command as a table -local function split_command(cmd) - local components = {} - for arg in cmd:gmatch("%S+") do - table.insert(components, arg) - end - remove_command_quotes(components) - return components -end - ---- Converts a key-value pair or a table of key-value pairs into a JSON string. ---- If the key is a table, it iterates over the table to construct a JSON object. ---- If the key is a single value, it constructs a JSON object with the provided key and value. ---- @param key any - A single key or a table of key-value pairs to convert. ---- @param val any - The value associated with the key, used only if the key is not a table. ---- @return string | nil - The resulting JSON string, or nil if the input is invalid. -local function convert_to_json(key, val) - if type(key) == "table" then - -- Handle the case where key is a table of key-value pairs - local json = "{" - local first = true - for k, v in pairs(key) do - if not first then - json = json .. ", " - end - first = false - - local quoted_val = string.format('"%s"', v) - json = json .. string.format('"%s": %s', k, quoted_val) - end - json = json .. "}" - return json - else - -- Handle array values (table as val) - if type(val) == "table" then - local arr = "[" - local first = true - for _, v in ipairs(val) do - if not first then - arr = arr .. ", " - end - first = false - arr = arr .. string.format('"%s"', v) - end - arr = arr .. "]" - return string.format('{"%s": %s}', key, arr) - elseif type(val) == "string" then - return string.format('{"%s": "%s"}', key, val) - else - return string.format('{"%s": %s}', key, tostring(val)) - end - end -end - --- }}} - --- QUEUE GETTERS AND SETTERS {{{ - ---- Gets the video at the specified index ---- @param idx number - the index of the video to get ---- @return table | nil - the video at the specified index -function YouTubeQueue.get_video_at(idx) - if idx <= 0 or idx > #video_queue then - print_osd_message("Invalid video index", MSG_DURATION, style.error) - return nil - end - return video_queue[idx] -end - ---- returns the content of the clipboard ---- @return string | nil - the content of the clipboard -function YouTubeQueue.get_clipboard_content() - local command = split_command(options.clipboard_command) - local res = mp.command_native({ - name = "subprocess", - playback_only = false, - capture_stdout = true, - args = command, - }) - - if res.status ~= 0 then - print_osd_message("Failed to get clipboard content", MSG_DURATION, style.error) - return nil - end - - local content = res.stdout:match("^%s*(.-)%s*$") -- Trim leading/trailing spaces - if content:match("^https?://") then - return content - elseif content:match("^file://") or utils.file_info(content) then - return content - else - print_osd_message("Clipboard content is not a valid URL or file path", MSG_DURATION, style.error) - return nil - end -end - ---- Function to get the video info from the URL ---- @param url string - the URL to get the video info from ---- @return table | nil - a table containing the video information -function YouTubeQueue.get_video_info(url) - -- Check LRU cache first - local cached = get_cached_video_info(url) - if cached then - if debug then - print("Cache hit for URL: " .. url) - end - return cached - end - - if debug then - print("Cache miss for URL: " .. url) - end - - print_osd_message("Getting video info...", MSG_DURATION * 2) - local res = mp.command_native({ - name = "subprocess", - playback_only = false, - capture_stdout = true, - args = { - "yt-dlp", - "--dump-single-json", - "--ignore-config", - "--no-warnings", - "--skip-download", - "--playlist-items", - "1", - url, - }, - }) - - if res.status ~= 0 or isnull(res.stdout) then - print_osd_message("Failed to get video info (yt-dlp error)", MSG_DURATION, style.error) - print("yt-dlp status: " .. res.status) - return nil - end - - local data = utils.parse_json(res.stdout) - if isnull(data) then - print_osd_message("Failed to parse JSON from yt-dlp", MSG_DURATION, style.error) - return nil - end - - local category = nil - if data.categories then - category = data.categories[1] - else - category = "Unknown" - end - local info = { - channel_url = data.channel_url or "", - channel_name = data.uploader or "", - video_name = data.title or "", - view_count = data.view_count or "", - upload_date = data.upload_date or "", - category = category or "", - thumbnail_url = data.thumbnail or "", - subscribers = data.channel_follower_count or 0, - } - - if isnull(info.channel_url) or isnull(info.channel_name) or isnull(info.video_name) then - print_osd_message("Missing metadata (channel_url, uploader, video_name) in JSON", MSG_DURATION, style.error) - return nil - end - - -- Cache the result with LRU eviction - cache_video_info(url, info) - return info -end - ---- Prints the currently playing video to the OSD -function YouTubeQueue.print_current_video() - destroy() - local current = current_video - if current and current.video_url ~= "" and is_file(current.video_url) then - print_osd_message("Playing: " .. current.video_url, 3) - else - if current and current.video_url then - print_osd_message("Playing: " .. current.video_name .. " by " .. current.channel_name, 3) - end - end -end - --- }}} - --- QUEUE FUNCTIONS {{{ - ---- Function to set the next or previous video in the queue as the current video ---- direction can be "NEXT" or "PREV". If nil, "next" is assumed ---- @param direction string - the direction to move in the queue ---- @return table | nil - the video at the new index -function YouTubeQueue.set_video(direction) - local amt - direction = string.upper(direction) - if direction == "NEXT" or direction == nil then - amt = 1 - elseif direction == "PREV" or direction == "PREVIOUS" then - amt = -1 - else - print_osd_message("Invalid direction: " .. direction, MSG_DURATION, style.error) - return nil - end - if index + amt > #video_queue or index + amt == 0 then - return nil - end - index = index + amt - selected_index = index - current_video = video_queue[index] - return current_video -end - ---- Function to check if a video is in the queue ---- @param url string - the URL to check ---- @return boolean - true if the video is in the queue, false otherwise -function YouTubeQueue.is_in_queue(url) - for _, v in ipairs(video_queue) do - if v.video_url == url then - return true - end - end - return false -end - ---- Function to find the index of the currently playing video ---- @param update_history boolean - whether to update the history database ---- @return number | nil - the index of the currently playing video -function YouTubeQueue.update_current_index(update_history) - if debug then - print("Updating current index") - end - if #video_queue == 0 then - return - end - if update_history == nil then - update_history = false - end - local current_url = mp.get_property("path") - for i, v in ipairs(video_queue) do - if v.video_url == current_url then - index = i - selected_index = index - ---@class table - current_video = YouTubeQueue.get_video_at(index) - if update_history then - YouTubeQueue.add_to_history_db(current_video) - end - return - end - end - -- if not found, reset the index - index = 0 -end - ---- Function to mark and move a video in the queue ---- If no video is marked, the currently selected video is marked ---- If a video is marked, it is moved to the selected position -function YouTubeQueue.mark_and_move_video() - if marked_index == nil and selected_index ~= index then - -- Mark the currently selected video for moving - marked_index = selected_index - else - -- Move the previously marked video to the selected position - ---@diagnostic disable-next-line: param-type-mismatch - YouTubeQueue.reorder_queue(marked_index, selected_index) - -- print_osd_message("Video moved to the selected position.", 1.5) - marked_index = nil -- Reset the marked index - end - -- Refresh the queue display - YouTubeQueue.print_queue() -end - ---- Function to reorder the queue ---- @param from_index number - the index to move from ---- @param to_index number - the index to move to -function YouTubeQueue.reorder_queue(from_index, to_index) - if from_index == to_index or to_index == index then - print_osd_message("No changes made.", 1.5) - return - end - -- Check if the provided indices are within the bounds of the video_queue - if from_index > 0 and from_index <= #video_queue and to_index > 0 and to_index <= #video_queue then - -- mpv's playlist-move moves entry at index1 to position before index2 (0-indexed) - -- When moving to end of playlist, use playlist count as target - local mpv_from = from_index - 1 - local mpv_to - if from_index < to_index then - -- Moving forward: playlist-move needs the position after target - mpv_to = to_index - else - -- Moving backward: playlist-move needs the target position - mpv_to = to_index - 1 - end - mp.commandv("playlist-move", mpv_from, mpv_to) - - -- Update our queue: remove from old position, insert at new - local temp_video = video_queue[from_index] - table.remove(video_queue, from_index) - table.insert(video_queue, to_index, temp_video) - - -- Update current index if affected by the move - if from_index == index then - -- We moved the currently playing video - index = to_index - elseif from_index < index and to_index >= index then - -- Moved an item from before current to at/after current: current shifts back - index = index - 1 - elseif from_index > index and to_index <= index then - -- Moved an item from after current to at/before current: current shifts forward - index = index + 1 - end - selected_index = to_index - else - print_osd_message("Invalid indices for reordering. No changes made.", MSG_DURATION, style.error) - end -end - ---- Prints the queue to the OSD ---- @param duration number Optional duration to display the queue -function YouTubeQueue.print_queue(duration) - -- Reset and prepare OSD - timeout:kill() - mp.set_osd_ass(0, 0, "") - timeout:resume() - - if #video_queue == 0 then - print_osd_message("No videos in the queue or history.", duration, style.error) - destroyer = destroy - return - end - - local ass = assdraw.ass_new() - local position_indicator = "" - if index > 0 then - position_indicator = " [" .. index .. "/" .. #video_queue .. "]" - else - position_indicator = " [" .. #video_queue .. " videos]" - end - ass:append(style.header .. "MPV-YOUTUBE-QUEUE" .. position_indicator .. "{\\u0\\b0}" .. style.reset .. style.font .. "\n") - - local start_index, end_index = get_display_range(#video_queue, selected_index, options.display_limit) - - for i = start_index, end_index do - local video = video_queue[i] - if not video then - break - end - local prefix = (i == selected_index) and style.cursor .. options.cursor_icon .. "\\h" .. style.reset - or "\\h\\h\\h" - local item_style = get_item_style(i, index, selected_index) - local message = build_osd_row(prefix, item_style, i, video.video_name, video.channel_name) .. style.reset - if i == marked_index then - message = message .. " " .. style.marked .. options.marked_icon .. style.reset - end - ass:append(style.font .. message .. "\n") - end - mp.set_osd_ass(0, 0, ass.text) - if duration then - mp.add_timeout(duration, destroy) - end - destroyer = destroy -end - ---- Function to move the cursor on the OSD by a specified amount. ---- Adjusts the selected index and updates the display offset to ensure ---- the selected item is visible within the display limits ---- @param amt number - the number of steps to move the cursor. Positive values move up, negative values move down. -function YouTubeQueue.move_cursor(amt) - timeout:kill() - timeout:resume() - selected_index = selected_index - amt - if selected_index < 1 then - selected_index = 1 - elseif selected_index > #video_queue then - selected_index = #video_queue - end - if amt == 1 and selected_index > 1 and selected_index < display_offset + 1 then - display_offset = display_offset - math.abs(selected_index - amt) - elseif amt == -1 and selected_index < #video_queue and selected_index > display_offset + options.display_limit then - display_offset = display_offset + math.abs(selected_index - amt) - end - YouTubeQueue.print_queue() -end - ---- play the video at the current index -function YouTubeQueue.play_video_at(idx) - if idx <= 0 or idx > #video_queue then - print_osd_message("Invalid video index", MSG_DURATION, style.error) - return nil - end - index = idx - selected_index = idx - current_video = video_queue[index] - mp.set_property_number("playlist-pos", index - 1) -- zero-based index - YouTubeQueue.print_current_video() - return current_video -end - ---- play the next video in the queue ---- @param direction string - the direction to move in the queue ---- @return table | nil - the video at the new index -function YouTubeQueue.play_video(direction) - direction = string.upper(direction) - local video = YouTubeQueue.set_video(direction) - if video == nil then - print_osd_message("No video available.", MSG_DURATION, style.error) - return - end - current_video = video - selected_index = index - -- if the current video is not the first in the queue, then play the video - -- else, check if the video is playing and if not play the video with replace - if direction == "NEXT" and #video_queue > 1 then - YouTubeQueue.play_video_at(index) - elseif direction == "NEXT" and #video_queue == 1 then - local state = mp.get_property("core-idle") - -- yes if the video is loaded but not currently playing - if state == "yes" then - mp.commandv("loadfile", video.video_url, "replace") - end - elseif direction == "PREV" or direction == "PREVIOUS" then - mp.set_property_number("playlist-pos", index - 1) - end - YouTubeQueue.print_current_video() -end - ---- add the video to the queue from the clipboard or call from script-message ---- updates the internal playlist by default, pass 0 to disable ---- @param url string - the URL to add to the queue ---- @param update_internal_playlist number - whether to update the internal playlist ---- @return table | nil - the video added to the queue -function YouTubeQueue.add_to_queue(url, update_internal_playlist) - if update_internal_playlist == nil then - update_internal_playlist = 0 - end - if isnull(url) then - --- @class string - url = YouTubeQueue.get_clipboard_content() - if url == nil then - return - end - end - if YouTubeQueue.is_in_queue(url) then - print_osd_message("Video already in queue.", MSG_DURATION, style.error) - return - end - - local video, channel_url, video_name - url = strip(url) - if not is_file(url) then - local info = YouTubeQueue.get_video_info(url) - if info == nil then - return nil - end - video_name = info.video_name - video = info - video["video_url"] = url - else - channel_url, video_name = split_path(url) - if isnull(channel_url) or isnull(video_name) then - print_osd_message("Error getting video info.", MSG_DURATION, style.error) - return - end - video = { - video_url = url, - video_name = video_name, - channel_url = channel_url, - channel_name = "Local file", - thumbnail_url = "", - view_count = "", - upload_date = "", - category = "", - subscribers = "", - } - end - - table.insert(video_queue, video) - -- if the queue was empty, start playing the video - -- otherwise, add the video to the playlist - if not current_video then - YouTubeQueue.play_video("NEXT") - elseif update_internal_playlist == 0 then - mp.commandv("loadfile", url, "append-play") - end - print_osd_message("Added " .. video_name .. " to queue.", MSG_DURATION) -end - ---- Downloads the video at the specified index ---- @param idx number - the index of the video to download ---- @return boolean - true if the video was downloaded successfully, false otherwise -function YouTubeQueue.download_video_at(idx) - if idx <= 0 or idx > #video_queue then - return false - end - local v = video_queue[idx] - if is_file(v.video_url) then - print_osd_message("Current video is a local file... doing nothing.", MSG_DURATION, style.error) - return false - end - local o = options - local q = o.download_quality:sub(1, -2) - local dl_dir = expanduser(o.download_directory) - - print_osd_message("Downloading " .. v.video_name .. "...", MSG_DURATION) - -- Run the download command - mp.command_native_async({ - name = "subprocess", - capture_stderr = true, - detach = true, - args = { - "yt-dlp", - "-f", - "bestvideo[height<=" - .. q - .. "][ext=" - .. options.ytdlp_file_format - .. "]+bestaudio/best[height<=" - .. q - .. "]/bestvideo[height<=" - .. q - .. "]+bestaudio/best[height<=" - .. q - .. "]", - "-o", - dl_dir .. "/" .. options.ytdlp_output_template, - "--downloader", - o.downloader, - "--", - v.video_url, - }, - }, function(success, _, err) - if success then - print_osd_message("Finished downloading " .. v.video_name .. ".", MSG_DURATION) - else - print_osd_message("Error downloading " .. v.video_name .. ": " .. err, MSG_DURATION, style.error) - end - end) - return true -end - ---- Removes the video at the selected index from the queue ---- @return boolean - true if the video was removed successfully, false otherwise -function YouTubeQueue.remove_from_queue() - if index == selected_index then - print_osd_message("Cannot remove current video", MSG_DURATION, style.error) - return false - end - local removed_video = video_queue[selected_index] - table.remove(video_queue, selected_index) - mp.commandv("playlist-remove", selected_index - 1) - if removed_video and removed_video.video_name then - print_osd_message("Deleted " .. removed_video.video_name .. " from queue.", MSG_DURATION) - end - if selected_index > 1 then - selected_index = selected_index - 1 - end - index = index - 1 - YouTubeQueue.print_queue() - return true -end - ---- Returns a list of URLs in the queue from start_index to the end ---- @param start_index number - the index to start from ---- @return table | nil - a table of URLs -function YouTubeQueue.get_urls(start_index) - if start_index < 0 or start_index > #video_queue then - return nil - end - local urls = {} - for i = start_index + 1, #video_queue do - table.insert(urls, video_queue[i].video_url) - end - return urls -end --- }}} - --- {{{ HISTORY DB - ---- Add a video to the history database ---- @param v table - the video to add to the history database ---- @return boolean - true if the video was added successfully, false otherwise -function YouTubeQueue.add_to_history_db(v) - if not options.use_history_db then - return false - end - local url = options.backend_host .. ":" .. options.backend_port .. "/add_video" - local json = convert_to_json(v) - local command = { "curl", "-X", "POST", url, "-H", "Content-Type: application/json", "-d", json } - if debug then - print("Adding video to history") - print("Command: " .. table.concat(command, " ")) - end - print_osd_message("Adding video to history...", MSG_DURATION) - mp.command_native_async({ - name = "subprocess", - playback_only = false, - capture_stdout = true, - args = command, - }, function(success, _, err) - if not success then - print_osd_message("Failed to send video data to backend: " .. err, MSG_DURATION, style.error) - return false - end - end) - print_osd_message("Video added to history db", MSG_DURATION) - return true -end - ---- Saves the remainder of the videos in the queue ---- (all videos after the currently playing video) to the history database ---- @param idx number - the index to start saving from ---- @return boolean - true if the queue was saved successfully, false otherwise -function YouTubeQueue.save_queue(idx) - if not options.use_history_db then - return false - end - if idx == nil then - idx = index - end - local url = options.backend_host .. ":" .. options.backend_port .. "/save_queue" - local data = convert_to_json("urls", YouTubeQueue.get_urls(idx + 1)) - if data == nil or data == '{"urls": []}' then - print_osd_message("Failed to save queue: No videos remaining in queue", MSG_DURATION, style.error) - return false - end - if debug then - print("Data: " .. data) - end - local command = { "curl", "-X", "POST", url, "-H", "Content-Type: application/json", "-d", data } - if debug then - print("Saving queue to history") - print("Command: " .. table.concat(command, " ")) - end - mp.command_native_async({ - name = "subprocess", - playback_only = false, - capture_stdout = true, - args = command, - }, function(success, result, err) - if not success then - print_osd_message("Failed to save queue: " .. err, MSG_DURATION, style.error) - return false - end - if debug then - print("Status: " .. result.status) - end - if result.status == 0 then - if idx > 1 then - print_osd_message("Queue saved to history from index: " .. idx, MSG_DURATION) - else - print_osd_message("Queue saved to history.", MSG_DURATION) - end - end - end) - return true -end - --- loads the queue from the backend -function YouTubeQueue.load_queue() - if not options.use_history_db then - return false - end - local url = options.backend_host .. ":" .. options.backend_port .. "/load_queue" - local command = { "curl", "-X", "GET", url } - - mp.command_native_async({ - name = "subprocess", - playback_only = false, - capture_stdout = true, - args = command, - }, function(success, result, err) - if not success then - print_osd_message("Failed to load queue: " .. err, MSG_DURATION, style.error) - return false - else - if result.status == 0 then - -- split urls based on commas - local urls = {} - -- Remove the brackets from json list - local l = result.stdout:sub(2, -3) - local item - for turl in l:gmatch("[^,]+") do - local trimmed = turl:match("^%s*(.-)%s*$") - if trimmed then - item = trimmed:gsub('"', "'") - table.insert(urls, item) - end - end - for _, turl in ipairs(urls) do - YouTubeQueue.add_to_queue(turl, 0) - end - print_osd_message("Loaded queue from history.", MSG_DURATION) - end - end - end) -end - ---- Function to sync the video queue with mpv's internal playlist ---- @return boolean - true if sync was successful, false otherwise -function YouTubeQueue.sync_with_playlist() - if debug then - print("Syncing with internal playlist") - end - - -- Get the current playlist count - local count = mp.get_property_number("playlist-count") - if count == 0 then - return false - end - - -- Clear our queue - video_queue = {} - - -- Add each item from the playlist to our queue - for i = 0, count - 1 do - local url = mp.get_property(string.format("playlist/%d/filename", i)) - if url then - if not is_file(url) then - local info = YouTubeQueue.get_video_info(url) - if info then - info["video_url"] = url - table.insert(video_queue, info) - end - else - local channel_url, video_name = split_path(url) - if not isnull(channel_url) and not isnull(video_name) then - table.insert(video_queue, { - video_url = url, - video_name = video_name, - channel_url = channel_url, - channel_name = "Local file", - thumbnail_url = "", - view_count = "", - upload_date = "", - category = "", - subscribers = "", - }) - end - end - end - end - - -- Update current index - YouTubeQueue.update_current_index(false) - return true -end --- }}} - --- LISTENERS {{{ --- Function to be called when the end-file event is triggered --- This function is called when the current file ends or when moving to the --- next or previous item in the internal playlist -local function on_end_file(event) - if debug then - print("End file event triggered: " .. event.reason) - end - if event.reason == "eof" then -- The file ended normally - YouTubeQueue.update_current_index(true) - end -end - --- Function to be called when the track-changed event is triggered -local function on_track_changed() - if debug then - print("Track changed event triggered.") - end - YouTubeQueue.update_current_index() -end - -local function on_file_loaded() - if debug then - print("Load file event triggered.") - end - YouTubeQueue.update_current_index(true) -end - --- Function to be called when the playback-restart event is triggered -local function on_playback_restart() - if debug then - print("Playback restart event triggered.") - end - if current_video == nil then - -- Instead of just adding the current file, sync with the entire playlist - YouTubeQueue.sync_with_playlist() - end -end - --- }}} - --- KEY BINDINGS {{{ -mp.add_key_binding(options.add_to_queue, "add_to_queue", YouTubeQueue.add_to_queue) -mp.add_key_binding(options.play_next_in_queue, "play_next_in_queue", function() - YouTubeQueue.play_video("NEXT") -end) -mp.add_key_binding(options.play_previous_in_queue, "play_prev_in_queue", function() - YouTubeQueue.play_video("PREV") -end) -mp.add_key_binding(options.print_queue, "print_queue", toggle_print) -mp.add_key_binding(options.move_cursor_up, "move_cursor_up", function() - YouTubeQueue.move_cursor(1) -end, { - repeatable = true, -}) -mp.add_key_binding(options.move_cursor_down, "move_cursor_down", function() - YouTubeQueue.move_cursor(-1) -end, { - repeatable = true, -}) -mp.add_key_binding(options.play_selected_video, "play_selected_video", function() - YouTubeQueue.play_video_at(selected_index) -end) -mp.add_key_binding(options.open_video_in_browser, "open_video_in_browser", open_video_in_browser) -mp.add_key_binding(options.print_current_video, "print_current_video", YouTubeQueue.print_current_video) -mp.add_key_binding(options.open_channel_in_browser, "open_channel_in_browser", open_channel_in_browser) -mp.add_key_binding(options.download_current_video, "download_current_video", function() - YouTubeQueue.download_video_at(index) -end) -mp.add_key_binding(options.download_selected_video, "download_selected_video", function() - YouTubeQueue.download_video_at(selected_index) -end) -mp.add_key_binding(options.move_video, "move_video", YouTubeQueue.mark_and_move_video) -mp.add_key_binding(options.remove_from_queue, "delete_video", YouTubeQueue.remove_from_queue) -mp.add_key_binding(options.save_queue, "save_queue", function() - if options.default_save_method == "unwatched" then - YouTubeQueue.save_queue(index) - else - YouTubeQueue.save_queue(0) - end -end) -mp.add_key_binding(options.save_queue_alt, "save_queue_alt", function() - if options.default_save_method == "unwatched" then - YouTubeQueue.save_queue(0) - else - YouTubeQueue.save_queue(index) - end -end) -mp.add_key_binding(options.load_queue, "load_queue", YouTubeQueue.load_queue) - -mp.register_event("end-file", on_end_file) -mp.register_event("track-changed", on_track_changed) -mp.register_event("playback-restart", on_playback_restart) -mp.register_event("file-loaded", on_file_loaded) -mp.add_hook("on_load", 50, function() - if debug then - print("Startup hook triggered") - end - YouTubeQueue.sync_with_playlist() -end) - - --- keep for backwards compatibility -mp.register_script_message("add_to_queue", YouTubeQueue.add_to_queue) -mp.register_script_message("print_queue", YouTubeQueue.print_queue) - -mp.register_script_message("add_to_youtube_queue", YouTubeQueue.add_to_queue) -mp.register_script_message("toggle_youtube_queue", toggle_print) -mp.register_script_message("print_internal_playlist", print_internal_playlist) -mp.register_script_message("reorder_youtube_queue", YouTubeQueue.reorder_queue) --- }}} \ No newline at end of file diff --git a/mpv-youtube-queue/app.lua b/mpv-youtube-queue/app.lua new file mode 100644 index 0000000..5b8200f --- /dev/null +++ b/mpv-youtube-queue/app.lua @@ -0,0 +1,467 @@ +local history_client = require("history_client") +local input = require("input") +local shell = require("shell") +local state = require("state") +local ui = require("ui") +local video_store = require("video_store") + +local App = {} + +local MSG_DURATION = 1.5 +local options = { + add_to_queue = "ctrl+a", + download_current_video = "ctrl+d", + download_selected_video = "ctrl+D", + move_cursor_down = "ctrl+j", + move_cursor_up = "ctrl+k", + move_video = "ctrl+m", + play_next_in_queue = "ctrl+n", + open_video_in_browser = "ctrl+o", + open_channel_in_browser = "ctrl+O", + play_previous_in_queue = "ctrl+p", + print_current_video = "ctrl+P", + print_queue = "ctrl+q", + remove_from_queue = "ctrl+x", + play_selected_video = "ctrl+ENTER", + browser = "firefox", + clipboard_command = "xclip -o", + cursor_icon = "➤", + display_limit = 10, + download_directory = "~/videos/YouTube", + download_quality = "720p", + downloader = "curl", + font_name = "JetBrains Mono", + font_size = 12, + marked_icon = "⇅", + menu_timeout = 5, + show_errors = true, + ytdlp_file_format = "mp4", + ytdlp_output_template = "%(uploader)s/%(title)s.%(ext)s", + use_history_db = false, + backend_host = "http://localhost", + backend_port = "42069", + max_title_length = 60, +} + +local function normalize_direction(direction) + return direction and string.upper(direction) or "NEXT" +end + +function App.new() + local mp = require("mp") + local utils = require("mp.utils") + mp.options = require("mp.options") + mp.options.read_options(options, "mpv-youtube-queue") + + local style_on = mp.get_property("osd-ass-cc/0") or "" + local style_off = mp.get_property("osd-ass-cc/1") or "" + local renderer = ui.create(options) + + local app = { + video_queue = {}, + index = 0, + selected_index = 1, + marked_index = nil, + current_video = nil, + destroyer = nil, + timeout = nil, + } + + local function sync_current_video() + app.current_video = app.index > 0 and app.video_queue[app.index] or nil + end + + local function destroy() + if app.timeout then + app.timeout:kill() + end + mp.set_osd_ass(0, 0, "") + app.destroyer = nil + end + + local function print_osd_message(message, duration, is_error) + if is_error and not options.show_errors then + return + end + destroy() + local formatted = style_on + .. renderer:message_style(is_error) + .. message + .. renderer.styles.reset + .. style_off + .. "\n" + mp.osd_message(formatted, duration or MSG_DURATION) + end + local videos = video_store.new({ + mp = mp, + utils = utils, + options = options, + notify = print_osd_message, + }) + local history_api = history_client.new({ + mp = mp, + options = options, + notify = print_osd_message, + }) + local runner = shell.new({ + mp = mp, + options = options, + notify = print_osd_message, + is_file = function(path) + return videos:is_file(path) + end, + }) + + local function update_current_index() + if #app.video_queue == 0 then + app.index = 0 + app.selected_index = 1 + app.current_video = nil + return + end + + local current_url = mp.get_property("path") + for i, video in ipairs(app.video_queue) do + if video.video_url == current_url then + app.index = i + app.selected_index = i + app.current_video = video + return + end + end + + app.index = 0 + app.current_video = nil + end + + local function print_queue(duration) + if app.timeout then + app.timeout:kill() + app.timeout:resume() + end + mp.set_osd_ass(0, 0, "") + + if #app.video_queue == 0 then + print_osd_message("No videos in the queue.", duration, true) + app.destroyer = destroy + return + end + + mp.set_osd_ass(0, 0, renderer:render_queue(app.video_queue, app.index, app.selected_index, app.marked_index)) + if duration then + mp.add_timeout(duration, destroy) + end + app.destroyer = destroy + end + + local function sync_with_playlist() + app.video_queue = videos:sync_playlist() + update_current_index() + if #app.video_queue > 0 and app.selected_index > #app.video_queue then + app.selected_index = #app.video_queue + end + return #app.video_queue > 0 + end + + function app.get_video_at(idx) + if idx <= 0 or idx > #app.video_queue then + print_osd_message("Invalid video index", MSG_DURATION, true) + return nil + end + return app.video_queue[idx] + end + + function app.print_current_video() + destroy() + local current = app.current_video + if not current then + return + end + if current.video_url ~= "" and videos:is_file(current.video_url) then + print_osd_message("Playing: " .. current.video_url, 3, false) + return + end + print_osd_message("Playing: " .. current.video_name .. " by " .. current.channel_name, 3, false) + end + + function app.set_video(direction) + local normalized = normalize_direction(direction) + if normalized ~= "NEXT" and normalized ~= "PREV" and normalized ~= "PREVIOUS" then + print_osd_message("Invalid direction: " .. tostring(direction), MSG_DURATION, true) + return nil + end + local delta = 1 + if normalized == "PREV" or normalized == "PREVIOUS" then + delta = -1 + end + if app.index + delta > #app.video_queue or app.index + delta < 1 then + return nil + end + app.index = app.index + delta + app.selected_index = app.index + sync_current_video() + return app.current_video + end + + function app.is_in_queue(url) + for _, video in ipairs(app.video_queue) do + if video.video_url == url then + return true + end + end + return false + end + + function app.mark_and_move_video() + if app.marked_index == nil and app.selected_index ~= app.index then + app.marked_index = app.selected_index + elseif app.marked_index ~= nil then + app.reorder_queue(app.marked_index, app.selected_index) + app.marked_index = nil + end + print_queue() + end + + function app.reorder_queue(from_index, to_index) + local ok, result = pcall(state.reorder_queue, { + queue = app.video_queue, + current_index = app.index, + selected_index = app.selected_index, + marked_index = app.marked_index, + from_index = from_index, + to_index = to_index, + }) + if not ok then + print_osd_message("Invalid indices for reordering. No changes made.", MSG_DURATION, true) + return false + end + + mp.commandv("playlist-move", result.mpv_from, result.mpv_to) + app.video_queue = result.queue + app.index = result.current_index + app.selected_index = result.selected_index + app.marked_index = result.marked_index + sync_current_video() + return true + end + + function app.print_queue(duration) + print_queue(duration) + end + + function app.move_cursor(amount) + if app.timeout then + app.timeout:kill() + app.timeout:resume() + end + app.selected_index = app.selected_index - amount + if #app.video_queue == 0 then + app.selected_index = 1 + else + app.selected_index = math.max(1, math.min(app.selected_index, #app.video_queue)) + end + print_queue() + end + + function app.play_video_at(idx) + if idx <= 0 or idx > #app.video_queue then + print_osd_message("Invalid video index", MSG_DURATION, true) + return nil + end + app.index = idx + app.selected_index = idx + sync_current_video() + mp.set_property_number("playlist-pos", idx - 1) + app.print_current_video() + return app.current_video + end + + function app.play_video(direction) + local video = app.set_video(direction) + if not video then + print_osd_message("No video available.", MSG_DURATION, true) + return + end + if mp.get_property_number("playlist-count", 0) == 0 then + mp.commandv("loadfile", video.video_url, "replace") + else + mp.set_property_number("playlist-pos", app.index - 1) + end + app.print_current_video() + end + + function app.add_to_queue(url, update_internal_playlist) + local source = videos:normalize_source(input.sanitize_source(url)) + if not source or source == "" then + source = videos:get_clipboard_content() + if not source then + return nil + end + end + if app.is_in_queue(source) then + print_osd_message("Video already in queue.", MSG_DURATION, true) + return nil + end + + local video = videos:resolve_video(source) + if not video then + print_osd_message("Error getting video info.", MSG_DURATION, true) + return nil + end + + table.insert(app.video_queue, video) + if not app.current_video then + app.index = #app.video_queue + app.selected_index = app.index + app.current_video = video + mp.commandv("loadfile", source, "replace") + elseif update_internal_playlist == nil or update_internal_playlist == 0 then + mp.commandv("loadfile", source, "append-play") + end + print_osd_message("Added " .. video.video_name .. " to queue.", MSG_DURATION, false) + return video + end + + function app.download_video_at(idx) + if idx <= 0 or idx > #app.video_queue then + return false + end + local video = app.video_queue[idx] + return runner:download_video(video) + end + + function app.remove_from_queue() + if app.index == app.selected_index then + print_osd_message("Cannot remove current video", MSG_DURATION, true) + return false + end + + local removed_index = app.selected_index + local removed_video = app.video_queue[app.selected_index] + local result = state.remove_queue_item({ + queue = app.video_queue, + current_index = app.index, + selected_index = app.selected_index, + marked_index = app.marked_index, + }) + app.video_queue = result.queue + app.index = result.current_index + app.selected_index = result.selected_index + app.marked_index = result.marked_index + mp.commandv("playlist-remove", removed_index - 1) + sync_current_video() + if removed_video then + print_osd_message("Deleted " .. removed_video.video_name .. " from queue.", MSG_DURATION, false) + end + print_queue() + return true + end + + function app.sync_with_playlist() + return sync_with_playlist() + end + + local function toggle_print() + if app.destroyer then + app.destroyer() + return + end + print_queue() + end + + local function open_video_in_browser() + if app.current_video then + runner:open_in_browser(app.current_video.video_url) + end + end + + local function open_channel_in_browser() + if app.current_video and app.current_video.channel_url ~= "" then + runner:open_in_browser(app.current_video.channel_url) + end + end + + local function on_end_file(event) + if event.reason == "eof" and app.current_video then + history_api:add_video(app.current_video) + end + end + + local function on_track_changed() + update_current_index() + end + + local function on_file_loaded() + sync_with_playlist() + update_current_index() + end + + app.timeout = mp.add_periodic_timer(options.menu_timeout, destroy) + + mp.add_key_binding(options.add_to_queue, "add_to_queue", app.add_to_queue) + mp.add_key_binding(options.play_next_in_queue, "play_next_in_queue", function() + app.play_video("NEXT") + end) + mp.add_key_binding(options.play_previous_in_queue, "play_prev_in_queue", function() + app.play_video("PREV") + end) + mp.add_key_binding(options.print_queue, "print_queue", toggle_print) + mp.add_key_binding(options.move_cursor_up, "move_cursor_up", function() + app.move_cursor(1) + end, { repeatable = true }) + mp.add_key_binding(options.move_cursor_down, "move_cursor_down", function() + app.move_cursor(-1) + end, { repeatable = true }) + mp.add_key_binding(options.play_selected_video, "play_selected_video", function() + app.play_video_at(app.selected_index) + end) + mp.add_key_binding(options.open_video_in_browser, "open_video_in_browser", open_video_in_browser) + mp.add_key_binding(options.print_current_video, "print_current_video", app.print_current_video) + mp.add_key_binding(options.open_channel_in_browser, "open_channel_in_browser", open_channel_in_browser) + mp.add_key_binding(options.download_current_video, "download_current_video", function() + app.download_video_at(app.index) + end) + mp.add_key_binding(options.download_selected_video, "download_selected_video", function() + app.download_video_at(app.selected_index) + end) + mp.add_key_binding(options.move_video, "move_video", app.mark_and_move_video) + mp.add_key_binding(options.remove_from_queue, "delete_video", app.remove_from_queue) + + mp.register_event("end-file", on_end_file) + mp.register_event("track-changed", on_track_changed) + mp.register_event("file-loaded", on_file_loaded) + + mp.register_script_message("add_to_queue", app.add_to_queue) + mp.register_script_message("print_queue", app.print_queue) + mp.register_script_message("add_to_youtube_queue", app.add_to_queue) + mp.register_script_message("toggle_youtube_queue", toggle_print) + mp.register_script_message("print_internal_playlist", function() + local count = mp.get_property_number("playlist-count", 0) + print("Playlist contents:") + for i = 0, count - 1 do + print(string.format("%d: %s", i, mp.get_property(string.format("playlist/%d/filename", i)))) + end + end) + mp.register_script_message("reorder_youtube_queue", function(from_index, to_index) + app.reorder_queue(from_index, to_index) + end) + + app.YouTubeQueue = app + app._test = { + snapshot_queue = function() + local snapshot = {} + for i, item in ipairs(app.video_queue) do + local copied = {} + for key, value in pairs(item) do + copied[key] = value + end + snapshot[i] = copied + end + return snapshot + end, + } + + return app +end + +return App diff --git a/mpv-youtube-queue/history_client.lua b/mpv-youtube-queue/history_client.lua new file mode 100644 index 0000000..b36d4c1 --- /dev/null +++ b/mpv-youtube-queue/history_client.lua @@ -0,0 +1,44 @@ +local json = require("json") + +local history_client = {} + +function history_client.new(config) + local client = { + mp = config.mp, + options = config.options, + notify = config.notify, + } + + function client:add_video(video) + if not self.options.use_history_db or not video then + return false + end + + self.mp.command_native_async({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = { + "curl", + "-X", + "POST", + self.options.backend_host .. ":" .. self.options.backend_port .. "/add_video", + "-H", + "Content-Type: application/json", + "-d", + json.encode(video), + }, + }, function(success, result, err) + if not success or not result or result.status ~= 0 then + self.notify("Failed to send video data to backend: " .. (err or "request failed"), nil, true) + return + end + self.notify("Video added to history db", nil, false) + end) + return true + end + + return client +end + +return history_client diff --git a/mpv-youtube-queue/input.lua b/mpv-youtube-queue/input.lua new file mode 100644 index 0000000..8434bba --- /dev/null +++ b/mpv-youtube-queue/input.lua @@ -0,0 +1,58 @@ +local input = {} + +function input.is_file_info(result) + return type(result) == "table" and result.is_file == true +end + +function input.sanitize_source(value) + if value == nil then + return nil + end + + local sanitized = value:match("^%s*(.-)%s*$") + if sanitized == nil then + return nil + end + if #sanitized >= 2 then + local first_char = sanitized:sub(1, 1) + local last_char = sanitized:sub(-1) + if (first_char == '"' and last_char == '"') or (first_char == "'" and last_char == "'") then + sanitized = sanitized:sub(2, -2) + end + end + return sanitized +end + +function input.split_command(command) + local parts = {} + local current = {} + local quote = nil + + for i = 1, #command do + local char = command:sub(i, i) + if quote then + if char == quote then + quote = nil + else + table.insert(current, char) + end + elseif char == '"' or char == "'" then + quote = char + elseif char:match("%s") then + if #current > 0 then + table.insert(parts, table.concat(current)) + current = {} + end + else + table.insert(current, char) + end + end + + if #current > 0 then + table.insert(parts, table.concat(current)) + end + + return parts +end + +return input diff --git a/mpv-youtube-queue/json.lua b/mpv-youtube-queue/json.lua new file mode 100644 index 0000000..c66fe31 --- /dev/null +++ b/mpv-youtube-queue/json.lua @@ -0,0 +1,267 @@ +local json = {} + +local function escape_string(value) + local escaped = value + escaped = escaped:gsub("\\", "\\\\") + escaped = escaped:gsub('"', '\\"') + escaped = escaped:gsub("\b", "\\b") + escaped = escaped:gsub("\f", "\\f") + escaped = escaped:gsub("\n", "\\n") + escaped = escaped:gsub("\r", "\\r") + escaped = escaped:gsub("\t", "\\t") + return escaped +end + +local function is_array(value) + if type(value) ~= "table" then + return false + end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return false + end + count = count + 1 + end + return count == #value +end + +local function encode_value(value) + local value_type = type(value) + if value_type == "string" then + return '"' .. escape_string(value) .. '"' + end + if value_type == "number" or value_type == "boolean" then + return tostring(value) + end + if value == nil then + return "null" + end + if value_type ~= "table" then + error("unsupported json type: " .. value_type) + end + + if is_array(value) then + local encoded = {} + for _, item in ipairs(value) do + table.insert(encoded, encode_value(item)) + end + return "[" .. table.concat(encoded, ",") .. "]" + end + + local keys = {} + for key in pairs(value) do + table.insert(keys, key) + end + table.sort(keys) + + local encoded = {} + for _, key in ipairs(keys) do + table.insert(encoded, '"' .. escape_string(key) .. '":' .. encode_value(value[key])) + end + return "{" .. table.concat(encoded, ",") .. "}" +end + +function json.encode(value) + return encode_value(value) +end + +local function new_parser(input) + local parser = { + input = input, + index = 1, + length = #input, + } + + function parser:peek() + return self.input:sub(self.index, self.index) + end + + function parser:consume() + local char = self:peek() + self.index = self.index + 1 + return char + end + + function parser:skip_whitespace() + while self.index <= self.length do + local char = self:peek() + if not char:match("%s") then + return + end + self.index = self.index + 1 + end + end + + function parser:error(message) + error(string.format("json parse error at %d: %s", self.index, message)) + end + + return parser +end + +local parse_value + +local function parse_string(parser) + if parser:consume() ~= '"' then + parser:error("expected '\"'") + end + + local result = {} + while parser.index <= parser.length do + local char = parser:consume() + if char == '"' then + return table.concat(result) + end + if char ~= "\\" then + table.insert(result, char) + goto continue + end + + local escape = parser:consume() + local replacements = { + ['"'] = '"', + ["\\"] = "\\", + ["/"] = "/", + ["b"] = "\b", + ["f"] = "\f", + ["n"] = "\n", + ["r"] = "\r", + ["t"] = "\t", + } + if escape == "u" then + local codepoint = parser.input:sub(parser.index, parser.index + 3) + if #codepoint < 4 or not codepoint:match("^[0-9a-fA-F]+$") then + parser:error("invalid unicode escape") + end + parser.index = parser.index + 4 + table.insert(result, utf8.char(tonumber(codepoint, 16))) + elseif replacements[escape] then + table.insert(result, replacements[escape]) + else + parser:error("invalid escape sequence") + end + + ::continue:: + end + + parser:error("unterminated string") +end + +local function parse_number(parser) + local start_index = parser.index + while parser.index <= parser.length do + local char = parser:peek() + if not char:match("[%d%+%-%.eE]") then + break + end + parser.index = parser.index + 1 + end + local value = tonumber(parser.input:sub(start_index, parser.index - 1)) + if value == nil then + parser:error("invalid number") + end + return value +end + +local function parse_literal(parser, literal, value) + if parser.input:sub(parser.index, parser.index + #literal - 1) ~= literal then + parser:error("expected " .. literal) + end + parser.index = parser.index + #literal + return value +end + +local function parse_array(parser) + parser:consume() + parser:skip_whitespace() + local result = {} + if parser:peek() == "]" then + parser:consume() + return result + end + + while true do + table.insert(result, parse_value(parser)) + parser:skip_whitespace() + local char = parser:consume() + if char == "]" then + return result + end + if char ~= "," then + parser:error("expected ',' or ']'") + end + parser:skip_whitespace() + end +end + +local function parse_object(parser) + parser:consume() + parser:skip_whitespace() + local result = {} + if parser:peek() == "}" then + parser:consume() + return result + end + + while true do + if parser:peek() ~= '"' then + parser:error("expected string key") + end + local key = parse_string(parser) + parser:skip_whitespace() + if parser:consume() ~= ":" then + parser:error("expected ':'") + end + parser:skip_whitespace() + result[key] = parse_value(parser) + parser:skip_whitespace() + local char = parser:consume() + if char == "}" then + return result + end + if char ~= "," then + parser:error("expected ',' or '}'") + end + parser:skip_whitespace() + end +end + +parse_value = function(parser) + parser:skip_whitespace() + local char = parser:peek() + if char == '"' then + return parse_string(parser) + end + if char == "[" then + return parse_array(parser) + end + if char == "{" then + return parse_object(parser) + end + if char == "t" then + return parse_literal(parser, "true", true) + end + if char == "f" then + return parse_literal(parser, "false", false) + end + if char == "n" then + return parse_literal(parser, "null", nil) + end + if char:match("[%d%-]") then + return parse_number(parser) + end + parser:error("unexpected token") +end + +function json.decode(input) + local parser = new_parser(input) + local value = parse_value(parser) + parser:skip_whitespace() + if parser.index <= parser.length then + parser:error("trailing characters") + end + return value +end + +return json diff --git a/mpv-youtube-queue/main.lua b/mpv-youtube-queue/main.lua new file mode 100644 index 0000000..9375336 --- /dev/null +++ b/mpv-youtube-queue/main.lua @@ -0,0 +1,3 @@ +local app = require("app").new() + +return app diff --git a/mpv-youtube-queue/shell.lua b/mpv-youtube-queue/shell.lua new file mode 100644 index 0000000..5a34636 --- /dev/null +++ b/mpv-youtube-queue/shell.lua @@ -0,0 +1,90 @@ +local input = require("input") + +local shell = {} + +local function expanduser(path) + if path:sub(-1) == "/" then + path = path:sub(1, -2) + end + if path:sub(1, 1) == "~" then + local home = os.getenv("HOME") + if home then + return home .. path:sub(2) + end + end + return path +end + +function shell.new(config) + local runner = { + mp = config.mp, + options = config.options, + notify = config.notify, + is_file = config.is_file, + } + + function runner:open_in_browser(target) + if not target or target == "" then + return + end + local browser_args = input.split_command(self.options.browser) + if #browser_args == 0 then + self.notify("Invalid browser command", nil, true) + return + end + table.insert(browser_args, target) + self.mp.command_native({ + name = "subprocess", + playback_only = false, + detach = true, + args = browser_args, + }) + end + + function runner:download_video(video) + if self:is_file(video.video_url) then + self.notify("Current video is a local file... doing nothing.", nil, true) + return false + end + + local quality = self.options.download_quality:sub(1, -2) + self.notify("Downloading " .. video.video_name .. "...", nil, false) + self.mp.command_native_async({ + name = "subprocess", + capture_stderr = true, + detach = true, + args = { + "yt-dlp", + "-f", + "bestvideo[height<=" + .. quality + .. "][ext=" + .. self.options.ytdlp_file_format + .. "]+bestaudio/best[height<=" + .. quality + .. "]/bestvideo[height<=" + .. quality + .. "]+bestaudio/best[height<=" + .. quality + .. "]", + "-o", + expanduser(self.options.download_directory) .. "/" .. self.options.ytdlp_output_template, + "--downloader", + self.options.downloader, + "--", + video.video_url, + }, + }, function(success, _, err) + if success then + self.notify("Finished downloading " .. video.video_name .. ".", nil, false) + return + end + self.notify("Error downloading " .. video.video_name .. ": " .. (err or "request failed"), nil, true) + end) + return true + end + + return runner +end + +return shell diff --git a/mpv-youtube-queue/state.lua b/mpv-youtube-queue/state.lua new file mode 100644 index 0000000..a9d95ca --- /dev/null +++ b/mpv-youtube-queue/state.lua @@ -0,0 +1,140 @@ +local state = {} + +local function clamp(value, minimum, maximum) + if value < minimum then + return minimum + end + if value > maximum then + return maximum + end + return value +end + +local function copy_queue(queue) + local copied = {} + for i, item in ipairs(queue) do + copied[i] = item + end + return copied +end + +local function move_index(index_value, from_index, to_index) + if index_value == nil then + return nil + end + if index_value == from_index then + return to_index + end + if from_index < index_value and to_index >= index_value then + return index_value - 1 + end + if from_index > index_value and to_index <= index_value then + return index_value + 1 + end + return index_value +end + +function state.normalize_reorder_indices(from_index, to_index) + local normalized_from = tonumber(from_index) + local normalized_to = tonumber(to_index) + if normalized_from == nil or normalized_to == nil then + error("invalid reorder indices") + end + return normalized_from, normalized_to +end + +function state.get_display_range(queue_length, selected_index, limit) + if queue_length <= 0 or limit <= 0 then + return 1, 0 + end + + local normalized_selected = clamp(selected_index, 1, queue_length) + if queue_length <= limit then + return 1, queue_length + end + + local half_limit = math.floor(limit / 2) + local start_index = normalized_selected - half_limit + start_index = clamp(start_index, 1, queue_length - limit + 1) + local end_index = math.min(queue_length, start_index + limit - 1) + return start_index, end_index +end + +function state.remove_queue_item(args) + local queue = copy_queue(args.queue) + local selected_index = args.selected_index + if selected_index < 1 or selected_index > #queue then + error("invalid selected index") + end + + table.remove(queue, selected_index) + + local current_index = args.current_index or 0 + if current_index > 0 and selected_index < current_index then + current_index = current_index - 1 + elseif #queue == 0 then + current_index = 0 + end + + local marked_index = args.marked_index + if marked_index == selected_index then + marked_index = nil + elseif marked_index ~= nil and marked_index > selected_index then + marked_index = marked_index - 1 + end + + local next_selected = selected_index + if #queue == 0 then + next_selected = 1 + else + next_selected = clamp(next_selected, 1, #queue) + end + + return { + queue = queue, + current_index = current_index, + selected_index = next_selected, + marked_index = marked_index, + } +end + +function state.reorder_queue(args) + local from_index, to_index = state.normalize_reorder_indices(args.from_index, args.to_index) + local queue = copy_queue(args.queue) + if from_index < 1 or from_index > #queue or to_index < 1 or to_index > #queue then + error("invalid reorder indices") + end + if from_index == to_index then + return { + queue = queue, + current_index = args.current_index or 0, + selected_index = args.selected_index or to_index, + marked_index = args.marked_index, + mpv_from = from_index - 1, + mpv_to = to_index - 1, + } + end + + local moved_item = queue[from_index] + table.remove(queue, from_index) + table.insert(queue, to_index, moved_item) + + local current_index = move_index(args.current_index or 0, from_index, to_index) + local marked_index = move_index(args.marked_index, from_index, to_index) + + local mpv_to = to_index - 1 + if from_index < to_index then + mpv_to = to_index + end + + return { + queue = queue, + current_index = current_index, + selected_index = to_index, + marked_index = marked_index, + mpv_from = from_index - 1, + mpv_to = mpv_to, + } +end + +return state diff --git a/mpv-youtube-queue/ui.lua b/mpv-youtube-queue/ui.lua new file mode 100644 index 0000000..1170856 --- /dev/null +++ b/mpv-youtube-queue/ui.lua @@ -0,0 +1,97 @@ +local state = require("state") + +local ui = {} + +local colors = { + error = "9687ED", + selected = "F5BDE6", + hover_selected = "C6C6F0", + cursor = "9FD4EE", + header = "CAD58B", + hover = "F8BDB7", + text = "E0C0B8", + marked = "F6A0C6", +} + +local function truncate_string(value, max_length) + if not value or max_length <= 0 then + return value or "" + end + if #value <= max_length then + return value + end + if max_length <= 3 then + return value:sub(1, max_length) + end + return value:sub(1, max_length - 3) .. "..." +end + +local function format_tag(font_name, font_size, color, alpha, extra) + return string.format("{\\fn%s\\fs%d\\c&H%s&\\alpha&H%s&%s}", font_name, font_size, color, alpha, extra or "") +end + +function ui.create(options) + local assdraw = require("mp.assdraw") + local styles = { + error = format_tag(options.font_name, options.font_size, colors.error, "00"), + text = format_tag(options.font_name, options.font_size, colors.text, "59"), + selected = format_tag(options.font_name, options.font_size, colors.selected, "40"), + hover_selected = format_tag(options.font_name, options.font_size, colors.hover_selected, "33"), + hover = format_tag(options.font_name, options.font_size, colors.hover, "40"), + cursor = format_tag(options.font_name, options.font_size, colors.cursor, "00"), + marked = format_tag(options.font_name, options.font_size, colors.marked, "00"), + header = format_tag(options.font_name, math.floor(options.font_size * 1.5), colors.header, "00", "\\u1\\b1"), + reset = "{\\r}", + } + + local renderer = { styles = styles } + + function renderer:message_style(is_error) + if is_error then + return self.styles.error + end + return self.styles.text + end + + function renderer:render_queue(queue, current_index, selected_index, marked_index) + local ass = assdraw.ass_new() + local position_indicator = current_index > 0 and string.format(" [%d/%d]", current_index, #queue) + or string.format(" [%d videos]", #queue) + + ass:append( + self.styles.header .. "MPV-YOUTUBE-QUEUE" .. position_indicator .. "{\\u0\\b0}" .. self.styles.reset .. "\n" + ) + + local start_index, end_index = state.get_display_range(#queue, selected_index, options.display_limit) + for i = start_index, end_index do + local item = queue[i] + local prefix = "\\h\\h\\h" + if i == selected_index then + prefix = self.styles.cursor .. options.cursor_icon .. "\\h" .. self.styles.reset + end + + local item_style = self.styles.text + if i == current_index and i == selected_index then + item_style = self.styles.hover_selected + elseif i == current_index then + item_style = self.styles.selected + elseif i == selected_index then + item_style = self.styles.hover + end + + local title = truncate_string(item.video_name or "", options.max_title_length) + local channel = item.channel_name or "" + local line = string.format("%s%s%d. %s - (%s)%s", prefix, item_style, i, title, channel, self.styles.reset) + if i == marked_index then + line = line .. " " .. self.styles.marked .. options.marked_icon .. self.styles.reset + end + ass:append(line .. "\n") + end + + return ass.text + end + + return renderer +end + +return ui diff --git a/mpv-youtube-queue/video_store.lua b/mpv-youtube-queue/video_store.lua new file mode 100644 index 0000000..bf7d9ef --- /dev/null +++ b/mpv-youtube-queue/video_store.lua @@ -0,0 +1,234 @@ +local input = require("input") + +local VIDEO_INFO_CACHE_MAX_SIZE = 100 + +local video_store = {} + +local function copy_table(value) + local copied = {} + for key, item in pairs(value) do + copied[key] = item + end + return copied +end + +local function build_local_video(path) + local directory, filename = path:match("^(.*[/\\])(.-)$") + if not directory or not filename then + return nil + end + return { + video_url = path, + video_name = filename, + channel_url = directory, + channel_name = "Local file", + thumbnail_url = "", + view_count = "", + upload_date = "", + category = "", + subscribers = 0, + } +end + +local function build_remote_placeholder(url, title) + return { + video_url = url, + video_name = title or url, + channel_url = "", + channel_name = "Remote URL", + thumbnail_url = "", + view_count = "", + upload_date = "", + category = "Unknown", + subscribers = 0, + } +end + +function video_store.new(config) + local store = { + mp = config.mp, + utils = config.utils, + options = config.options, + notify = config.notify, + cache = {}, + cache_order = {}, + } + + function store:is_file(path) + return input.is_file_info(self.utils.file_info(path)) + end + + function store.normalize_source(_, source) + if source and source:match("^file://") then + return source:gsub("^file://", "") + end + return source + end + + function store:cache_video_info(url, info) + if self.cache[url] then + for i, cached_url in ipairs(self.cache_order) do + if cached_url == url then + table.remove(self.cache_order, i) + break + end + end + end + while #self.cache_order >= VIDEO_INFO_CACHE_MAX_SIZE do + local oldest_url = table.remove(self.cache_order, 1) + self.cache[oldest_url] = nil + end + self.cache[url] = copy_table(info) + table.insert(self.cache_order, url) + end + + function store:get_cached_video_info(url) + local cached = self.cache[url] + if not cached then + return nil + end + for i, cached_url in ipairs(self.cache_order) do + if cached_url == url then + table.remove(self.cache_order, i) + table.insert(self.cache_order, url) + break + end + end + return copy_table(cached) + end + + function store:get_clipboard_content() + local result = self.mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = input.split_command(self.options.clipboard_command), + }) + if result.status ~= 0 then + self.notify("Failed to get clipboard content", nil, true) + return nil + end + + local content = input.sanitize_source(result.stdout) + if not content then + return nil + end + if content:match("^https?://") or content:match("^file://") or self:is_file(content) then + return content + end + self.notify("Clipboard content is not a valid URL or file path", nil, true) + return nil + end + + function store:get_video_info(url) + local cached = self:get_cached_video_info(url) + if cached then + return cached + end + + self.notify("Getting video info...", 3, false) + local result = self.mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = { + "yt-dlp", + "--dump-single-json", + "--ignore-config", + "--no-warnings", + "--skip-download", + "--playlist-items", + "1", + url, + }, + }) + if result.status ~= 0 or not result.stdout or result.stdout:match("^%s*$") then + self.notify("Failed to get video info (yt-dlp error)", nil, true) + return nil + end + + local data = self.utils.parse_json(result.stdout) + if type(data) ~= "table" then + self.notify("Failed to parse JSON from yt-dlp", nil, true) + return nil + end + + local info = { + channel_url = data.channel_url or "", + channel_name = data.uploader or "", + video_name = data.title or "", + view_count = data.view_count or "", + upload_date = data.upload_date or "", + category = data.categories and data.categories[1] or "Unknown", + thumbnail_url = data.thumbnail or "", + subscribers = data.channel_follower_count or 0, + } + if info.channel_url == "" or info.channel_name == "" or info.video_name == "" then + self.notify("Missing metadata in yt-dlp JSON", nil, true) + return nil + end + + self:cache_video_info(url, info) + return copy_table(info) + end + + function store:resolve_video(source) + local normalized = self:normalize_source(source) + if self:is_file(normalized) then + return build_local_video(normalized) + end + + local info = self:get_video_info(normalized) + if not info then + return nil + end + info.video_url = normalized + return info + end + + function store:sync_playlist() + local count = self.mp.get_property_number("playlist-count", 0) + if count == 0 then + return {} + end + + local current_path = self.mp.get_property("path") + local queue = {} + for i = 0, count - 1 do + local url = self.mp.get_property(string.format("playlist/%d/filename", i)) + if url then + local entry + if self:is_file(url) then + entry = build_local_video(url) + else + local cached = self:get_cached_video_info(url) + if cached then + cached.video_url = url + entry = cached + else + local title = self.mp.get_property(string.format("playlist/%d/title", i)) + if url == current_path then + local info = self:get_video_info(url) + if info then + info.video_url = url + entry = info + else + entry = build_remote_placeholder(url, self.mp.get_property("media-title") or title) + self:cache_video_info(url, entry) + end + else + entry = build_remote_placeholder(url, title) + end + end + end + table.insert(queue, entry) + end + end + + return queue + end + + return store +end + +return video_store diff --git a/tests/app_spec.lua b/tests/app_spec.lua new file mode 100644 index 0000000..2ed8df7 --- /dev/null +++ b/tests/app_spec.lua @@ -0,0 +1,126 @@ +local function assert_equal(actual, expected, message) + if actual ~= expected then + error( + (message or "values differ") + .. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual)) + ) + end +end + +local function assert_nil(value, message) + if value ~= nil then + error((message or "expected nil") .. string.format("\nactual: %s", tostring(value))) + end +end + +local function assert_falsy(value, message) + if value then + error(message or "expected falsy value") + end +end + +local function load_script() + local bindings = {} + local mp_stub = { + get_property = function() + return "" + end, + get_property_number = function(_, default) + return default + end, + set_property_number = function() end, + set_osd_ass = function() end, + osd_message = function() end, + add_periodic_timer = function() + return { + kill = function() end, + resume = function() end, + } + end, + add_timeout = function() + return { + kill = function() end, + resume = function() end, + } + end, + add_key_binding = function(_, _, name) + bindings[name] = true + end, + register_event = function() end, + register_script_message = function() end, + commandv = function() end, + command_native_async = function(_, callback) + if callback then + callback(false, nil, "not implemented in tests") + end + end, + command_native = function() + return { + status = 0, + stdout = "", + } + end, + } + + package.loaded["mp"] = nil + package.loaded["mp.options"] = nil + package.loaded["mp.utils"] = nil + package.loaded["mp.assdraw"] = nil + package.loaded["app"] = nil + package.loaded["history_client"] = nil + package.loaded["input"] = nil + package.loaded["json"] = nil + package.loaded["shell"] = nil + package.loaded["state"] = nil + package.loaded["ui"] = nil + package.loaded["video_store"] = nil + + package.preload["mp"] = function() + return mp_stub + end + + package.preload["mp.options"] = function() + return { + read_options = function() end, + } + end + + package.preload["mp.utils"] = function() + return { + file_info = function() + return nil + end, + split_path = function(path) + return path:match("^(.*[/\\])(.-)$") + end, + parse_json = function() + return nil + end, + } + end + + package.preload["mp.assdraw"] = function() + return { + ass_new = function() + return { + text = "", + append = function(self, value) + self.text = self.text .. value + end, + } + end, + } + end + + local chunk = assert(loadfile("mpv-youtube-queue/main.lua")) + return chunk(), bindings +end + +local script, bindings = load_script() + +assert_nil(script.YouTubeQueue.save_queue, "queue save API should be removed") +assert_nil(script.YouTubeQueue.load_queue, "queue load API should be removed") +assert_falsy(bindings.save_queue, "save_queue binding should be removed") +assert_falsy(bindings.save_queue_alt, "save_queue_alt binding should be removed") +assert_falsy(bindings.load_queue, "load_queue binding should be removed") +assert_equal(type(script.YouTubeQueue.add_to_queue), "function", "queue add API should remain") diff --git a/tests/history_client_spec.lua b/tests/history_client_spec.lua new file mode 100644 index 0000000..9660c3e --- /dev/null +++ b/tests/history_client_spec.lua @@ -0,0 +1,60 @@ +local function assert_equal(actual, expected, message) + if actual ~= expected then + error( + (message or "values differ") + .. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual)) + ) + end +end + +local function assert_nil(value, message) + if value ~= nil then + error((message or "expected nil") .. string.format("\nactual: %s", tostring(value))) + end +end + +local function assert_truthy(value, message) + if not value then + error(message or "expected truthy value") + end +end + +package.loaded["history_client"] = nil +package.loaded["json"] = nil + +local calls = {} +local notices = {} +local client = require("history_client").new({ + mp = { + command_native_async = function(command, callback) + table.insert(calls, command) + callback(true, { status = 0 }, nil) + end, + }, + options = { + use_history_db = true, + backend_host = "http://backend.test", + backend_port = "42069", + }, + notify = function(message) + table.insert(notices, message) + end, +}) + +assert_nil(client.save_queue, "queue save backend API should be removed") +assert_nil(client.load_queue, "queue load backend API should be removed") + +local ok = client:add_video({ + video_name = "Demo", + video_url = "https://example.test/watch?v=1", +}) + +assert_truthy(ok, "add_video should still be enabled for shared backend") +assert_equal(#calls, 1, "add_video should issue one backend request") +assert_equal(calls[1].args[1], "curl", "backend request should use curl subprocess") +assert_equal( + calls[1].args[4], + "http://backend.test:42069/add_video", + "backend request should target add_video endpoint" +) +assert_equal(notices[#notices], "Video added to history db", "successful add_video should notify") diff --git a/tests/input_spec.lua b/tests/input_spec.lua new file mode 100644 index 0000000..99a1a11 --- /dev/null +++ b/tests/input_spec.lua @@ -0,0 +1,17 @@ +local input = require("input") + +local function eq(actual, expected, message) + assert(actual == expected, string.format("%s: expected %s, got %s", message, tostring(expected), tostring(actual))) +end + +do + local sanitized = input.sanitize_source([[ "Mary's Video.mp4" +]]) + eq(sanitized, "Mary's Video.mp4", "sanitize should trim wrapper quotes and whitespace without dropping apostrophes") +end + +do + eq(input.is_file_info({ is_file = true }), true, "file info should accept files") + eq(input.is_file_info({ is_file = false }), false, "file info should reject directories") + eq(input.is_file_info(nil), false, "file info should reject missing paths") +end diff --git a/tests/metadata_resolution_test.lua b/tests/metadata_resolution_test.lua new file mode 100644 index 0000000..0e0b25e --- /dev/null +++ b/tests/metadata_resolution_test.lua @@ -0,0 +1,265 @@ +local function assert_equal(actual, expected, message) + if actual ~= expected then + error( + (message or "values differ") + .. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual)) + ) + end +end + +local function assert_truthy(value, message) + if not value then + error(message or "expected truthy value") + end +end + +local function load_script(config) + config = config or {} + local events = {} + local command_native_calls = 0 + local properties = config.properties or {} + local property_numbers = config.property_numbers or {} + local json_map = config.json_map or {} + + local mp_stub = { + get_property = function(name) + return properties[name] + end, + get_property_number = function(name) + return property_numbers[name] + end, + set_property_number = function(name, value) + property_numbers[name] = value + end, + set_osd_ass = function() end, + osd_message = function() end, + add_periodic_timer = function() + return { + kill = function() end, + resume = function() end, + } + end, + add_timeout = function() + return { + kill = function() end, + resume = function() end, + } + end, + add_key_binding = function() end, + register_event = function(name, handler) + events[name] = handler + end, + add_hook = function() end, + register_script_message = function() end, + commandv = function() end, + command_native_async = function(_, callback) + if callback then + callback(false, nil, "not implemented in tests") + end + end, + command_native = function(command) + if command.name == "subprocess" and command.args and command.args[1] == "yt-dlp" then + command_native_calls = command_native_calls + 1 + if config.subprocess_result then + return config.subprocess_result(command_native_calls, command) + end + return { + status = 1, + stdout = "", + } + end + return { + status = 0, + stdout = "", + } + end, + } + + package.loaded["mp"] = nil + package.loaded["mp.options"] = nil + package.loaded["mp.utils"] = nil + package.loaded["mp.assdraw"] = nil + package.loaded["app"] = nil + package.loaded["history"] = nil + package.loaded["history_client"] = nil + package.loaded["input"] = nil + package.loaded["json"] = nil + package.loaded["shell"] = nil + package.loaded["state"] = nil + package.loaded["ui"] = nil + package.loaded["video_store"] = nil + + package.preload["mp"] = function() + return mp_stub + end + + package.preload["mp.options"] = function() + return { + read_options = function() end, + } + end + + package.preload["mp.utils"] = function() + return { + file_info = function(path) + if path and path:match("^/") then + return { is_file = true } + end + return nil + end, + split_path = function(path) + return path:match("^(.*[/\\])(.-)$") + end, + parse_json = function(payload) + return json_map[payload] + end, + } + end + + package.preload["mp.assdraw"] = function() + return { + ass_new = function() + return { + text = "", + append = function(self, value) + self.text = self.text .. value + end, + } + end, + } + end + + local chunk = assert(loadfile("mpv-youtube-queue/main.lua")) + local script = chunk() + + return { + events = events, + script = script, + get_ytdlp_calls = function() + return command_native_calls + end, + set_property = function(name, value) + properties[name] = value + end, + } +end + +local unsupported = load_script({ + properties = { + ["osd-ass-cc/0"] = "", + ["osd-ass-cc/1"] = "", + ["path"] = "https://jellyfin.example/items/1", + ["media-title"] = "Jellyfin Episode 1", + ["playlist/0/filename"] = "https://jellyfin.example/items/1", + }, + property_numbers = { + ["playlist-count"] = 1, + }, + subprocess_result = function() + return { + status = 1, + stdout = "", + } + end, +}) + +assert_truthy(unsupported.script and unsupported.script._test, "script test helpers should be returned") +unsupported.events["file-loaded"]() + +local queue = unsupported.script._test.snapshot_queue() +assert_equal(#queue, 1, "unsupported stream should be queued") +assert_equal(queue[1].video_name, "Jellyfin Episode 1", "fallback metadata should prefer media-title") +assert_equal(unsupported.get_ytdlp_calls(), 1, "first sync should try extractor once") + +assert_equal(unsupported.events["playback-restart"], nil, "playback-restart import hook should be removed") +assert_equal(unsupported.get_ytdlp_calls(), 1, "seeking should not retry extractor metadata lookup") + +unsupported.script.YouTubeQueue.sync_with_playlist() +assert_equal(unsupported.get_ytdlp_calls(), 1, "cached fallback metadata should prevent repeated extractor calls") + +local supported = load_script({ + properties = { + ["osd-ass-cc/0"] = "", + ["osd-ass-cc/1"] = "", + ["path"] = "https://youtube.example/watch?v=abc", + ["playlist/0/filename"] = "https://youtube.example/watch?v=abc", + }, + property_numbers = { + ["playlist-count"] = 1, + }, + json_map = { + supported = { + channel_url = "https://youtube.example/channel/demo", + uploader = "Demo Channel", + title = "Supported Video", + view_count = 42, + upload_date = "20260306", + categories = { "Music" }, + thumbnail = "https://img.example/thumb.jpg", + channel_follower_count = 1000, + }, + }, + subprocess_result = function() + return { + status = 0, + stdout = "supported", + } + end, +}) + +supported.script.YouTubeQueue.sync_with_playlist() +local supported_queue = supported.script._test.snapshot_queue() +assert_equal(supported_queue[1].video_name, "Supported Video", "supported urls should keep extractor metadata") +assert_equal(supported.get_ytdlp_calls(), 1, "supported url should call extractor once") + +supported.script.YouTubeQueue.sync_with_playlist() +assert_equal(supported.get_ytdlp_calls(), 1, "supported url should reuse cached extractor metadata") + +local multi_remote = load_script({ + properties = { + ["osd-ass-cc/0"] = "", + ["osd-ass-cc/1"] = "", + ["path"] = "https://example.test/watch?v=first", + ["playlist/0/filename"] = "https://example.test/watch?v=first", + ["playlist/0/title"] = "Title A mpv", + ["playlist/1/filename"] = "https://example.test/watch?v=second", + ["playlist/1/title"] = "Title B mpv", + }, + property_numbers = { + ["playlist-count"] = 2, + }, + json_map = { + first = { + channel_url = "https://example.test/channel/a", + uploader = "Channel A", + title = "Extractor A", + }, + second = { + channel_url = "https://example.test/channel/b", + uploader = "Channel B", + title = "Extractor B", + }, + }, + subprocess_result = function(call_count) + if call_count == 1 then + return { status = 0, stdout = "first" } + end + return { status = 0, stdout = "second" } + end, +}) + +multi_remote.events["file-loaded"]() +local first_pass = multi_remote.script._test.snapshot_queue() +assert_equal(first_pass[1].video_name, "Extractor A", "first current item should resolve extractor metadata") +assert_equal(first_pass[2].video_name, "Title B mpv", "later items can start as placeholders") + +assert_equal(multi_remote.events["playback-restart"], nil, "playback-restart import hook should stay removed") +assert_equal(multi_remote.get_ytdlp_calls(), 1, "playback restart should not trigger playlist resync") + +multi_remote.set_property("path", "https://example.test/watch?v=second") +multi_remote.events["file-loaded"]() +local second_pass = multi_remote.script._test.snapshot_queue() +assert_equal(second_pass[2].video_name, "Extractor B", "current item should upgrade when it loads") +assert_equal(multi_remote.get_ytdlp_calls(), 2, "each remote item should resolve at most once when current") + +print("ok") diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..887d8b0 --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,44 @@ +package.path = table.concat({ + "./?.lua", + "./?/init.lua", + "./?/?.lua", + "./mpv-youtube-queue/?.lua", + "./mpv-youtube-queue/?/?.lua", + package.path, +}, ";") + +local total = 0 +local failed = 0 + +local function run_test(file) + local chunk, err = loadfile(file) + if not chunk then + error(err) + end + local ok, test_err = pcall(chunk) + total = total + 1 + if ok then + io.write("PASS ", file, "\n") + return + end + failed = failed + 1 + io.write("FAIL ", file, "\n", test_err, "\n") +end + +local tests = { + "tests/app_spec.lua", + "tests/metadata_resolution_test.lua", + "tests/state_spec.lua", + "tests/history_client_spec.lua", + "tests/input_spec.lua", +} + +for _, file in ipairs(tests) do + run_test(file) +end + +if failed > 0 then + error(string.format("%d/%d tests failed", failed, total)) +end + +io.write(string.format("PASS %d tests\n", total)) diff --git a/tests/state_spec.lua b/tests/state_spec.lua new file mode 100644 index 0000000..76cade4 --- /dev/null +++ b/tests/state_spec.lua @@ -0,0 +1,63 @@ +local state = require("state") + +local function eq(actual, expected, message) + assert(actual == expected, string.format("%s: expected %s, got %s", message, tostring(expected), tostring(actual))) +end + +local function same_table(actual, expected, message) + eq(#actual, #expected, message .. " length") + for i, value in ipairs(expected) do + eq(actual[i], value, message .. " [" .. i .. "]") + end +end + +do + local start_index, end_index = state.get_display_range(20, 20, 10) + eq(start_index, 11, "range start should backfill near queue end") + eq(end_index, 20, "range end should stop at queue end") +end + +do + local result = state.remove_queue_item({ + queue = { "a", "b", "c", "d" }, + current_index = 2, + selected_index = 4, + marked_index = 3, + }) + same_table(result.queue, { "a", "b", "c" }, "remove after current queue") + eq(result.current_index, 2, "current index should not shift when removing after current") + eq(result.selected_index, 3, "selected index should move to previous row when deleting last row") + eq(result.marked_index, 3, "marked index should remain attached to same item when removing after it") +end + +do + local result = state.remove_queue_item({ + queue = { "a", "b", "c", "d" }, + current_index = 4, + selected_index = 2, + marked_index = 4, + }) + same_table(result.queue, { "a", "c", "d" }, "remove before current queue") + eq(result.current_index, 3, "current index should shift back when removing before current") + eq(result.marked_index, 3, "marked index should rebase when its item shifts") +end + +do + local result = state.reorder_queue({ + queue = { "a", "b", "c", "d" }, + current_index = 3, + selected_index = 1, + from_index = 1, + to_index = 3, + }) + same_table(result.queue, { "b", "c", "a", "d" }, "reorder into current slot queue") + eq(result.current_index, 2, "current index should follow the current item when inserting before it") + eq(result.selected_index, 3, "selected index should follow moved item") +end + +do + local ok, err = pcall(function() + state.normalize_reorder_indices("2", "4") + end) + assert(ok, "string reorder indices should be accepted: " .. tostring(err)) +end