mirror of
https://github.com/ksyasuda/mpv-youtube-queue.git
synced 2026-03-22 06:11:26 -07:00
Compare commits
1 Commits
master
...
use-mpv-ut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff60b12e61 |
4
.github/workflows/luackeck.yml
vendored
4
.github/workflows/luackeck.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
test.sh
|
||||
.git/*
|
||||
.luarc.json
|
||||
60
README.md
60
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
649
mpv-youtube-queue.lua
Normal 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)
|
||||
-- }}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
local app = require("app").new()
|
||||
|
||||
return app
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user