1 Commits

Author SHA1 Message Date
ksyasuda
ff60b12e61 change helper functions to use mpv utils
- use utils.file_info.is_file to check if the file exits and is a file
- use utils.split_path to split path into directory and filename components
2023-08-08 00:47:19 -07:00
22 changed files with 685 additions and 2251 deletions

View File

@@ -1,7 +1,7 @@
name: Luacheck
on: push
on: [push, pull_request]
jobs:
luacheck:
sile:
runs-on: ubuntu-latest
steps:
- name: Checkout

3
.gitignore vendored
View File

@@ -1,3 +0,0 @@
test.sh
.git/*
.luarc.json

View File

@@ -2,7 +2,7 @@
<div align="center">
A Lua script that replicates and extends the YouTube "Add to Queue" feature for mpv
A Lua script that implements the YouTube 'Add to Queue' functionality for mpv
</div>
@@ -10,29 +10,36 @@ A Lua script that replicates and extends the YouTube "Add to Queue" feature for
## Features
- **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
- Add YouTube videos to a queue from the clipboard
- Fetch and display the video and channel names of the videos in the queue
- Select a video to play from the queue with an interactive menu,
or navigate through the queue with keyboard shortcuts
- Edit the order of videos in the queue
- Open the URL or channel page of the currently playing video in a new browser tab
- Download the currently playing video
- Download a video in the queue
## Notes
- This script uses the Linux `xclip` utility to read from the clipboard.
If you're on macOS or Windows, you'll need to adjust the `clipboard_command`
config variable in [mpv-youtube-queue.conf](./mpv-youtube-queue.conf)
- When adding videos to the queue, the script fetches the video name using
`yt-dlp`. Ensure you have `yt-dlp` installed and in your PATH.
## Requirements
This script requires the following software to be installed on the system
- One of [xclip](https://github.com/astrand/xclip), [wl-clipboard](https://github.com/bugaevc/wl-clipboard), or any command-line utility that can paste from the system clipboard
- Windows users can utilize `Get-Clipboard` from powershell by setting the `clipboard_command` in `mpv-youtube-queue.conf` file to the following: `clipboard_command=powershell -command Get-Clipboard`
- [xclip](https://github.com/astrand/xclip)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
## Installation
- 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
to customize the script configuration as described in the next section
- Copy the `mpv-youtube-queue.lua` script to your `~~/scripts` directory
(`~/.config/mpv` on Linux)
- Optionally copy the `mpv-youtube-queue.conf` to the `~~/script-opts` directory
to customize the script configuration as described in the next section
## Configuration
@@ -43,7 +50,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
- `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
@@ -58,27 +65,26 @@ This script requires the following software to be installed on the system
- `play_selected_video - ctrl+ENTER`: Play the currently selected video in
the queue
### Default Options
### Default Option
- `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
- `display_limit - 6`: The maximum amount of videos to show on the OSD at once
- `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
- `font_name - JetBrains Mono`: The name of the font to use
- `font_size - 12`: Size of the font
- `marked_icon - ⇅`: The icon to use to mark a video as ready to be moved in the queue
- `menu_timeout - 5`: The number of seconds until the menu times out
- `marked_icon - ⇅`: The icon to use to mark a video as ready to be moved in
the queue
- `show_errors - yes`: Show error messages on the OSD
- `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/<uploader>/<title>.<ext>`
- `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
- `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/<uploader>/<title>.<ext>`
## License

View File

@@ -1,62 +0,0 @@
# 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.

View File

@@ -1,176 +0,0 @@
# 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"
```

View File

@@ -15,18 +15,13 @@ play_selected_video=ctrl+ENTER
browser=firefox
clipboard_command=xclip -o
cursor_icon=➤
display_limit=10
max_title_length=60
display_limit=6
download_directory=~/videos/YouTube
download_quality=720p
downloader=curl
font_name=JetBrains Mono
font_size=12
marked_icon=⇅
menu_timeout=5
show_errors=yes
ytdlp_file_format=mp4
ytdlp_output_template=%(uploader)s/%(title)s.%(ext)s
use_history_db=no
backend_host=http://localhost
backend_port=42069

649
mpv-youtube-queue.lua Normal file
View File

@@ -0,0 +1,649 @@
-- 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 styleOn = mp.get_property("osd-ass-cc/0")
local styleOff = mp.get_property("osd-ass-cc/1")
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 = 6,
download_directory = "~/videos/YouTube",
download_quality = "720p",
downloader = "curl",
font_name = "JetBrains Mono",
font_size = 12,
marked_icon = "",
show_errors = true,
ytdlp_file_format = "mp4",
ytdlp_output_template = "%(uploader)s/%(title)s.%(ext)s"
}
mp.options.read_options(options, "mpv-youtube-queue")
local colors = {
error = "676EFF",
selected = "F993BD",
hover_selected = "FAA9CA",
cursor = "FDE98B",
header = "8CFAF1",
hover = "F2F8F8",
text = "BFBFBF",
marked = "C679FF"
}
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 .. "}"
}
local YouTubeQueue = {}
local video_queue = {}
local MSG_DURATION = 1.5
local display_limit = options.display_limit
local index = 0
local selected_index = 1
local display_offset = 0
local marked_index = nil
local current_video = nil
-- HELPERS {{{
-- surround string with single quotes if it does not already have them
local function surround_with_quotes(s)
if string.sub(s, 0, 1) == "'" and string.sub(s, -1) == "'" then
return s
else
return "'" .. s .. "'"
end
end
local function remove_quotes(s) return string.gsub(s, "'", "") end
-- run sleep shell command for n seconds
local function sleep(n) os.execute("sleep " .. tonumber(n)) end
local function print_osd_message(message, duration, s)
if s == style.error and not options.show_errors then return end
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
local function is_file(filepath)
mp.msg.info("FILEPATH: " .. filepath)
local result = utils.file_info(filepath)
if result == nil then
print_osd_message("File not found: " .. filepath, 3, style.error)
return false
end
return result.is_file
end
-- returns the filename given a path (e.g. /home/user/file.txt -> file.txt)
local function split_path(filepath)
if is_file(filepath) then return utils.split_path(filepath) end
end
local function print_current_video()
local current = YouTubeQueue.get_current_video()
if is_file(current.video_url) then
print_osd_message("Playing: " .. current.video_name, 3)
else
print_osd_message("Playing: " .. current.video_name .. ' by ' ..
current.channel_name, 3)
end
end
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
local function open_url_in_browser(url)
local command = options.browser .. " " .. surround_with_quotes(url)
os.execute(command)
end
local function open_video_in_browser()
open_url_in_browser(YouTubeQueue.get_current_video().video_url)
end
local function open_channel_in_browser()
open_url_in_browser(YouTubeQueue.get_current_video().channel_url)
end
-- local function is_valid_ytdlp_url(url)
-- local command = 'yt-dlp --simulate \'' .. url .. '\' >/dev/null 2>&1'
-- local handle = io.popen(command .. "; echo $?")
-- if handle == nil then return false end
-- local result = handle:read("*a")
-- if result == nil then return false end
-- handle:close()
-- return result:gsub("%s+$", "") == "0"
-- end
-- }}}
-- QUEUE GETTERS AND SETTERS {{{
function YouTubeQueue.size() return #video_queue end
function YouTubeQueue.get_current_index() return index end
function YouTubeQueue.get_video_queue() return video_queue end
function YouTubeQueue.set_current_index(idx)
index = idx
current_video = video_queue[idx]
end
function YouTubeQueue.get_current_video() return current_video end
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
function YouTubeQueue.get_clipboard_content()
local handle = io.popen(options.clipboard_command)
if handle == nil then
print_osd_message("Error getting clipboard content", MSG_DURATION,
style.error)
return nil
end
local result = handle:read("*a")
handle:close()
return result
end
function YouTubeQueue.get_video_info(url)
local command =
'yt-dlp --print channel_url --print uploader --print title --playlist-items 1 ' ..
surround_with_quotes(url)
local handle = io.popen(command)
if handle == nil then return nil, nil, nil end
local result = handle:read("*a")
handle:close()
-- Split the result into URL, name, and video title
local channel_url, channel_name, video_name = result:match(
"(.-)\n(.-)\n(.*)")
-- Remove trailing whitespace
if channel_url ~= nil then channel_url = channel_url:gsub("%s+$", "") end
if channel_name ~= nil then channel_name = channel_name:gsub("%s+$", "") end
if video_name ~= nil then video_name = video_name:gsub("%s+$", "") end
return channel_url, channel_name, video_name
end
-- }}}
-- QUEUE FUNCTIONS {{{
-- Function to get the next video in the queue
-- Returns nil if there are no videos in the queue
function YouTubeQueue.next_in_queue()
if index < #video_queue then
index = index + 1
selected_index = index
current_video = video_queue[index]
return current_video
end
end
function YouTubeQueue.prev_in_queue()
if index > 1 then
index = index - 1
selected_index = index
current_video = video_queue[index]
return current_video
end
end
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
function YouTubeQueue.update_current_index()
if #video_queue == 0 then return 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
current_video = YouTubeQueue.get_video_at(index)
return
end
end
-- if not found, reset the index
index = 0
end
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
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 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
-- Swap the videos between the two provided indices in the video_queue
local temp_video = video_queue[from_index]
table.remove(video_queue, from_index)
table.insert(video_queue, to_index, temp_video)
-- Swap the videos between the two provided indices in the MPV playlist
mp.commandv("playlist-move", from_index - 1, to_index - 1)
-- Redraw the queue after reordering
YouTubeQueue.print_queue()
else
print_osd_message("Invalid indices for reordering. No changes made.",
MSG_DURATION, style.error)
end
end
function YouTubeQueue.print_queue(duration)
local current_index = index
if duration == nil then duration = 3 end
if #video_queue > 0 then
local start_index = math.max(1, selected_index - display_limit / 2)
local end_index =
math.min(#video_queue, start_index + display_limit - 1)
display_offset = start_index - 1
local message =
styleOn .. style.header .. "MPV-YOUTUBE-QUEUE{\\u0\\b0}" ..
style.reset .. style.font .. "\n"
for i = start_index, end_index do
local prefix = (i == selected_index) and style.cursor ..
options.cursor_icon .. " " .. style.reset or
" "
if i == current_index and i == selected_index then
message =
message .. prefix .. style.hover_selected .. i .. ". " ..
video_queue[i].video_name .. " - (" ..
video_queue[i].channel_name .. ")" .. style.reset
elseif i == current_index then
message = message .. prefix .. style.selected .. i .. ". " ..
video_queue[i].video_name .. " - (" ..
video_queue[i].channel_name .. ")" .. style.reset
elseif i == selected_index then
message = message .. prefix .. style.hover .. i .. ". " ..
video_queue[i].video_name .. " - (" ..
video_queue[i].channel_name .. ")" .. style.reset
else
message = message .. prefix .. style.reset .. i .. ". " ..
video_queue[i].video_name .. " - (" ..
video_queue[i].channel_name .. ")" .. style.reset
end
if i == marked_index then
message =
message .. " " .. style.marked .. options.marked_icon ..
style.reset .. "\n"
else
message = message .. "\n"
end
end
message = message .. styleOff
mp.osd_message(message, duration)
else
print_osd_message("No videos in the queue or history.", duration,
style.error)
end
end
function YouTubeQueue.move_cursor_up()
if selected_index > 1 then
selected_index = selected_index - 1
if selected_index < display_offset + 1 then
display_offset = display_offset - 1
end
YouTubeQueue.print_queue(MSG_DURATION)
end
end
function YouTubeQueue.move_cursor_down()
if selected_index < YouTubeQueue.size() then
selected_index = selected_index + 1
if selected_index > display_offset + display_limit then
display_offset = display_offset + 1
end
YouTubeQueue.print_queue(MSG_DURATION)
end
end
function YouTubeQueue.play_video_at(idx)
local queue = YouTubeQueue.get_video_queue()
if idx <= 0 or idx > #queue then
print_osd_message("Invalid video index", MSG_DURATION, style.error)
return nil
end
YouTubeQueue.set_current_index(idx)
selected_index = index
mp.set_property_number("playlist-pos", index - 1) -- zero-based index
return current_video
end
function YouTubeQueue.play_selected_video()
-- local current_index = YouTubeQueue.get_current_index()
YouTubeQueue.play_video_at(selected_index)
YouTubeQueue.print_queue(MSG_DURATION - 0.5)
sleep(MSG_DURATION)
print_current_video()
end
-- play the next video in the queue
function YouTubeQueue.play_next_in_queue()
local next_video = YouTubeQueue.next_in_queue()
if next_video == nil then
print_osd_message("No more videos in the queue.", MSG_DURATION,
style.error)
return
end
local current_index = YouTubeQueue.get_current_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 YouTubeQueue.size() > 1 then
mp.set_property_number("playlist-pos", current_index - 1)
else
local state = mp.get_property("core-idle")
if state == "yes" then
mp.commandv("loadfile", next_video.video_url, "replace")
end
end
print_current_video()
selected_index = current_index
sleep(MSG_DURATION)
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
function YouTubeQueue.add_to_queue(url, update_internal_playlist)
if update_internal_playlist == nil then update_internal_playlist = 0 end
if url == nil or url == "" then
url = YouTubeQueue.get_clipboard_content()
if url == nil or url == "" then
print_osd_message("Nothing found in the clipboard.", MSG_DURATION,
style.error)
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, channel_name, video_name, video_url
if is_file(url) then
video_url = url
channel_url, video_name = split_path(video_url)
mp.msg.info("channel_url: " .. channel_url)
mp.msg.info("video_name: " .. video_name)
channel_name = "Local file"
video = {
video_url = video_url,
video_name = video_name,
channel_url = channel_url,
channel_name = channel_name
}
else
channel_url, channel_name, video_name = YouTubeQueue.get_video_info(url)
url = remove_quotes(url)
if (channel_url == nil or channel_name == nil or video_name == nil) or
(channel_url == "" or channel_name == "" or video_name == "") then
print_osd_message("Error getting video info.", MSG_DURATION,
style.error)
else
video = {
video_url = url,
video_name = video_name,
channel_url = channel_url,
channel_name = channel_name
}
end
end
table.insert(video_queue, video)
-- if the queue was empty, start playing the video
-- otherwise, add the video to the playlist
if not YouTubeQueue.get_current_video() then
YouTubeQueue.play_next_in_queue()
elseif update_internal_playlist == 0 then
mp.commandv("loadfile", url, "append-play")
end
print_osd_message("Added " .. video_name .. " to queue.", MSG_DURATION)
end
-- play the previous video in the queue
function YouTubeQueue.play_previous_video()
local previous_video = YouTubeQueue.prev_in_queue()
if previous_video == nil then
print_osd_message("No previous video available.", MSG_DURATION,
style.error)
return
end
local current_index = YouTubeQueue.get_current_index()
mp.set_property_number("playlist-pos", current_index - 1)
selected_index = current_index
print_current_video()
sleep(MSG_DURATION)
end
function YouTubeQueue.download_video_at(idx)
local o = options
local v = video_queue[idx]
local q = o.download_quality:sub(1, -2)
local dl_dir = expanduser(o.download_directory)
local command = '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
-- Run the download command
local handle = io.popen(command)
if handle == nil then
print_osd_message("Error starting download.", MSG_DURATION, style.error)
return
end
print_osd_message("Starting download for " .. v.video_name, MSG_DURATION)
local result = handle:read("*a")
handle:close()
if result == nil then
print_osd_message("Error starting download.", MSG_DURATION, style.error)
return
end
if result then
print_osd_message("Finished downloading " .. v.video_name, MSG_DURATION)
else
print_osd_message("Error downloading " .. v.video_name, MSG_DURATION,
style.error)
end
end
function YouTubeQueue.download_current_video()
if is_file(current_video.video_url) then
print_osd_message("Current video is a local file... doing nothing.",
MSG_DURATION, style.error)
return
end
if current_video ~= nil and current_video ~= "" then
YouTubeQueue.download_video_at(index)
else
print_osd_message("No video to download.", MSG_DURATION, style.error)
end
end
function YouTubeQueue.download_selected_video()
if selected_index == 1 and current_video == nil then
print_osd_message("No video to download.", MSG_DURATION, style.error)
return
end
if is_file(YouTubeQueue.get_video_at(selected_index)) then
print_osd_message("Current video is a local file... doing nothing.",
MSG_DURATION, style.error)
return
end
YouTubeQueue.download_video_at(selected_index)
end
function YouTubeQueue.remove_from_queue()
if index == selected_index then
print_osd_message("Cannot remove current video", MSG_DURATION,
style.error)
return
end
table.remove(video_queue, selected_index)
mp.commandv("playlist-remove", selected_index - 1)
print_osd_message("Deleted " .. current_video.video_name .. " from queue.",
MSG_DURATION)
if selected_index > 1 then selected_index = selected_index - 1 end
index = index - 1
YouTubeQueue.print_queue()
end
-- }}}
-- LISTENERS {{{
-- Function to be called when the end-file event is triggered
local function on_end_file(event)
if event.reason == "eof" then -- The file ended normally
YouTubeQueue.update_current_index()
end
end
-- Function to be called when the track-changed event is triggered
local function on_track_changed() YouTubeQueue.update_current_index() end
-- Function to be called when the playback-restart event is triggered
local function on_playback_restart()
local playlist_size = mp.get_property_number("playlist-count", 0)
if current_video ~= nil and playlist_size > 1 then
YouTubeQueue.update_current_index()
elseif current_video == nil then
local url = mp.get_property("path")
YouTubeQueue.add_to_queue(url)
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",
YouTubeQueue.play_next_in_queue)
mp.add_key_binding(options.play_previous_in_queue, "play_previous_video",
YouTubeQueue.play_previous_video)
mp.add_key_binding(options.print_queue, "print_queue", YouTubeQueue.print_queue)
mp.add_key_binding(options.move_cursor_up, "move_cursor_up",
YouTubeQueue.move_cursor_up, { repeatable = true })
mp.add_key_binding(options.move_cursor_down, "move_cursor_down",
YouTubeQueue.move_cursor_down, { repeatable = true })
mp.add_key_binding(options.play_selected_video, "play_selected_video",
YouTubeQueue.play_selected_video)
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",
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",
YouTubeQueue.download_current_video)
mp.add_key_binding(options.download_selected_video, "download_selected_video",
YouTubeQueue.download_selected_video)
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.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_script_message("add_to_queue", YouTubeQueue.add_to_queue)
mp.register_script_message("print_queue", YouTubeQueue.print_queue)
-- }}}

View File

@@ -1,467 +0,0 @@
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

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,58 +0,0 @@
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

View File

@@ -1,267 +0,0 @@
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

View File

@@ -1,3 +0,0 @@
local app = require("app").new()
return app

View File

@@ -1,90 +0,0 @@
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

View File

@@ -1,140 +0,0 @@
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

View File

@@ -1,97 +0,0 @@
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

View File

@@ -1,234 +0,0 @@
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

View File

@@ -1,126 +0,0 @@
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")

View File

@@ -1,60 +0,0 @@
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")

View File

@@ -1,17 +0,0 @@
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

View File

@@ -1,265 +0,0 @@
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")

View File

@@ -1,44 +0,0 @@
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))

View File

@@ -1,63 +0,0 @@
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