mirror of
https://github.com/ksyasuda/mpv-youtube-queue.git
synced 2026-03-22 18:11:27 -07:00
refactor: split script into modules and drop queue save/load
All checks were successful
Luacheck / luacheck (push) Successful in 58s
All checks were successful
Luacheck / luacheck (push) Successful in 58s
This commit is contained in:
22
README.md
22
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
|
- **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")
|
- **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
|
- **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
|
- **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
|
## Installation
|
||||||
|
|
||||||
- Copy `mpv-youtube-queue.lua` script to your `~~/scripts` directory
|
- Copy the `mpv-youtube-queue/` directory to your `~~/scripts` directory
|
||||||
- `~/.config/mpv/scripts` on Linux
|
- Result on Linux: `~/.config/mpv/scripts/mpv-youtube-queue/main.lua`
|
||||||
- `%APPDATA%\mpv\scripts` on Windows
|
- Result on Windows: `%APPDATA%\mpv\scripts\mpv-youtube-queue\main.lua`
|
||||||
- Optionally copy `mpv-youtube-queue.conf` to the `~~/script-opts` directory
|
- Optionally copy `mpv-youtube-queue.conf` to the `~~/script-opts` directory
|
||||||
- `~/.config/mpv/script-opts` on Linux
|
- `~/.config/mpv/script-opts` on Linux
|
||||||
- `%APPDATA%\mpv\script-opts` on Windows
|
- `%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
|
- `download_selected_video - ctrl+D`: Download the currently selected video
|
||||||
in the queue
|
in the queue
|
||||||
- `move_cursor_down - ctrl+j`: Move the cursor down one row 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
|
||||||
- `load_queue - ctrl+l` - Appends the videos from the most recent save point to the
|
|
||||||
queue
|
|
||||||
- `move_video - ctrl+m`: Mark/move the selected video 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
|
- `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
|
- `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
|
- `print_current_video - ctrl+P`: Print the name and channel of the currently
|
||||||
playing video to the OSD
|
playing video to the OSD
|
||||||
- `print_queue - ctrl+q`: Print the contents of the queue 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
|
- `remove_from_queue - ctrl+x`: Remove the currently selected video from the
|
||||||
queue
|
queue
|
||||||
- `play_selected_video - ctrl+ENTER`: Play the currently selected video in
|
- `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 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
|
- `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
|
- `clipboard_command - xclip -o`: The command to use to get the contents of the clipboard
|
||||||
- `cursor_icon - ➤`: The icon to use for the cursor
|
- `cursor_icon - ➤`: The icon to use for the cursor
|
||||||
- `display_limit - 10`: The maximum amount of videos to show on the OSD at once
|
- `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_directory - ~/videos/YouTube`: The directory to use when downloading a video
|
||||||
- `download_quality 720p`: The maximum download quality
|
- `download_quality 720p`: The maximum download quality
|
||||||
- `downloader - curl`: The name of the program to use to download the video
|
- `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_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)
|
- `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>`
|
- Full path with the default `download_directory` is: `~/videos/YouTube/<uploader>/<title>.<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_host`: ip or hostname of the backend server
|
||||||
- `backend_port`: port to connect to for the backend server
|
- `backend_port`: port to connect to for the backend server
|
||||||
|
|
||||||
|
|||||||
62
docs/plans/2026-03-06-stream-metadata-design.md
Normal file
62
docs/plans/2026-03-06-stream-metadata-design.md
Normal file
@@ -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.
|
||||||
176
docs/plans/2026-03-06-stream-metadata-fix.md
Normal file
176
docs/plans/2026-03-06-stream-metadata-fix.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
add_to_queue=ctrl+a
|
add_to_queue=ctrl+a
|
||||||
default_save_method=unwatched
|
|
||||||
download_current_video=ctrl+d
|
download_current_video=ctrl+d
|
||||||
download_selected_video=ctrl+D
|
download_selected_video=ctrl+D
|
||||||
move_cursor_down=ctrl+j
|
move_cursor_down=ctrl+j
|
||||||
move_cursor_up=ctrl+k
|
move_cursor_up=ctrl+k
|
||||||
load_queue=ctrl+l
|
|
||||||
move_video=ctrl+m
|
move_video=ctrl+m
|
||||||
play_next_in_queue=ctrl+n
|
play_next_in_queue=ctrl+n
|
||||||
open_video_in_browser=ctrl+o
|
open_video_in_browser=ctrl+o
|
||||||
@@ -12,18 +10,17 @@ open_channel_in_browser=ctrl+O
|
|||||||
play_previous_in_queue=ctrl+p
|
play_previous_in_queue=ctrl+p
|
||||||
print_current_video=ctrl+P
|
print_current_video=ctrl+P
|
||||||
print_queue=ctrl+q
|
print_queue=ctrl+q
|
||||||
save_queue=ctrl+s
|
|
||||||
save_full_alt=ctrl+S
|
|
||||||
remove_from_queue=ctrl+x
|
remove_from_queue=ctrl+x
|
||||||
play_selected_video=ctrl+ENTER
|
play_selected_video=ctrl+ENTER
|
||||||
browser=firefox
|
browser=firefox
|
||||||
clipboard_command=xclip -o
|
clipboard_command=xclip -o
|
||||||
cursor_icon=➤
|
cursor_icon=➤
|
||||||
display_limit=10
|
display_limit=10
|
||||||
|
max_title_length=60
|
||||||
download_directory=~/videos/YouTube
|
download_directory=~/videos/YouTube
|
||||||
download_quality=720p
|
download_quality=720p
|
||||||
downloader=curl
|
downloader=curl
|
||||||
font_name=JetBrainsMono
|
font_name=JetBrains Mono
|
||||||
font_size=12
|
font_size=12
|
||||||
marked_icon=⇅
|
marked_icon=⇅
|
||||||
menu_timeout=5
|
menu_timeout=5
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
467
mpv-youtube-queue/app.lua
Normal file
467
mpv-youtube-queue/app.lua
Normal file
@@ -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
|
||||||
44
mpv-youtube-queue/history_client.lua
Normal file
44
mpv-youtube-queue/history_client.lua
Normal file
@@ -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
|
||||||
58
mpv-youtube-queue/input.lua
Normal file
58
mpv-youtube-queue/input.lua
Normal file
@@ -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
|
||||||
267
mpv-youtube-queue/json.lua
Normal file
267
mpv-youtube-queue/json.lua
Normal file
@@ -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
|
||||||
3
mpv-youtube-queue/main.lua
Normal file
3
mpv-youtube-queue/main.lua
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
local app = require("app").new()
|
||||||
|
|
||||||
|
return app
|
||||||
90
mpv-youtube-queue/shell.lua
Normal file
90
mpv-youtube-queue/shell.lua
Normal file
@@ -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
|
||||||
140
mpv-youtube-queue/state.lua
Normal file
140
mpv-youtube-queue/state.lua
Normal file
@@ -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
|
||||||
97
mpv-youtube-queue/ui.lua
Normal file
97
mpv-youtube-queue/ui.lua
Normal file
@@ -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
|
||||||
234
mpv-youtube-queue/video_store.lua
Normal file
234
mpv-youtube-queue/video_store.lua
Normal file
@@ -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
|
||||||
126
tests/app_spec.lua
Normal file
126
tests/app_spec.lua
Normal file
@@ -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")
|
||||||
60
tests/history_client_spec.lua
Normal file
60
tests/history_client_spec.lua
Normal file
@@ -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")
|
||||||
17
tests/input_spec.lua
Normal file
17
tests/input_spec.lua
Normal file
@@ -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
|
||||||
265
tests/metadata_resolution_test.lua
Normal file
265
tests/metadata_resolution_test.lua
Normal file
@@ -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")
|
||||||
44
tests/run.lua
Normal file
44
tests/run.lua
Normal file
@@ -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))
|
||||||
63
tests/state_spec.lua
Normal file
63
tests/state_spec.lua
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user