first commit

This commit is contained in:
2026-03-26 03:26:37 -07:00
commit 38d50a814f
38 changed files with 7755 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
VITE_HYDRUS_HOST=http://localhost
VITE_HYDRUS_PORT=45869
VITE_HYDRUS_API_KEY=95178b08e6ba3c57991e7b4e162c6efff1ce90c500005c6ebf8524122ed2486e
VITE_HYDRUS_SSL=false

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.vscode/
.DS_Store
/public/icon-*.png

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# API Media Player (PWA)
This is a small web-first PWA prototype for browsing media from a Hydrus client via its Client API. The app ships with a demo library and routes playback to external players such as mpv or VLC instead of using an in-browser player.
## Quick start (PowerShell)
1. Install dependencies:
```powershell
pwsh -c "npm install"
```
2. Run the dev server:
```powershell
pwsh -c "npm run dev"
```
Open `http://localhost:5173` in your browser to test the PWA and external-player launch flow.
## Configuring Hydrus
Create a `.env` file in the project root (not committed) with these variables if you want to connect to a real Hydrus instance:
```
VITE_HYDRUS_HOST=http://localhost
VITE_HYDRUS_PORT=45869
VITE_HYDRUS_API_KEY=
VITE_HYDRUS_SSL=false
```
> Note: browsers cannot attach custom Hydrus API headers to direct media URLs. If your Hydrus server requires header-based authentication for file access, use a reverse proxy or another trusted layer that can mint playable URLs for your external player.
### Settings UI & quick test
On first run the app will seed a sample server entry for `192.168.1.128:45869` so you can quickly add your API key and test connectivity via the app's Settings (top-right gear). Open Settings, choose the server, paste your `Hydrus-Client-API-Access-Key` (if needed), and click **Test connection**. The test reports whether the server is reachable, whether authentication is required (HTTP 401/403), and whether byte-range requests are supported (needed for seeking).
If you get a CORS or network error in the browser, consider running a reverse proxy that adds proper CORS headers or packaging the app with Capacitor to avoid browser CORS limitations.
## Violentmonkey mpv userscript
If you want desktop or Android browsers to hand media straight to mpv instead of the in-browser player, install the userscript at:
```text
/userscripts/api-media-player-open-in-mpv.user.js
```
Examples:
```text
http://localhost:5173/userscripts/api-media-player-open-in-mpv.user.js
http://127.0.0.1:4173/userscripts/api-media-player-open-in-mpv.user.js
```
What it does:
- On desktop, direct media playback is redirected to `mpv-handler://...`
- On Android, direct media playback is redirected to the mpv app via `intent://...`
- It only activates by default on `localhost`, loopback, RFC1918 LAN IPs, and `.local` / `.lan` hosts so it does not hijack unrelated public websites
Notes:
- Desktop requires `mpv-handler` to be installed and registered.
- Android requires `mpv-android` (`is.xyz.mpv`) to be installed.
- The script intercepts direct file URLs and Hydrus `/get_files/file` playback requests before the browser player starts.
### Desktop setup for mpv-handler
Official project:
```text
https://github.com/akiirui/mpv-handler
```
Latest releases:
```text
https://github.com/akiirui/mpv-handler/releases
```
Windows:
1. Install `mpv` itself first if you do not already have it.
2. Download the latest Windows archive:
```text
https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip
```
3. Extract it somewhere permanent.
4. Edit `config.toml` in that folder and set the path to your `mpv` executable. If you use `yt-dlp`, set that path too.
5. Register the protocol with either the upstream batch file or the PowerShell installer in this repo.
Upstream batch option:
```powershell
Set-Location C:\path\to\mpv-handler
.\handler-install.bat
```
PowerShell alternative from this repo:
```powershell
Set-Location C:\Forgejo\API-MediaPlayer
powershell -ExecutionPolicy Bypass -File .\scripts\install-mpv-handler.ps1 -InstallRoot 'C:\path\to\mpv-handler'
```
Or from an already elevated PowerShell window:
```powershell
& 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1' -InstallRoot 'C:\path\to\mpv-handler'
```
If you copied `config.toml`, `mpv-handler.exe`, and `mpv-handler-debug.exe` into the same folder as [scripts/install-mpv-handler.ps1](scripts/install-mpv-handler.ps1), you can also run it without `-InstallRoot`:
```powershell
& 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1'
```
What the PowerShell installer does:
- validates that `config.toml`, `mpv-handler.exe`, and `mpv-handler-debug.exe` exist
- removes old `mpv://` and existing `mpv-handler://` protocol keys unless you tell it not to
- registers `mpv-handler://` and `mpv-handler-debug://` in the Windows registry
- uses the `mpv` path from `config.toml` as the icon when possible, otherwise falls back to `mpv-handler.exe`
Requirements for the PowerShell installer:
- run it from an elevated PowerShell window
- point `-InstallRoot` at the extracted `mpv-handler` folder
- do not dot-source it from the repo root without `-InstallRoot`, because this repo does not contain the extracted `mpv-handler` binaries
Linux:
1. Download the latest Linux archive:
```text
https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip
```
2. Extract it.
3. Copy `mpv-handler` to `$HOME/.local/bin`.
4. Copy `mpv-handler.desktop` and `mpv-handler-debug.desktop` to `$HOME/.local/share/applications/`.
5. Make the binary executable:
```text
chmod +x $HOME/.local/bin/mpv-handler
```
6. Register the protocol handlers:
```text
xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler
xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug
```
7. Add `$HOME/.local/bin` to `PATH` if needed.
8. Optionally copy and edit `config.toml` for your `mpv` and `yt-dlp` paths.
After setup, clicking an `mpv-handler://...` link in the browser should launch mpv instead of showing an unknown protocol error.
## Next steps
- Wire the UI to a real Hydrus instance (update `src/api/hydrusClient.ts`).
- Add Capacitor for native packaging and secure storage for API keys.
- Improve Hydrus browsing and filtering UX for large libraries.
If you want, I can run `npm install` and start the dev server now and confirm the app is reachable locally. Let me know and I'll proceed.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<title>API Media Player</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2465
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "api-mediaplayer",
"version": "0.1.0",
"private": true,
"description": "Web-first PWA media player that integrates with Hydrus",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.0.0",
"prettier": "^2.8.8",
"typescript": "^5.3.0",
"vite": "^5.1.0"
}
}

12
public/manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "API Media Player",
"short_name": "MediaPlayer",
"start_url": "/",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#6200EE",
"icons": [
{ "src": "no-image.svg", "sizes": "192x192", "type": "image/svg+xml" },
{ "src": "no-image.svg", "sizes": "512x512", "type": "image/svg+xml" }
]
}

4
public/no-image.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="140">
<rect fill="#eee" width="100%" height="100%"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="Arial, sans-serif" font-size="20">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 268 B

169
public/sw.js Normal file
View File

@@ -0,0 +1,169 @@
const CACHE_NAME = 'api-mediaplayer-v1'
const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v2'
const ASSETS = ['/', '/index.html', '/manifest.json', '/no-image.svg']
const MAX_MEDIA_CACHE_ITEMS = 12
function getRangeBounds(rangeHeader, size) {
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader || '')
if (!match) return null
let start = match[1] ? Number(match[1]) : NaN
let end = match[2] ? Number(match[2]) : NaN
if (Number.isNaN(start) && Number.isNaN(end)) return null
if (Number.isNaN(start)) {
const suffixLength = end
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null
start = Math.max(size - suffixLength, 0)
end = size - 1
} else {
if (!Number.isFinite(start) || start < 0 || start >= size) return null
if (Number.isNaN(end) || end >= size) end = size - 1
}
if (end < start) return null
return { start, end }
}
async function trimMediaCache(cache) {
const keys = await cache.keys()
if (keys.length <= MAX_MEDIA_CACHE_ITEMS) return
const overflow = keys.length - MAX_MEDIA_CACHE_ITEMS
for (let index = 0; index < overflow; index += 1) {
await cache.delete(keys[index])
}
}
async function createRangeResponse(cachedResponse, rangeHeader) {
const blob = await cachedResponse.blob()
const size = blob.size
const bounds = getRangeBounds(rangeHeader, size)
if (!bounds) {
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${size}` }
})
}
const { start, end } = bounds
const slice = blob.slice(start, end + 1)
const headers = new Headers(cachedResponse.headers)
headers.set('Accept-Ranges', 'bytes')
headers.set('Content-Length', String(end - start + 1))
headers.set('Content-Range', `bytes ${start}-${end}/${size}`)
return new Response(slice, {
status: 206,
statusText: 'Partial Content',
headers,
})
}
async function handleMediaRequest(event) {
const mediaCache = await caches.open(MEDIA_CACHE_NAME)
const cacheKey = event.request.url
const rangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range')
const cached = await mediaCache.match(cacheKey).catch(() => null)
if (cached) {
if (rangeHeader) return createRangeResponse(cached, rangeHeader)
return cached
}
const networkResponse = await fetch(event.request)
if (!rangeHeader && networkResponse.ok && networkResponse.status === 200) {
event.waitUntil(
mediaCache.put(cacheKey, networkResponse.clone()).then(() => trimMediaCache(mediaCache)).catch(() => undefined)
)
}
return networkResponse
}
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS)
}).then(() => self.skipWaiting())
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.map((k) => (k !== CACHE_NAME && k !== MEDIA_CACHE_NAME ? caches.delete(k) : Promise.resolve())))).then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
const url = event.request.url || ''
const acceptHeader = (event.request.headers && event.request.headers.get && event.request.headers.get('accept')) || ''
const destination = event.request.destination || ''
const hasRangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range')
const isNativeVideoBypass = /(?:\?|&)_native_video=1(?:&|$)/i.test(url)
const isVideoRequest = destination === 'video' || /video\//i.test(acceptHeader)
const isAudioRequest = destination === 'audio' || /audio\//i.test(acceptHeader)
const isMediaRequest = isVideoRequest
|| isAudioRequest
|| /\/get_files\/(file|thumbnail)\?/i.test(url)
|| /\.(m3u8|mp4|webm|ogg|mov)(\?|$)/i.test(url)
if (isNativeVideoBypass || isVideoRequest) {
event.respondWith(fetch(event.request))
return
}
if (isMediaRequest || hasRangeHeader) {
event.respondWith(handleMediaRequest(event))
return
}
event.respondWith((async () => {
try {
const cached = await caches.match(event.request).catch(() => null)
if (cached) return cached
try {
const response = await fetch(event.request)
return response
} catch (networkErr) {
// On network failure, provide a graceful fallback for images and navigation
try {
const dest = event.request.destination || ''
const url = event.request.url || ''
// Image fallback
if (dest === 'image' || /\.(png|jpe?g|gif|svg|webp)(\?|$)/i.test(url)) {
const fallback = await caches.match('/no-image.svg').catch(() => null)
if (fallback) return fallback
return new Response("<svg xmlns='http://www.w3.org/2000/svg' width='300' height='140'><rect fill='%23eee' width='100%' height='100%'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='20'>No Image</text></svg>", { headers: { 'Content-Type': 'image/svg+xml' } })
}
// Navigation fallback (HTML)
if (event.request.mode === 'navigate' || (event.request.headers && event.request.headers.get && event.request.headers.get('accept') && event.request.headers.get('accept').includes('text/html'))) {
const index = await caches.match('/index.html').catch(() => null)
if (index) return index
}
} catch (e) {
console.error('SW network error fallback failed', e)
}
return new Response('Network error', { status: 502, statusText: 'Bad Gateway' })
}
} catch (e) {
console.error('SW fetch handler error', e)
return new Response('SW internal error', { status: 500 })
}
})())
})
// Support skip waiting via message
self.addEventListener('message', (event) => {
if (event && event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})

View File

@@ -0,0 +1,157 @@
// ==UserScript==
// @name API Media Player: Open in mpv
// @namespace https://local.api-media-player
// @version 0.1.0
// @description Redirect direct media playback from this app into mpv instead of playing inside the browser.
// @match *://*/*
// @run-at document-start
// @inject-into page
// @grant none
// ==/UserScript==
(() => {
const RECENT_OPEN_WINDOW_MS = 1500
const DIRECT_MEDIA_EXTENSION = /\.(mp4|m4v|mkv|webm|mov|mp3|m4a|flac|ogg|opus|wav|aac)(?:$|[?#])/i
const PRIVATE_IPV4_HOST = /^(?:10\.|127\.|192\.168\.|172\.(?:1[6-9]|2\d|3[0-1])\.)/
const LOCAL_HOST_SUFFIX = /(?:\.local|\.lan)$/i
let lastOpenedUrl = ''
let lastOpenedAt = 0
function isAllowedHost(hostname) {
return hostname === 'localhost'
|| hostname === '[::1]'
|| PRIVATE_IPV4_HOST.test(hostname)
|| LOCAL_HOST_SUFFIX.test(hostname)
}
function getMediaUrlFromElement(element) {
if (!element) return ''
return element.currentSrc
|| element.src
|| element.getAttribute('src')
|| element.querySelector('source[src]')?.getAttribute('src')
|| ''
}
function isDirectMediaUrl(rawUrl) {
if (!rawUrl) return false
try {
const url = new URL(rawUrl, location.href)
if (!/^https?:$/i.test(url.protocol)) return false
return DIRECT_MEDIA_EXTENSION.test(url.pathname)
|| /\/get_files\/file$/i.test(url.pathname)
|| url.searchParams.has('file_id')
} catch {
return false
}
}
function encodeUrlSafeBase64(value) {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '')
}
function looksLikeVideo(url, element) {
if (element?.tagName === 'VIDEO') return true
try {
const pathname = new URL(url, location.href).pathname
return /\.(mp4|m4v|mkv|webm|mov)$/i.test(pathname)
} catch {
return false
}
}
function buildDesktopMpvUrl(url, title) {
const encodedUrl = encodeUrlSafeBase64(url)
const encodedTitle = title ? encodeUrlSafeBase64(title) : ''
const query = encodedTitle ? `?v_title=${encodedTitle}` : ''
return `mpv-handler://play/${encodedUrl}/${query}`
}
function buildAndroidMpvUrl(url, element) {
try {
const parsed = new URL(url, location.href)
const scheme = parsed.protocol.replace(':', '') || 'https'
const intentPath = `${parsed.host}${parsed.pathname}${parsed.search}`
const mimeType = looksLikeVideo(url, element) ? 'video/*' : 'audio/*'
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end`
} catch {
return url
}
}
function buildExternalPlayerUrl(url, title, element) {
if (/Android/i.test(navigator.userAgent || '')) {
return buildAndroidMpvUrl(url, element)
}
return buildDesktopMpvUrl(url, title)
}
function openInExternalPlayer(url, element) {
const now = Date.now()
if (url === lastOpenedUrl && now - lastOpenedAt < RECENT_OPEN_WINDOW_MS) {
return
}
lastOpenedUrl = url
lastOpenedAt = now
const title = document.title || ''
const targetUrl = buildExternalPlayerUrl(url, title, element)
location.assign(targetUrl)
}
function shouldInterceptCurrentPage() {
return isAllowedHost(location.hostname)
}
function rejectPlaybackRedirect() {
return Promise.reject(new DOMException('Playback redirected to external player', 'AbortError'))
}
function interceptElementPlayback(element) {
if (!shouldInterceptCurrentPage()) return false
const mediaUrl = getMediaUrlFromElement(element)
if (!isDirectMediaUrl(mediaUrl)) return false
try { element.pause() } catch {}
try { element.removeAttribute('src') } catch {}
try { element.load() } catch {}
openInExternalPlayer(mediaUrl, element)
return true
}
const originalPlay = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function play(...args) {
if (interceptElementPlayback(this)) {
return rejectPlaybackRedirect()
}
return originalPlay.apply(this, args)
}
document.addEventListener('click', (event) => {
if (!shouldInterceptCurrentPage()) return
const target = event.target instanceof Element ? event.target.closest('a[href]') : null
if (!target) return
const href = target.getAttribute('href') || ''
if (!isDirectMediaUrl(href)) return
event.preventDefault()
event.stopPropagation()
openInExternalPlayer(href)
}, true)
})()

155
scripts/README.md Normal file
View File

@@ -0,0 +1,155 @@
[English][readme-en] | [简体中文][readme-zh-hans] | [繁体中文][readme-zh-hant]
[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md
[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md
[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md
# mpv handler
A protocol handler for **mpv**, written by Rust.
Use **mpv** and **yt-dlp** to play video and music from the websites.
Please use it with userscript:
[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv]
## Breaking changes
### [v0.4.0][v0.4.0]
To avoid conflicts with the `mpv://` protocol provided by mpv.
> mpv://...
>
> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way.
Scheme `mpv://` and `mpv-debug://` are deprecated, use `mpv-handler://` and `mpv-handler-debug://`.
**Require manual intervention**
#### Windows
Run `handler-uninstall.bat` to uninstall deprecated protocol, and run `handler-install.bat` to install new procotol.
#### Linux
If you installed manually, please repeat the manual installation process.
## Protocol
![](share/proto.png)
### Scheme
- `mpv-handler`: Run mpv-handler without console window
- `mpv-handler-debug`: Run mpv-handler with console window to view outputs and errors
### Plugins
- `play`: Use mpv player to play video
### Encoded Data
Use [URL-safe base64][rfc-base64-url] to encode the URL or TITLE.
Replace `/` to `_`, `+` to `-` and remove padding `=`.
Example (JavaScript):
```javascript
let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU");
let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
```
### Parameters (Optional)
```
cookies = [ www.domain.com.txt ]
profile = [ default, low-latency, etc... ]
quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ]
v_codec = [ av01, vp9, h265, h264 ]
v_title = [ Encoded Title ]
subfile = [ Encoded URL ]
startat = [ Seconds (float) ]
referrer = [ Encoded URL ]
```
## Installation
### Linux
#### Arch Linux
[![mpv-handler][badges-aur]][download-aur]
[![mpv-handler-git][badges-aur-git]][download-aur-git]
#### Manual installation
1. Download [latest Linux release][download-linux]
2. Unzip the archive
3. Copy `mpv-handler` to `$HOME/.local/bin`
4. Copy `mpv-handler.desktop` to `$HOME/.local/share/applications/`
5. Copy `mpv-handler-debug.desktop` to `$HOME/.local/share/applications/`
6. Set executable permission for binary
- ```
$ chmod +x $HOME/.local/bin/mpv-handler
```
7. Register xdg-mime (thanks for the [linuxuprising][linuxuprising] reminder)
- ```
$ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler
$ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug
```
8. Add `$HOME/.local/bin` to your environment variable `PATH`
9. **Optional**: _Copy `config.toml` to `$HOME/.config/mpv-handler/config.toml` and configure_
### Windows
Windows users need to install manually.
#### Manual installation
1. Download [latest Windows release][download-windows]
2. Unzip the archive to the directory you want
3. Run `handler-install.bat` to register protocol handler
4. Edit `config.toml` and set `mpv` and `ytdl` path
## Configuration
```toml
mpv = "/usr/bin/mpv"
# Optional, Type: String
# The path of mpv executable binary
# Default value:
# - Linux: mpv
# - Windows: mpv.com
ytdl = "/usr/bin/yt-dlp"
# Optional, Type: String
# The path of yt-dlp executable binary
proxy = "http://example.com:8080"
# Optional, Type: String
# HTTP(S) proxy server address
# For Windows users:
# - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe"
# - The path target is an executable binary file, not a directory
```
[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0
[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5
[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git
[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler
[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv
[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/
[download-aur]: https://aur.archlinux.org/packages/mpv-handler/
[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip
[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip
[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip
[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv
[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html

155
scripts/README.zh-Hans.md Normal file
View File

@@ -0,0 +1,155 @@
[English][readme-en] | [简体中文][readme-zh-hans] | [繁体中文][readme-zh-hant]
[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md
[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md
[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md
# mpv handler
一个 **mpv** 的协议处理程序,使用 Rust 编写。
使用 **mpv****yt-dlp** 播放网站上的视频与音乐。
请配合用户脚本使用:
[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv]
## 重大变更
### [v0.4.0][v0.4.0]
为了避免与 mpv 所提供的 `mpv://` 协议冲突。
> mpv://...
>
> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way.
协议 `mpv://``mpv-debug://` 已弃用, 请使用 `mpv-handler://``mpv-handler-debug://`.
**需要手动干预**
#### Windows
运行 `handler-uninstall.bat` 卸载已弃用的协议, 然后运行 `handler-install.bat` 安装新的协议.
#### Linux
如果你是手动安装的,请重新执行一遍手动安装流程。
## 协议
![](share/proto.png)
### 协议名
- `mpv-handler`: 在没有命令行窗口的情况下运行 mpv-handler
- `mpv-handler-debug`: 在有命令行窗口的情况下运行 mpv-handler 以便于查看输出和错误
### 插件 / Plugins
- `play`: 使用 mpv 播放视频
### 编码数据 / Encoded Data
使用 [URL 安全的 base64][rfc-base64-url] 编码网址或标题。
替换 `/``_`, `+``-` 并且删除填充的 `=`
示例 (JavaScript):
```javascript
let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU");
let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
```
### 参数 / Parameters (可选)
```
cookies = [ www.domain.com.txt ]
profile = [ default, low-latency, etc... ]
quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ]
v_codec = [ av01, vp9, h265, h264 ]
v_title = [ Encoded Title ]
subfile = [ Encoded URL ]
startat = [ Seconds (float) ]
referrer = [ Encoded URL ]
```
## 安装
### Linux
#### Arch Linux
[![mpv-handler][badges-aur]][download-aur]
[![mpv-handler-git][badges-aur-git]][download-aur-git]
#### 手动安装
1. 下载 [最新的 Linux 压缩包][download-linux]
2. 解压缩压缩包
3. 复制 `mpv-handler``$HOME/.local/bin`
4. 复制 `mpv-handler.desktop``$HOME/.local/share/applications/`
5. 复制 `mpv-handler-debug.desktop``$HOME/.local/share/applications/`
6. 为二进制文件设置可执行权限
- ```
$ chmod +x $HOME/.local/bin/mpv-handler
```
7. 注册 xdg-mime感谢 [linuxuprising][linuxuprising] 的提醒)
- ```
$ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler
$ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug
```
8. 添加 `$HOME/.local/bin` 到环境变量 `PATH`
9. **可选**: _复制 `config.toml` 至 `$HOME/.config/mpv-handler/config.toml` 并配置_
### Windows
Windows 用户目前只能手动安装。
#### 手动安装
1. 下载 [最新的 Windows 压缩包][download-windows]
2. 解压缩档案到你想要的位置
3. 运行 `handler-install.bat` 注册协议处理程序
4. 编辑 `config.toml` 设置 `mpv` 和 `ytdl` 的路径
## 配置
```toml
mpv = "/usr/bin/mpv"
# 可选,类型:字符串
# mpv 可执行文件的路径
# 默认值:
# - Linux: mpv
# - Windows: mpv.com
ytdl = "/usr/bin/yt-dlp"
# 可选,类型:字符串
# yt-dlp 可执行文件的路径
proxy = "http://example.com:8080"
# 可选,类型:字符串
# HTTP(S) 代理服务器的地址
# 对于 Windows 用户:
# - 路径格式可以是 "C:\\folder\\some.exe",也可以是 "C:/folder/some.exe"
# - 路径的目标是可执行二进制文件,而不是目录
```
[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0
[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5
[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git
[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler
[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv
[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/
[download-aur]: https://aur.archlinux.org/packages/mpv-handler/
[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip
[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip
[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip
[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv
[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html

155
scripts/README.zh-Hant.md Normal file
View File

@@ -0,0 +1,155 @@
[English][readme-en] | [簡體中文][readme-zh-hans] | [繁體中文][readme-zh-hant]
[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md
[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md
[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md
# mpv handler
一個 **mpv** 的協議處理程序,使用 Rust 編寫。
使用 **mpv****yt-dlp** 播放網站上的視頻與音樂。
請配合用戶腳本使用:
[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv]
## 重大變更
### [v0.4.0][v0.4.0]
爲了避免與 mpv 所提供的 `mpv://` 協議衝突。
> mpv://...
>
> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way.
協議 `mpv://``mpv-debug://` 已棄用, 請使用 `mpv-handler://``mpv-handler-debug://`.
**需要手動干預**
#### Windows
運行 `handler-uninstall.bat` 卸載已棄用的協議, 然後運行 `handler-install.bat` 安裝新的協議.
#### Linux
如果你是手動安裝的,請重新執行一遍手動安裝流程。
## 協議
![](share/proto.png)
### 協議名
- `mpv-handler`: 在沒有命令行窗口的情況下運行 mpv-handler
- `mpv-handler-debug`: 在有命令行窗口的情況下運行 mpv-handler 以便於查看輸出和錯誤
### 插件 / Plugins
- `play`: 使用 mpv 播放視頻
### 編碼數據 / Encoded Data
使用 [URL 安全的 base64][rfc-base64-url] 編碼網址或標題。
替換 `/``_`, `+``-` 並且刪除填充的 `=`
示例 (JavaScript):
```javascript
let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU");
let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, "");
```
### 參數 / Parameters (可選)
```
cookies = [ www.domain.com.txt ]
profile = [ default, low-latency, etc... ]
quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ]
v_codec = [ av01, vp9, h265, h264 ]
v_title = [ Encoded Title ]
subfile = [ Encoded URL ]
startat = [ Seconds (float) ]
referrer = [ Encoded URL ]
```
## 安裝
### Linux
#### Arch Linux
[![mpv-handler][badges-aur]][download-aur]
[![mpv-handler-git][badges-aur-git]][download-aur-git]
#### 手動安裝
1. 下載 [最新的 Linux 壓縮包][download-linux]
2. 解壓縮壓縮包
3. 複製 `mpv-handler``$HOME/.local/bin`
4. 複製 `mpv-handler.desktop``$HOME/.local/share/applications/`
5. 複製 `mpv-handler-debug.desktop``$HOME/.local/share/applications/`
6. 爲二進制文件設置可執行權限
- ```
$ chmod +x $HOME/.local/bin/mpv-handler
```
7. 註冊 xdg-mime感謝 [linuxuprising][linuxuprising] 的提醒)
- ```
$ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler
$ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug
```
8. 添加 `$HOME/.local/bin` 到環境變量 `PATH`
9. **可選**: _複製 `config.toml` 至 `$HOME/.config/mpv-handler/config.toml` 並配置_
### Windows
Windows 用戶目前只能手動安裝。
#### 手動安裝
1. 下載 [最新的 Windows 壓縮包][download-windows]
2. 解壓縮檔案到你想要的位置
3. 運行 `handler-install.bat` 註冊協議處理程序
4. 編輯 `config.toml` 設置 `mpv` 和 `ytdl` 的路徑
## 配置
```toml
mpv = "/usr/bin/mpv"
# 可選,類型:字符串
# mpv 可執行文件的路徑
# 默認值:
# - Linux: mpv
# - Windows: mpv.com
ytdl = "/usr/bin/yt-dlp"
# 可選,類型:字符串
# yt-dlp 可執行文件的路徑
proxy = "http://example.com:8080"
# 可選,類型:字符串
# HTTP(S) 代理服務器的地址
# 對於 Windows 用戶:
# - 路徑格式可以是 "C:\\folder\\some.exe",也可以是 "C:/folder/some.exe"
# - 路徑的目標是可執行二進制文件,而不是目錄
```
[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0
[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5
[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git
[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler
[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv
[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/
[download-aur]: https://aur.archlinux.org/packages/mpv-handler/
[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip
[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip
[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip
[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv
[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html

18
scripts/config.toml Normal file
View File

@@ -0,0 +1,18 @@
#mpv = "C:\\path\\of\\mpv.com"
# Optional, Type: String
# The path of mpv executable binary
# Default value:
# - Linux: mpv
# - Windows: mpv.com
#ytdl = "C:\\path\\of\\yt-dlp.exe"
# Optional, Type: String
# The path of yt-dlp executable binary
#proxy = "http://example.com:8080"
# Optional, Type: String
# HTTP(S) proxy server address
# For Windows users:
# - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe"
# - The path is an executable binary file, not a directory

View File

@@ -0,0 +1,82 @@
@echo OFF
:: Unattended install flag. When set, the script will not require user input.
set unattended=no
if "%1"=="/u" set unattended=yes
:: Make sure this is Windows Vista or later
call :ensure_vista
:: Make sure the script is running as admin
call :ensure_admin
:: Get mpv.exe location
call :check_binary
:: Add registry
call :add_verbs
:die
if not [%1] == [] echo %~1
if [%unattended%] == [yes] exit 1
pause
exit 1
:ensure_admin
:: 'openfiles' is just a commmand that is present on all supported Windows
:: versions, requires admin privileges and has no side effects, see:
:: https://stackoverflow.com/questions/4051883/batch-script-how-to-check-for-admin-rights
openfiles >nul 2>&1
if errorlevel 1 (
echo This batch script requires administrator privileges.
echo Right-click on handler-install.bat and select "Run as administrator".
call :die
)
goto :EOF
:ensure_vista
ver | find "XP" >nul
if not errorlevel 1 (
echo This batch script only works on Windows Vista and later. To create file
echo associations on Windows XP, right click on a video file and use "Open with...".
call :die
)
goto :EOF
:check_binary
cd /D %~dp0
set mpv_handler_conf=%cd%\config.toml
set mpv_handler_path=%cd%\mpv-handler.exe
set mpv_handler_debug_path=%cd%\mpv-handler-debug.exe
if not exist "%mpv_handler_conf%" call :die "Not found config.toml"
if not exist "%mpv_handler_path%" call :die "Not found mpv-handler.exe"
if not exist "%mpv_handler_debug_path%" call :die "Not found mpv-handler-debug.exe"
goto :EOF
:reg
:: Wrap the reg command to check for errors
>nul reg %*
if errorlevel 1 set error=yes
if [%error%] == [yes] echo Error in command: reg %*
if [%error%] == [yes] call :die
goto :EOF
:add_verbs
:: Add the mpv protocol to the registry
call :reg add "HKCR\mpv-handler" /d "URL:MPV Handler" /f
call :reg add "HKCR\mpv-handler" /v "Content Type" /d "application/x-mpv-handler" /f
call :reg add "HKCR\mpv-handler" /v "URL Protocol" /f
call :reg add "HKCR\mpv-handler\DefaultIcon" /d "\"%mpv_exe_path%\",1" /f
call :reg add "HKCR\mpv-handler\shell\open\command" /d "\"%mpv_handler_path%\" \"%%%%1\"" /f
:: Add the mpv protocol to the registry
call :reg add "HKCR\mpv-handler-debug" /d "URL:MPV Handler Debug" /f
call :reg add "HKCR\mpv-handler-debug" /v "Content Type" /d "application/x-mpv-handler-debug" /f
call :reg add "HKCR\mpv-handler-debug" /v "URL Protocol" /f
call :reg add "HKCR\mpv-handler-debug\DefaultIcon" /d "\"%mpv_exe_path%\",1" /f
call :reg add "HKCR\mpv-handler-debug\shell\open\command" /d "\"%mpv_handler_debug_path%\" \"%%%%1\"" /f
echo Successfully installed mpv-handler
echo Enjoy!
goto :EOF

View File

@@ -0,0 +1,64 @@
@echo OFF
:: Unattended install flag. When set, the script will not require user input.
set unattended=no
if "%1"=="/u" set unattended=yes
:: Make sure this is Windows Vista or later
call :ensure_vista
:: Make sure the script is running as admin
call :ensure_admin
:: Delete registry
call :del_verbs
:die
if not [%1] == [] echo %~1
if [%unattended%] == [yes] exit 1
pause
exit 1
:ensure_admin
:: 'openfiles' is just a commmand that is present on all supported Windows
:: versions, requires admin privileges and has no side effects, see:
:: https://stackoverflow.com/questions/4051883/batch-script-how-to-check-for-admin-rights
openfiles >nul 2>&1
if errorlevel 1 (
echo This batch script requires administrator privileges.
echo Right-click on handler-uninstall.bat and select "Run as administrator".
call :die
)
goto :EOF
:ensure_vista
ver | find "XP" >nul
if not errorlevel 1 (
echo This batch script only works on Windows Vista and later. To create file
echo associations on Windows XP, right click on a video file and use "Open with...".
call :die
)
goto :EOF
:reg
:: Wrap the reg command to check for errors
>nul reg %*
if errorlevel 1 set error=yes
if [%error%] == [yes] echo Error in command: reg %*
if [%error%] == [yes] call :die
goto :EOF
:del_verbs
:: Delete deprecated mpv and mpv-debug protocol
call :reg delete "HKCR\mpv" /f
call :reg delete "HKCR\mpv-debug" /f
:: Delete protocol
call :reg delete "HKCR\mpv-handler" /f
call :reg delete "HKCR\mpv-handler-debug" /f
echo Successfully uninstalled mpv-handler
goto :EOF

View File

@@ -0,0 +1,229 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$InstallRoot,
[string]$IconPath,
[switch]$KeepExistingProtocolKeys
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-ExistingFile {
param(
[Parameter(Mandatory = $true)]
[string]$BasePath,
[Parameter(Mandatory = $true)]
[string]$FileName
)
$resolvedPath = Join-Path -Path $BasePath -ChildPath $FileName
if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
throw "Required file not found: $resolvedPath`nInstallRoot must point at the extracted mpv-handler folder that contains config.toml, mpv-handler.exe, and mpv-handler-debug.exe."
}
return (Resolve-Path -LiteralPath $resolvedPath).Path
}
function Get-UsageExample {
return @"
Example usage:
powershell -ExecutionPolicy Bypass -File .\scripts\install-mpv-handler.ps1 -InstallRoot 'C:\path\to\mpv-handler'
Or from an elevated PowerShell session:
& 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1' -InstallRoot 'C:\path\to\mpv-handler'
If config.toml, mpv-handler.exe, and mpv-handler-debug.exe are in the same folder as this script,
you can run it without -InstallRoot.
"@
}
function Test-InstallRootContents {
param(
[string]$CandidatePath
)
if (-not $CandidatePath) {
return $false
}
if (-not (Test-Path -LiteralPath $CandidatePath -PathType Container)) {
return $false
}
return (
(Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'config.toml') -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'mpv-handler.exe') -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'mpv-handler-debug.exe') -PathType Leaf)
)
}
function Get-DefaultInstallRoot {
if (Test-InstallRootContents -CandidatePath $PSScriptRoot) {
return (Resolve-Path -LiteralPath $PSScriptRoot).Path
}
return $null
}
function Assert-InstallRoot {
param(
[string]$CandidatePath
)
if (-not $CandidatePath) {
$defaultInstallRoot = Get-DefaultInstallRoot
if ($defaultInstallRoot) {
return $defaultInstallRoot
}
throw "InstallRoot is required.`n$(Get-UsageExample)"
}
if (-not (Test-Path -LiteralPath $CandidatePath -PathType Container)) {
throw "InstallRoot does not exist or is not a folder: $CandidatePath`n$(Get-UsageExample)"
}
$resolved = (Resolve-Path -LiteralPath $CandidatePath).Path
if (Test-Path -LiteralPath (Join-Path -Path $resolved -ChildPath 'package.json') -PathType Leaf) {
throw "InstallRoot appears to be this repo, not the extracted mpv-handler folder: $resolved`n$(Get-UsageExample)"
}
return $resolved
}
function Get-MpvPathFromConfig {
param(
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) {
return $null
}
$raw = Get-Content -LiteralPath $ConfigPath -Raw
$match = [regex]::Match($raw, '(?m)^\s*mpv\s*=\s*"(?<path>[^"]+)"\s*$')
if (-not $match.Success) {
return $null
}
$candidate = $match.Groups['path'].Value.Trim()
if (-not $candidate) {
return $null
}
if ([System.IO.Path]::IsPathRooted($candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
return (Resolve-Path -LiteralPath $candidate).Path
}
try {
$command = Get-Command -Name $candidate -ErrorAction Stop
return $command.Source
} catch {
return $null
}
}
function Remove-ProtocolKey {
param(
[Parameter(Mandatory = $true)]
[string]$SchemeName
)
$classesRoot = [Microsoft.Win32.Registry]::ClassesRoot
try {
$classesRoot.DeleteSubKeyTree($SchemeName, $false)
} catch {
}
}
function Register-Protocol {
param(
[Parameter(Mandatory = $true)]
[string]$SchemeName,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$ContentType,
[Parameter(Mandatory = $true)]
[string]$HandlerExecutable,
[Parameter(Mandatory = $true)]
[string]$EffectiveIconPath
)
$classesRoot = [Microsoft.Win32.Registry]::ClassesRoot
$schemeKey = $classesRoot.CreateSubKey($SchemeName)
if (-not $schemeKey) {
throw "Failed to create registry key for $SchemeName"
}
try {
$schemeKey.SetValue('', $Description, [Microsoft.Win32.RegistryValueKind]::String)
$schemeKey.SetValue('Content Type', $ContentType, [Microsoft.Win32.RegistryValueKind]::String)
$schemeKey.SetValue('URL Protocol', '', [Microsoft.Win32.RegistryValueKind]::String)
$defaultIconKey = $schemeKey.CreateSubKey('DefaultIcon')
try {
$defaultIconKey.SetValue('', ('"{0}",1' -f $EffectiveIconPath), [Microsoft.Win32.RegistryValueKind]::String)
} finally {
$defaultIconKey.Dispose()
}
$commandKey = $schemeKey.CreateSubKey('shell\open\command')
try {
$commandKey.SetValue('', ('"{0}" "%1"' -f $HandlerExecutable), [Microsoft.Win32.RegistryValueKind]::String)
} finally {
$commandKey.Dispose()
}
} finally {
$schemeKey.Dispose()
}
}
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw 'This installer is only for Windows.'
}
$installRootPath = Assert-InstallRoot -CandidatePath $InstallRoot
$configPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'config.toml'
$handlerPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'mpv-handler.exe'
$handlerDebugPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'mpv-handler-debug.exe'
$effectiveIconPath = if ($IconPath) {
if (-not (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
throw "IconPath does not exist: $IconPath"
}
(Resolve-Path -LiteralPath $IconPath).Path
} else {
Get-MpvPathFromConfig -ConfigPath $configPath
}
if (-not $effectiveIconPath) {
$effectiveIconPath = $handlerPath
}
if (-not $KeepExistingProtocolKeys) {
Remove-ProtocolKey -SchemeName 'mpv'
Remove-ProtocolKey -SchemeName 'mpv-debug'
Remove-ProtocolKey -SchemeName 'mpv-handler'
Remove-ProtocolKey -SchemeName 'mpv-handler-debug'
}
Register-Protocol -SchemeName 'mpv-handler' -Description 'URL:MPV Handler' -ContentType 'application/x-mpv-handler' -HandlerExecutable $handlerPath -EffectiveIconPath $effectiveIconPath
Register-Protocol -SchemeName 'mpv-handler-debug' -Description 'URL:MPV Handler Debug' -ContentType 'application/x-mpv-handler-debug' -HandlerExecutable $handlerDebugPath -EffectiveIconPath $effectiveIconPath
Write-Host 'Successfully installed mpv-handler protocol registration.' -ForegroundColor Green
Write-Host ('InstallRoot: {0}' -f $installRootPath)
Write-Host ('Handler: {0}' -f $handlerPath)
Write-Host ('Debug Handler: {0}' -f $handlerDebugPath)
Write-Host ('Icon: {0}' -f $effectiveIconPath)
Write-Host 'You can now test an mpv-handler:// URL from the browser.'

Binary file not shown.

BIN
scripts/mpv-handler.exe Normal file

Binary file not shown.

357
src/App.tsx Normal file
View File

@@ -0,0 +1,357 @@
import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react'
import Library from './pages/Library'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { ServersProvider } from './context/ServersContext'
import { addDevLog } from './debugLog'
import { loadUiPreferences, saveUiPreferences } from './appPreferences'
import type { MediaSection, Track } from './types'
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const DevErrorPanel = lazy(() => import('./components/DevErrorPanel'))
const VIDEO_URL_PATTERN = /\.(m3u8|mp4|webm|ogv|mov|mkv|avi|wmv)$/i
const AUDIO_URL_PATTERN = /\.(mp3|m4a|aac|flac|wav|ogg|opus|oga|wma)$/i
type MediaInfo = {
mimeType?: string
isVideo?: boolean
}
type ExternalPlayerTarget = {
href: string
appName: string
}
function isPlayableMediaTrack(track: Track) {
const mimeType = normalizeMimeForPlaybackCheck(track.mimeType)
if (track.isVideo) return true
if (mimeType.startsWith('audio/') || mimeType.startsWith('video/')) return true
if (mimeType.includes('mpegurl')) return true
if (VIDEO_URL_PATTERN.test(track.url)) return true
if (AUDIO_URL_PATTERN.test(track.url)) return true
return false
}
function normalizeMimeForPlaybackCheck(mimeType?: string) {
return (mimeType || '').trim().toLowerCase()
}
function encodeUrlSafeBase64(value: string) {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '')
}
function buildVlcStreamUrl(track: Track) {
const url = `vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(track.url)}`
const fileName = (track.title || '').trim()
if (!fileName) return url
return `${url}&filename=${encodeURIComponent(fileName)}`
}
function buildAndroidMpvIntentUrl(track: Track) {
try {
const url = new URL(track.url)
const scheme = url.protocol.replace(':', '') || 'https'
const intentPath = `${url.host}${url.pathname}${url.search}`
const mimeType = track.isVideo || normalizeMimeForPlaybackCheck(track.mimeType).startsWith('video/')
? 'video/*'
: normalizeMimeForPlaybackCheck(track.mimeType).startsWith('audio/')
? 'audio/*'
: 'video/any'
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end`
} catch {
return track.url
}
}
function buildDesktopMpvHandlerUrl(track: Track) {
const encodedUrl = encodeUrlSafeBase64(track.url)
const title = (track.title || '').trim()
const query = title ? `?v_title=${encodeUrlSafeBase64(title)}` : ''
return `mpv-handler://play/${encodedUrl}/${query}`
}
function App() {
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
const [activePage, setActivePage] = useState<MediaSection | 'settings'>('audio')
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
const [libraryDisplayMode, setLibraryDisplayMode] = useState(initialUiPreferences.libraryDisplayMode)
const [devOverlayEnabled, setDevOverlayEnabled] = useState(initialUiPreferences.devOverlayEnabled)
const theme = useMemo(() => createTheme({
palette: {
mode: 'dark',
primary: { main: '#1db954' },
background: { default: '#0f1113', paper: '#151617' }
}
}), [])
const mimeCacheRef = useRef<Record<string, MediaInfo>>({})
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
const playRequestAbortRef = useRef<AbortController | null>(null)
const lastBrowsePageRef = useRef<MediaSection>(activePage === 'settings' ? 'audio' : activePage)
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent || '' : ''
const platform = typeof navigator !== 'undefined' ? navigator.platform || '' : ''
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints || 0 : 0
const isIosPhone = /iPhone|iPod/i.test(userAgent)
const isIpad = /iPad/i.test(userAgent) || (/Mac/i.test(platform) && maxTouchPoints > 1)
const isAppleMobileOrTablet = isIosPhone || isIpad
const isAndroidMobileOrTablet = /Android/i.test(userAgent)
const isDesktopLayout = useMediaQuery(theme.breakpoints.up('md'))
useEffect(() => {
return () => {
try { playRequestAbortRef.current?.abort() } catch {}
}
}, [])
useEffect(() => {
if (activePage !== 'settings') {
lastBrowsePageRef.current = activePage
}
}, [activePage])
useEffect(() => {
saveUiPreferences({ libraryQuery })
}, [libraryQuery])
const getPreferredExternalPlayer = useCallback((track: Track): ExternalPlayerTarget => {
if (isAppleMobileOrTablet) {
return {
href: buildVlcStreamUrl(track),
appName: 'VLC',
}
}
if (isAndroidMobileOrTablet) {
return {
href: buildAndroidMpvIntentUrl(track),
appName: 'mpv',
}
}
return {
href: buildDesktopMpvHandlerUrl(track),
appName: 'mpv',
}
}, [isAndroidMobileOrTablet, isAppleMobileOrTablet])
const openInPreferredExternalPlayer = useCallback((track: Track) => {
if (!isPlayableMediaTrack(track)) {
addDevLog({
kind: 'debug',
category: 'playback',
message: 'Opening non-media file in browser tab',
details: {
trackId: track.id,
fileId: track.fileId,
mimeType: track.mimeType,
href: track.url,
}
})
const openedWindow = window.open(track.url, '_blank', 'noopener,noreferrer')
if (!openedWindow) {
window.location.href = track.url
}
return
}
const target = getPreferredExternalPlayer(track)
addDevLog({
kind: 'debug',
category: 'playback',
message: `Opening track in ${target.appName}`,
details: {
trackId: track.id,
fileId: track.fileId,
mimeType: track.mimeType,
href: target.href,
appName: target.appName,
}
})
window.location.href = target.href
}, [getPreferredExternalPlayer])
const fetchMediaInfo = useCallback(async (track: Track, signal?: AbortSignal): Promise<MediaInfo> => {
try {
let res = await fetch(track.url, { method: 'HEAD', mode: 'cors', signal })
if (!res.ok) {
res = await fetch(track.url, { method: 'GET', mode: 'cors', headers: { Range: 'bytes=0-0' }, signal })
}
const mimeType = res.headers.get('content-type') || undefined
const isVideo = !!mimeType && (mimeType.startsWith('video/') || mimeType.includes('mpegurl'))
const mediaInfo = { mimeType, isVideo: isVideo || undefined }
mimeCacheRef.current[track.url] = mediaInfo
return mediaInfo
} catch (error: any) {
if (error?.name === 'AbortError') throw error
return {}
}
}, [])
const resolveMediaInfo = useCallback(async (track: Track, signal?: AbortSignal): Promise<MediaInfo> => {
if (track.mimeType || track.isVideo !== undefined) {
return { mimeType: track.mimeType, isVideo: track.isVideo }
}
const cachedInfo = mimeCacheRef.current[track.url]
if (cachedInfo) return cachedInfo
if (VIDEO_URL_PATTERN.test(track.url)) {
const inferredInfo = { isVideo: true }
mimeCacheRef.current[track.url] = inferredInfo
return inferredInfo
}
if (signal) {
return fetchMediaInfo(track, signal)
}
const pendingRequest = mimeRequestCacheRef.current[track.url]
if (pendingRequest) return pendingRequest
const request = (async () => {
try {
return await fetchMediaInfo(track)
} finally {
delete mimeRequestCacheRef.current[track.url]
}
})()
mimeRequestCacheRef.current[track.url] = request
return request
}, [fetchMediaInfo])
const playNow = useCallback(async (track: Track) => {
try { playRequestAbortRef.current?.abort() } catch {}
const controller = new AbortController()
playRequestAbortRef.current = controller
const cachedInfo = track.mimeType || track.isVideo !== undefined
? { mimeType: track.mimeType, isVideo: track.isVideo }
: mimeCacheRef.current[track.url] || (VIDEO_URL_PATTERN.test(track.url) ? { isVideo: true } : undefined)
let resolved = cachedInfo ? { ...track, ...cachedInfo } : track
if (!cachedInfo) {
try {
const mediaInfo = await resolveMediaInfo(track, controller.signal)
if (controller.signal.aborted) return
resolved = { ...track, ...mediaInfo }
} catch (error: any) {
if (error?.name === 'AbortError') return
addDevLog({
kind: 'debug',
category: 'playback',
message: 'Media info resolution failed',
details: {
trackId: track.id,
fileId: track.fileId,
name: error?.name,
message: error?.message ?? String(error),
}
})
}
}
openInPreferredExternalPlayer(resolved)
}, [openInPreferredExternalPlayer, resolveMediaInfo])
const toggleSidebar = useCallback(() => {
if (isDesktopLayout) {
setDesktopSidebarOpen((open) => !open)
return
}
setMobileSidebarOpen((open) => !open)
}, [isDesktopLayout])
const closeSidebar = useCallback(() => setMobileSidebarOpen(false), [])
const openSettings = useCallback(() => setActivePage('settings'), [])
const closeSettings = useCallback(() => setActivePage(lastBrowsePageRef.current), [])
const handleDevOverlayEnabledChange = useCallback((enabled: boolean) => {
setDevOverlayEnabled(enabled)
saveUiPreferences({ devOverlayEnabled: enabled })
}, [])
const handleLibraryDisplayModeChange = useCallback((mode: 'grid' | 'table') => {
setLibraryDisplayMode(mode)
saveUiPreferences({ libraryDisplayMode: mode })
}, [])
const handleSidebarNavigate = useCallback((id: string) => {
if (id === 'settings') setActivePage('settings')
else setActivePage(id as MediaSection)
if (!isDesktopLayout) {
setMobileSidebarOpen(false)
}
}, [isDesktopLayout])
return (
<ServersProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<Header
onOpenSettings={openSettings}
onToggleSidebar={toggleSidebar}
searchQuery={libraryQuery}
onSearchQueryChange={setLibraryQuery}
searchDisabled={activePage === 'settings'}
/>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<Sidebar
mobileOpen={mobileSidebarOpen}
desktopOpen={desktopSidebarOpen}
onMobileClose={closeSidebar}
onNavigate={handleSidebarNavigate}
activeId={activePage}
/>
<Box sx={{ flex: 1, overflow: 'auto', pb: 2, minWidth: 0 }}>
{activePage === 'settings'
? (
<Suspense fallback={null}>
<SettingsPage
onClose={closeSettings}
devOverlayEnabled={devOverlayEnabled}
onDevOverlayEnabledChange={handleDevOverlayEnabledChange}
libraryDisplayMode={libraryDisplayMode}
onLibraryDisplayModeChange={handleLibraryDisplayModeChange}
/>
</Suspense>
)
: (
<Library
mediaSection={activePage}
onPlayNow={playNow}
query={libraryQuery}
onQueryChange={setLibraryQuery}
displayModePreference={libraryDisplayMode}
/>
)}
</Box>
</Box>
{devOverlayEnabled ? (
<Suspense fallback={null}>
<DevErrorPanel />
</Suspense>
) : null}
</Box>
</ThemeProvider>
</ServersProvider>
)
}
export default App

698
src/api/hydrusClient.ts Normal file
View File

@@ -0,0 +1,698 @@
export type ServerConfig = {
id: string
name?: string
host: string
port?: string | number
apiKey?: string
ssl?: boolean
forceApiKeyInQuery?: boolean
}
export type ConnectivityResult = {
ok: boolean
message: string
status?: number | null
searchOk?: boolean
rangeSupported?: boolean
}
export type HydrusMediaInfo = {
mimeType?: string
isVideo?: boolean
}
export type HydrusFileDetails = HydrusMediaInfo & {
fileId: number
extension?: string
sizeBytes?: number
width?: number
height?: number
durationMs?: number
tags: string[]
}
export function makeId() {
return Date.now().toString() + '-' + Math.random().toString(36).slice(2, 9)
}
export type HydrusSearchTag = string | HydrusSearchTags
export type HydrusSearchTags = HydrusSearchTag[]
const SYSTEM_PREDICATE_PATTERN = /^(system:[^<>!=]+?)\s*(<=|>=|!=|=|<|>)\s*(.+)$/i
const SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
function createAbortError() {
const error = new Error('Aborted')
error.name = 'AbortError'
return error
}
export class HydrusClient {
cfg: ServerConfig
constructor(cfg: Partial<ServerConfig> = {}) {
this.cfg = {
id: (cfg.id as string) || makeId(),
name: cfg.name || '',
host: cfg.host || '',
port: cfg.port,
apiKey: cfg.apiKey,
ssl: !!cfg.ssl,
forceApiKeyInQuery: !!cfg.forceApiKeyInQuery
}
}
baseUrl(): string {
if (!this.cfg.host) throw new Error('Hydrus host not defined')
let url = this.cfg.host.trim().replace(/\/+$/, '')
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = (this.cfg.ssl ? 'https://' : 'http://') + url
}
try {
const parsed = new URL(url)
if (this.cfg.port && !parsed.port) {
parsed.port = String(this.cfg.port)
}
// preserve any configured pathname (e.g., if Hydrus is hosted under /hydrus)
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
return parsed.origin + path
} catch (e) {
// fallback
return url + (this.cfg.port ? `:${this.cfg.port}` : '')
}
}
getHeaders(includeApiKeyInHeader = true, includeContentType = false) {
const headers: Record<string, string> = {}
if (includeContentType) headers['Content-Type'] = 'application/json'
if (this.cfg.apiKey && includeApiKeyInHeader) headers['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey
return headers
}
private appendApiKeyToUrl(url: string) {
if (!this.cfg.apiKey) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}Hydrus-Client-API-Access-Key=${encodeURIComponent(this.cfg.apiKey)}`
}
private buildApiUrl(path: string, params: Record<string, string | number | undefined> = {}, includeApiKeyInQuery = false) {
const url = new URL(`${this.baseUrl()}${path}`)
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue
url.searchParams.set(key, String(value))
}
if (includeApiKeyInQuery && this.cfg.apiKey) {
url.searchParams.set('Hydrus-Client-API-Access-Key', this.cfg.apiKey)
}
return url.toString()
}
private async fetchWithAuthRetry(url: string, init: RequestInit = {}) {
const requestInit: RequestInit = { mode: 'cors', ...init }
let response = await fetch(url, requestInit)
if ((response.status === 401 || response.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) {
response = await fetch(this.appendApiKeyToUrl(url), {
...requestInit,
headers: undefined,
})
}
return response
}
private async getFileMetadataPayload(fileId: number, signal?: AbortSignal) {
const url = this.buildApiUrl('/get_files/file_metadata', { file_id: fileId }, this.cfg.forceApiKeyInQuery ?? false)
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
const res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal })
if (res.status === 404) {
console.warn('[HydrusClient] getFileMetadata 404', { url, status: res.status })
return null
}
if (!res.ok) {
console.warn('[HydrusClient] getFileMetadata Response Error', { status: res.status, statusText: res.statusText })
return null
}
return res.json().catch(() => null)
}
private getFileMetadataEntry(data: any, fileId: number) {
if (!data || typeof data !== 'object') return null
if (data.file_metadata && typeof data.file_metadata === 'object' && !Array.isArray(data.file_metadata)) {
const direct = data.file_metadata[String(fileId)]
if (direct && typeof direct === 'object') return direct
}
if (Array.isArray(data.file_metadata)) {
const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId))
if (found && typeof found === 'object') return found
}
if (String((data as any).file_id) === String(fileId)) return data
return null
}
private normalizeMimeType(value: string): string | undefined {
const normalized = value.trim().toLowerCase()
if (!normalized) return undefined
if (normalized.includes('mpegurl') || normalized.includes('m3u8')) return 'application/vnd.apple.mpegurl'
const directMimeMatch = normalized.match(/(?:video|audio|application)\/[a-z0-9.+-]+/)
if (directMimeMatch) return directMimeMatch[0]
const knownMimeMap: Array<[string, string]> = [
['quicktime', 'video/quicktime'],
['matroska', 'video/x-matroska'],
['mkv', 'video/x-matroska'],
['mp4', normalized.includes('audio') ? 'audio/mp4' : 'video/mp4'],
['webm', normalized.includes('audio') ? 'audio/webm' : 'video/webm'],
['mpeg', normalized.includes('audio') ? 'audio/mpeg' : 'video/mpeg'],
['avi', 'video/x-msvideo'],
['wmv', 'video/x-ms-wmv'],
['mov', 'video/quicktime'],
['ogg', normalized.includes('video') ? 'video/ogg' : 'audio/ogg'],
['mp3', 'audio/mpeg'],
['m4a', 'audio/mp4'],
['aac', 'audio/aac'],
['flac', 'audio/flac'],
['wav', 'audio/wav'],
]
const match = knownMimeMap.find(([token]) => normalized.includes(token))
return match ? match[1] : undefined
}
private extractMediaInfoFromMetadata(data: any, fileId: number): HydrusMediaInfo {
const metadata = this.getFileMetadataEntry(data, fileId) || data
if (!metadata || typeof metadata !== 'object') return {}
let width = 0
let height = 0
let frameCount = 0
let hasDuration = false
const mimeCandidates: string[] = []
const visit = (value: any, keyHint = '') => {
if (value == null) return
if (typeof value === 'string') {
const lowerKey = keyHint.toLowerCase()
if (lowerKey.includes('mime') || lowerKey.includes('filetype') || lowerKey.includes('container') || lowerKey.includes('format')) {
mimeCandidates.push(value)
}
return
}
if (typeof value === 'number') {
const lowerKey = keyHint.toLowerCase()
if (lowerKey === 'width') width = Math.max(width, value)
else if (lowerKey === 'height') height = Math.max(height, value)
else if (lowerKey.includes('frame')) frameCount = Math.max(frameCount, value)
else if (lowerKey.includes('duration')) hasDuration = hasDuration || value > 0
return
}
if (typeof value === 'boolean') {
if (keyHint.toLowerCase().includes('video') && value) mimeCandidates.push('video')
if (keyHint.toLowerCase().includes('audio') && value) mimeCandidates.push('audio')
return
}
if (Array.isArray(value)) {
for (const item of value) visit(item, keyHint)
return
}
if (typeof value === 'object') {
for (const [childKey, childValue] of Object.entries(value)) {
visit(childValue, childKey)
}
}
}
visit(metadata)
for (const candidate of mimeCandidates) {
const mimeType = this.normalizeMimeType(candidate)
if (mimeType) {
return {
mimeType,
isVideo: mimeType.startsWith('video/') || mimeType === 'application/vnd.apple.mpegurl'
}
}
const lower = candidate.toLowerCase()
if (lower.includes('video')) return { isVideo: true }
if (lower.includes('audio')) return { isVideo: false }
}
if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return { isVideo: true }
if (hasDuration) return { isVideo: false }
return {}
}
private extractTagsFromMetadata(data: any, fileId: number): string[] {
if (!data) return []
try {
if (data.file_metadata && typeof data.file_metadata === 'object' && data.file_metadata[String(fileId)]) {
const meta = data.file_metadata[String(fileId)]
if (Array.isArray(meta.tags)) return meta.tags
if (meta.service_keys_to_tags && typeof meta.service_keys_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(meta.service_keys_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
if (meta.service_names_to_tags && typeof meta.service_names_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(meta.service_names_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
}
if (Array.isArray(data.tags)) return data.tags
if (Array.isArray(data.file_tags)) return data.file_tags
if (Array.isArray(data.file_metadata)) {
const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId))
if (found) {
if (Array.isArray(found.tags)) return found.tags
if (found.service_keys_to_tags && typeof found.service_keys_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(found.service_keys_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
}
}
const foundArrays: string[][] = []
const walk = (obj: any) => {
if (!obj || typeof obj !== 'object') return
if (Array.isArray(obj)) {
if (obj.length > 0 && obj.every((item) => typeof item === 'string')) {
foundArrays.push(obj as string[])
return
}
for (const entry of obj) walk(entry)
return
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
if (value.length > 0 && value.every((item) => typeof item === 'string')) foundArrays.push(value as string[])
else for (const entry of value) walk(entry)
} else if (value && typeof value === 'object') {
walk(value)
}
}
}
walk(data)
if (foundArrays.length) {
const flattened = ([] as string[]).concat(...foundArrays)
return Array.from(new Set(flattened))
}
} catch {
// fall through
}
return []
}
private extractFileDetailsFromMetadata(data: any, fileId: number): HydrusFileDetails {
const metadata = this.getFileMetadataEntry(data, fileId) || data || {}
const mediaInfo = this.extractMediaInfoFromMetadata(data, fileId)
const tags = this.extractTagsFromMetadata(data, fileId)
let extension: string | undefined
let sizeBytes: number | undefined
let width: number | undefined
let height: number | undefined
let durationMs: number | undefined
const visit = (value: any, keyHint = '') => {
if (value == null) return
const lowerKey = keyHint.toLowerCase()
if (typeof value === 'string') {
if (!extension && (lowerKey === 'ext' || lowerKey === 'extension')) {
extension = value.replace(/^\./, '').trim().toLowerCase() || undefined
}
if (!extension && (lowerKey.includes('filename') || lowerKey === 'name')) {
const match = value.match(/\.([a-z0-9]{1,10})$/i)
if (match) extension = match[1].toLowerCase()
}
return
}
if (typeof value === 'number') {
if ((lowerKey === 'size' || lowerKey === 'file_size' || lowerKey === 'num_bytes' || lowerKey === 'bytes') && value > 0) {
sizeBytes = Math.max(sizeBytes || 0, value)
} else if (lowerKey === 'width' && value > 0) {
width = Math.max(width || 0, value)
} else if (lowerKey === 'height' && value > 0) {
height = Math.max(height || 0, value)
} else if ((lowerKey.includes('duration') || lowerKey === 'ms') && value > 0) {
durationMs = Math.max(durationMs || 0, value)
}
return
}
if (Array.isArray(value)) {
for (const item of value) visit(item, keyHint)
return
}
if (typeof value === 'object') {
for (const [childKey, childValue] of Object.entries(value)) {
visit(childValue, childKey)
}
}
}
visit(metadata)
return {
fileId,
...mediaInfo,
extension,
sizeBytes,
width,
height,
durationMs,
tags,
}
}
private cleanSearchTag(value: HydrusSearchTag | undefined | null): HydrusSearchTag | null {
if (!value) return null
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return null
return /^system:/i.test(trimmed) ? this.normalizeSystemPredicate(trimmed) : trimmed
}
if (Array.isArray(value)) {
const cleaned = value
.map((item) => this.cleanSearchTag(item))
.filter((item): item is HydrusSearchTag => item !== null)
return cleaned.length ? cleaned : null
}
return null
}
private normalizeSystemPredicate(value: string) {
const trimmed = value.trim().replace(/\s+/g, ' ')
const match = trimmed.match(SYSTEM_PREDICATE_PATTERN)
if (!match) return trimmed
const [, left, operator, right] = match
return `${left.trim()} ${operator} ${right.trim()}`
}
private buildSearchTags(searchableText: string | HydrusSearchTags | undefined | null): HydrusSearchTags {
const tags: HydrusSearchTags = []
if (!searchableText) return tags
const seen = new Set<string>()
const pushValue = (value: HydrusSearchTag | undefined | null) => {
const cleaned = this.cleanSearchTag(value)
if (!cleaned) return
const key = JSON.stringify(cleaned)
if (seen.has(key)) return
seen.add(key)
tags.push(cleaned)
}
if (typeof searchableText === 'string') {
const s = searchableText.trim()
if (!s) return tags
if (/^system:/i.test(s)) {
pushValue(this.normalizeSystemPredicate(s))
return tags
}
const tokens = s.match(SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
const wildcardize = (tok: string) => (tok.includes('*') ? tok : `*${tok}*`)
if (tokens.length === 1) {
const t = tokens[0]
if (t.includes(':')) {
// user explicitly used a namespace or special token
pushValue(t)
} else {
// match either as a plain tag OR as part of title (substring)
pushValue([t, `title:${wildcardize(t)}`])
}
} else {
// multi-token: try a full-phrase title match OR per-token matches
if (!s.includes(':')) {
pushValue([s, `title:${wildcardize(s)}`])
}
for (const t of tokens) {
if (t.includes(':')) pushValue(t)
else pushValue([t, `title:${wildcardize(t)}`])
}
}
} else {
for (const tag of searchableText) {
pushValue(tag)
}
}
return tags
}
async searchFiles(searchableText: string | HydrusSearchTags | null = '', resultsPerPage = 20, signal?: AbortSignal): Promise<number[]> {
// If server configured to prefer query param key, omit key from header
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
const tagsArr: HydrusSearchTags = this.buildSearchTags(searchableText)
tagsArr.push(this.normalizeSystemPredicate(`system:limit=${resultsPerPage}`))
const url = this.buildApiUrl('/get_files/search_files', {
tags: JSON.stringify(tagsArr),
return_file_ids: 'true'
}, this.cfg.forceApiKeyInQuery ?? false)
let res: Response
try {
res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal })
} catch (err: any) {
if (err && err.name === 'AbortError') throw err
throw err
}
if (res.status === 404) {
const text = await res.text().catch(() => '')
console.warn('[HydrusClient] searchFiles 404', { url, status: res.status, body: text })
throw new Error(`Search failed (404): ${text ? text : 'Not Found'} (request: ${url}). Note: /get_files/search_files expects GET with a 'tags' query parameter. Avoid POST fallback as this endpoint may not accept POST.`)
}
if (!res.ok) {
const text = await res.text().catch(() => '')
console.warn('[HydrusClient] searchFiles Response Error', { status: res.status, statusText: res.statusText, body: text })
throw new Error(`Search failed (${res.status})${text ? ': ' + (text.length > 1000 ? text.slice(0, 1000) + '...' : text) : ''} (request: ${url})`)
}
const data = await res.json()
if (Array.isArray(data)) return data as number[]
if (data && Array.isArray((data as any).file_ids)) return (data as any).file_ids
if (data && Array.isArray((data as any).results)) return (data as any).results
return []
}
getFileUrl(fileId: number, includeApiKeyInQuery = true) {
return this.buildApiUrl('/get_files/file', { file_id: fileId }, includeApiKeyInQuery)
}
getThumbnailUrl(fileId: number, includeApiKeyInQuery = true) {
return this.buildApiUrl('/get_files/thumbnail', { file_id: fileId }, includeApiKeyInQuery)
}
async testConnectivity(): Promise<ConnectivityResult> {
try {
// 1) Try a simple GET search with a small limit
const searchUrl = this.buildApiUrl('/get_files/search_files', {
tags: JSON.stringify([this.normalizeSystemPredicate('system:limit=1')]),
return_file_ids: 'true'
}, this.cfg.forceApiKeyInQuery ?? false)
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
let res = await fetch(searchUrl, { method: 'GET', headers, mode: 'cors' })
if ((res.status === 401 || res.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) {
// Auth required; report it
return { ok: false, message: `Authentication required (status ${res.status})`, status: res.status }
}
if (res.status === 404) {
const text = await res.text().catch(() => '')
return { ok: false, message: `Search endpoint not found (404): ${text ? text : 'No response body'}`, status: 404 }
}
if (!res.ok) return { ok: false, message: `Search request failed (status ${res.status})`, status: res.status }
const json = await res.json()
const fileId = Array.isArray(json) && json.length > 0 ? json[0] : json?.file_ids?.[0] ?? null
const result: ConnectivityResult = { ok: true, message: 'Connected (search OK)', status: res.status, searchOk: true }
// 2) If we have a file, test range requests for streaming/seek
if (fileId) {
try {
// Try with header first
const fileUrl = `${this.baseUrl()}/get_files/file?file_id=${fileId}`
const headers2: Record<string, string> = {}
if (this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) headers2['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey
headers2['Range'] = 'bytes=0-0'
const rres = await fetch(fileUrl, { method: 'GET', headers: headers2, mode: 'cors' })
if (rres.status === 206 || (rres.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') {
result.rangeSupported = true
result.message += '; Range requests supported'
return result
}
// Fallback: if header approach didn't yield 206, try query-param API key (useful for HTML audio tags)
if (this.cfg.apiKey) {
const qUrl = `${this.getFileUrl(fileId, true)}`
const rres2 = await fetch(qUrl, { method: 'GET', headers: { Range: 'bytes=0-0' }, mode: 'cors' })
if (rres2.status === 206 || (rres2.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') {
result.rangeSupported = true
result.message += '; Range requests supported (via query param)'
return result
}
}
result.rangeSupported = false
result.message += '; Range request test failed (no 206)'
} catch (e: any) {
result.rangeSupported = false
result.message += `; Range test error: ${e?.message ?? String(e)}`
}
} else {
result.message += '; No files to test Range support'
}
return result
} catch (err: any) {
const msg = err?.message ?? String(err)
// A TypeError commonly indicates network or CORS blocks in the browser
if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('TypeError')) {
return { ok: false, message: `Network or CORS error: ${msg}` }
}
return { ok: false, message: `Error: ${msg}` }
}
}
async getFileTags(fileId: number, signal?: AbortSignal): Promise<string[]> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return []
return this.extractTagsFromMetadata(data, fileId)
}
async getFileMediaInfo(fileId: number, signal?: AbortSignal): Promise<HydrusMediaInfo> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return {}
return this.extractMediaInfoFromMetadata(data, fileId)
}
async getFileDetails(fileId: number, signal?: AbortSignal): Promise<HydrusFileDetails> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return { fileId, tags: [] }
return this.extractFileDetailsFromMetadata(data, fileId)
}
async getFilesTags(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise<Record<number, string[]>> {
const out: Record<number, string[]> = {}
if (!fileIds || fileIds.length === 0) return out
let idx = 0
const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => {
while (true) {
if (signal?.aborted) throw createAbortError()
const i = idx
if (i >= fileIds.length) break
idx++
const fid = fileIds[i]
try {
out[fid] = await this.getFileTags(fid, signal)
} catch (error: any) {
if (error?.name === 'AbortError') throw error
out[fid] = []
}
}
})
await Promise.all(workers)
return out
}
async getFilesMediaInfo(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise<Record<number, HydrusMediaInfo>> {
const out: Record<number, HydrusMediaInfo> = {}
if (!fileIds || fileIds.length === 0) return out
let idx = 0
const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => {
while (true) {
if (signal?.aborted) throw createAbortError()
const i = idx
if (i >= fileIds.length) break
idx++
const fid = fileIds[i]
try {
out[fid] = await this.getFileMediaInfo(fid, signal)
} catch (error: any) {
if (error?.name === 'AbortError') throw error
out[fid] = {}
}
}
})
await Promise.all(workers)
return out
}
}
export function extractTitleFromTags(tags: string[] | null | undefined): string | null {
if (!tags || tags.length === 0) return null
const candidates: string[] = tags.filter((t) => /^title:/i.test(t))
if (candidates.length === 0) return null
const values = candidates
.map((t) => {
const m = t.match(/^title:(.*)$/i)
return m ? m[1].replace(/_/g, ' ').trim() : ''
})
.filter(Boolean)
if (values.length === 0) return null
// prefer the longest (most descriptive) title
values.sort((a, b) => b.length - a.length)
return values[0]
}

47
src/appPreferences.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { MediaSection } from './types'
export type UiPreferences = {
devOverlayEnabled: boolean
libraryQuery: string
libraryDisplayMode: 'grid' | 'table'
librarySortBy: string
librarySortDirection: 'asc' | 'desc'
librarySectionViews: Partial<Record<MediaSection, string>>
}
const STORAGE_KEY = 'api_media_player_ui_preferences_v1'
const DEFAULT_UI_PREFERENCES: UiPreferences = {
devOverlayEnabled: true,
libraryQuery: '',
libraryDisplayMode: 'grid',
librarySortBy: 'artist',
librarySortDirection: 'asc',
librarySectionViews: {},
}
export function loadUiPreferences(): UiPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_UI_PREFERENCES
const parsed = JSON.parse(raw) as Partial<UiPreferences>
return {
...DEFAULT_UI_PREFERENCES,
...parsed,
}
} catch {
return DEFAULT_UI_PREFERENCES
}
}
export function saveUiPreferences(preferences: Partial<UiPreferences>) {
try {
const nextPreferences = {
...loadUiPreferences(),
...preferences,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences))
} catch {
// ignore persistence failures
}
}

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Box, Button, Paper, Typography, List, ListItem, ListItemText, IconButton, Chip } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { addDevLog, clearDevLogs, getDevLogs, subscribeDevLogs, type DevLogItem } from '../debugLog'
function formatLogItem(log: DevLogItem) {
return [
`${log.kind} - ${new Date(log.time).toLocaleString()}`,
log.category ? `category: ${log.category}` : null,
`message: ${log.message}`,
log.source ? `source: ${log.source}` : null,
log.stack ? `stack:\n${log.stack}` : null,
log.details ? `details:\n${log.details}` : null,
].filter(Boolean).join('\n')
}
export default function DevErrorPanel() {
const [logs, setLogs] = useState<DevLogItem[]>(() => getDevLogs())
const [open, setOpen] = useState(false)
const errorCount = useMemo(() => logs.filter((item) => item.kind !== 'debug').length, [logs])
const copyLog = async (log: DevLogItem) => {
const payload = formatLogItem(log)
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(payload)
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
return
}
} catch (error: any) {
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
}
try {
const textarea = document.createElement('textarea')
textarea.value = payload
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id, fallback: true } })
} catch (error: any) {
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard fallback copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
}
}
useEffect(() => {
return subscribeDevLogs((items) => {
setLogs(items)
if (items.length > 0) setOpen(true)
})
}, [])
useEffect(() => {
const onError = (e: ErrorEvent) => {
try {
const item = { kind: 'error' as const, category: 'window', message: e.message || 'Error', stack: (e.error && (e.error.stack || e.error.message)) || undefined, source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined }
addDevLog(item)
// also log to console for developer convenience
// eslint-disable-next-line no-console
console.error('[DevErrorPanel] window.error', item)
} catch (_) {}
}
const onRejection = (e: PromiseRejectionEvent) => {
try {
const reason: any = e.reason
const item = { kind: 'unhandledrejection' as const, category: 'window', message: (reason && (reason.message || String(reason))) || 'Unhandled rejection', stack: reason && reason.stack ? String(reason.stack) : undefined }
addDevLog(item)
// eslint-disable-next-line no-console
console.error('[DevErrorPanel] unhandledrejection', item)
} catch (_) {}
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onRejection)
return () => {
window.removeEventListener('error', onError)
window.removeEventListener('unhandledrejection', onRejection)
}
}, [])
if (!import.meta.env.DEV) return null
return (
<Box sx={{ position: 'fixed', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 12, zIndex: 9999 }}>
<Paper elevation={6} sx={{ minWidth: { xs: 0, sm: 320 }, width: { xs: 'calc(100vw - 24px)', sm: 'auto' }, maxWidth: { xs: 'calc(100vw - 24px)', sm: 640 }, p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={`Logs: ${logs.length}`} color={logs.length ? 'info' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
<Chip label={`Errors: ${errorCount}`} color={errorCount ? 'error' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
<Typography variant="subtitle2">Dev Log Panel</Typography>
</Box>
<Box>
<Button size="small" onClick={() => { clearDevLogs() }} sx={{ mr: 1 }}>Clear</Button>
<IconButton size="small" onClick={() => setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Box>
{open && (
<List sx={{ maxHeight: 300, overflow: 'auto', p: 0 }}>
{logs.map((l) => (
<ListItem key={l.id} divider alignItems="flex-start">
<ListItemText primary={`${l.kind}${new Date(l.time).toLocaleTimeString()}`} secondary={<>
<Typography component="div" variant="body2">{l.message}</Typography>
{l.category && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.category}</Typography>}
{l.source && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.source}</Typography>}
{l.stack && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.stack}</Typography>}
{l.details && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.details}</Typography>}
</>} />
<IconButton edge="end" size="small" aria-label="copy log entry" onClick={() => { void copyLog(l) }} sx={{ ml: 1, alignSelf: 'flex-start' }}>
<ContentCopyIcon sx={{ fontSize: 18 }} />
</IconButton>
</ListItem>
))}
</List>
)}
</Paper>
</Box>
)
}

104
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react'
import { AppBar, Toolbar, IconButton, Typography, Button, Menu, MenuItem, Box, Chip, TextField } from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import StorageIcon from '@mui/icons-material/Storage'
import { useServers } from '../context/ServersContext'
type HeaderProps = {
onOpenSettings?: () => void
onToggleSidebar?: () => void
searchQuery?: string
onSearchQueryChange?: (value: string) => void
searchDisabled?: boolean
}
export default function Header({ onOpenSettings, onToggleSidebar, searchQuery = '', onSearchQueryChange, searchDisabled = false }: HeaderProps) {
const { servers, activeServerId, setActiveServerId } = useServers()
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
const active = servers.find((s) => s.id === activeServerId)
const activeServerLabel = active ? active.name || active.host : 'No server configured'
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)
const handleClose = () => setAnchor(null)
return (
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 0, borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<Toolbar variant="dense" sx={{ minHeight: { xs: 'auto', sm: 48 }, py: { xs: 1, sm: 0.25 }, gap: 1, flexWrap: { xs: 'wrap', sm: 'nowrap' } }}>
<IconButton onClick={() => onToggleSidebar && onToggleSidebar()} aria-label="menu" size="medium" sx={{ flexShrink: 0 }}>
<MenuIcon />
</IconButton>
<TextField
value={searchQuery}
onChange={(event) => onSearchQueryChange && onSearchQueryChange(event.target.value)}
disabled={searchDisabled}
size="small"
placeholder="Search library"
sx={{
flex: { xs: '1 1 calc(100% - 104px)', sm: 1 },
minWidth: 0,
maxWidth: { sm: 520 },
order: { xs: 1, sm: 0 },
'& .MuiInputBase-input': { fontSize: { xs: 14, sm: 13 }, py: 0.9 },
}}
/>
<IconButton onClick={() => onOpenSettings && onOpenSettings()} aria-label="settings" size="medium" sx={{ width: 40, height: 40, flexShrink: 0, order: { xs: 2, sm: 0 } }}>
<SettingsIcon sx={{ fontSize: 20 }} />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: 'auto' }, minWidth: 0, order: { xs: 3, sm: 0 } }}>
<Button
variant="text"
onClick={handleOpen}
startIcon={<StorageIcon sx={{ fontSize: 18 }} />}
sx={{
px: 0.75,
py: 0.25,
fontSize: { xs: 13, sm: 13 },
justifyContent: 'flex-start',
minWidth: 0,
flex: { xs: 1, sm: '0 1 auto' },
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{activeServerLabel}
</Button>
{active?.lastTest && active.lastTest.ok === false && (
<Chip label={active.lastTest.message} color="error" size="small" sx={{ maxWidth: { xs: 132, sm: 200 } }} />
)}
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={handleClose}>
{servers.length === 0 ? (
<MenuItem disabled>No servers configured</MenuItem>
) : (
servers.map((s) => (
<MenuItem
key={s.id}
selected={s.id === activeServerId}
onClick={() => {
setActiveServerId(s.id)
handleClose()
}}
>
{s.name || s.host}
</MenuItem>
))
)}
<MenuItem
onClick={() => {
handleClose()
onOpenSettings && onOpenSettings()
}}
>
Manage servers...
</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
)
}

118
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,118 @@
import React from 'react'
import { Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography, Divider } from '@mui/material'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import MovieIcon from '@mui/icons-material/Movie'
import ImageIcon from '@mui/icons-material/Image'
import AppsIcon from '@mui/icons-material/Apps'
import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
import SettingsIcon from '@mui/icons-material/Settings'
import type { MediaSection } from '../types'
export const drawerWidth = 240
type NavItem = {
id: MediaSection
label: string
icon: React.ReactNode
}
const ITEMS: NavItem[] = [
{ id: 'all', label: 'All', icon: <LibraryMusicIcon /> },
{ id: 'audio', label: 'Audio', icon: <AudiotrackIcon /> },
{ id: 'video', label: 'Video', icon: <MovieIcon /> },
{ id: 'image', label: 'Image', icon: <ImageIcon /> },
{ id: 'application', label: 'Applications', icon: <AppsIcon /> },
]
export default function Sidebar({
mobileOpen,
desktopOpen = true,
onMobileClose,
onNavigate,
activeId,
}: {
mobileOpen?: boolean
desktopOpen?: boolean
onMobileClose?: () => void
onNavigate?: (id: string) => void
activeId?: string
}) {
const handleNavigate = (id: string) => {
onNavigate?.(id)
onMobileClose?.()
}
const content = (
<Box sx={{ width: drawerWidth, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 36, height: 36, borderRadius: 1, background: 'linear-gradient(135deg,#1db954,#1ed760)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700 }}>
H
</Box>
<Typography variant="h6" component="div" sx={{ fontSize: 16, fontWeight: 600 }}>
Hydrus
</Typography>
</Box>
<Divider sx={{ opacity: 0.08 }} />
<List sx={{ p: 1, flex: 1 }}>
{ITEMS.map((it) => (
<ListItemButton key={it.id} selected={activeId === it.id} onClick={() => handleNavigate(it.id)} sx={{ borderRadius: 1, mb: 0.5 }}>
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>{it.icon}</ListItemIcon>
<ListItemText primary={it.label} primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
))}
</List>
<Divider sx={{ opacity: 0.08 }} />
<Box sx={{ p: 1 }}>
<ListItemButton selected={activeId === 'settings'} onClick={() => handleNavigate('settings')} sx={{ borderRadius: 1 }}>
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
</Box>
</Box>
)
return (
<>
<Box
sx={{
display: { xs: 'none', md: 'block' },
width: desktopOpen ? drawerWidth : 0,
flexShrink: 0,
overflow: 'hidden',
transition: (theme) => theme.transitions.create('width', {
duration: theme.transitions.duration.standard,
easing: theme.transitions.easing.sharp,
}),
}}
>
<Box
sx={{
width: drawerWidth,
height: '100%',
borderRight: '1px solid rgba(255,255,255,0.08)',
bgcolor: 'background.paper',
backgroundImage: 'none',
transform: desktopOpen ? 'translateX(0)' : `translateX(-${drawerWidth}px)`,
transition: (theme) => theme.transitions.create('transform', {
duration: theme.transitions.duration.standard,
easing: theme.transitions.easing.sharp,
}),
}}
>
{content}
</Box>
</Box>
{/* Temporary drawer for mobile */}
<Drawer anchor="left" open={Boolean(mobileOpen)} onClose={onMobileClose} ModalProps={{ keepMounted: true }} PaperProps={{ sx: { width: drawerWidth } }} sx={{ display: { xs: 'block', md: 'none' } }}>
{content}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,144 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient'
import { HydrusClient, makeId } from '../api/hydrusClient'
import type { ServerSyncSummary } from '../types'
const STORAGE_KEY = 'hydrus_servers_v1'
const ACTIVE_KEY = 'hydrus_active_id_v1'
export type Server = ServerConfig & {
lastTest?: (ConnectivityResult & { timestamp: number }) | null
syncSummary?: ServerSyncSummary | null
}
type ServersContextType = {
servers: Server[]
activeServerId: string | null
setActiveServerId: (id: string | null) => void
addServer: (s: Omit<Server, 'id' | 'lastTest'>) => Server
updateServer: (id: string, patch: Partial<Server>) => void
removeServer: (id: string) => void
testServerById: (id: string) => Promise<ConnectivityResult>
testServerConfig: (cfg: Omit<Server, 'id' | 'lastTest'>) => Promise<ConnectivityResult>
}
const ServersContext = createContext<ServersContextType | null>(null)
function loadServers(): Server[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Server[]
} catch (e) {
return []
}
}
function saveServers(servers: Server[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers))
} catch (e) {
// ignore
}
}
export function ServersProvider({ children }: { children: React.ReactNode }) {
const [servers, setServers] = useState<Server[]>(() => loadServers())
const [activeServerId, setActiveServerIdState] = useState<string | null>(() => {
try {
return localStorage.getItem(ACTIVE_KEY)
} catch (e) {
return null
}
})
useEffect(() => saveServers(servers), [servers])
useEffect(() => {
if (!activeServerId && servers.length > 0) {
setActiveServerIdState(servers[0].id)
localStorage.setItem(ACTIVE_KEY, servers[0].id)
}
// Seed a local server if none exist yet (user-provided default IP)
if (servers.length === 0) {
const seedHost = '192.168.1.128'
const seedPort = '45869'
const seedName = 'Local Hydrus (192.168.1.128)'
const id = makeId()
const srv: Server = {
id,
name: seedName,
host: seedHost,
port: seedPort,
apiKey: '',
ssl: false,
forceApiKeyInQuery: false,
lastTest: { ok: false, message: 'Unauthenticated (401). Add API key to test', status: 401, searchOk: false, rangeSupported: false, timestamp: Date.now() }
}
setServers([srv])
setActiveServerIdState(id)
localStorage.setItem(STORAGE_KEY, JSON.stringify([srv]))
localStorage.setItem(ACTIVE_KEY, id)
}
}, [])
const setActiveServerId = useCallback((id: string | null) => {
setActiveServerIdState(id)
if (id) localStorage.setItem(ACTIVE_KEY, id)
else localStorage.removeItem(ACTIVE_KEY)
}, [])
const addServer = useCallback((s: Omit<Server, 'id' | 'lastTest'>) => {
const id = makeId()
const srv: Server = { id, ...s, lastTest: null }
setServers((prev) => [...prev, srv])
return srv
}, [])
const updateServer = useCallback((id: string, patch: Partial<Server>) => {
setServers((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)))
}, [])
const removeServer = useCallback((id: string) => {
setServers((prev) => prev.filter((s) => s.id !== id))
if (activeServerId === id) setActiveServerId(null)
}, [activeServerId, setActiveServerId])
const testServerById = useCallback(async (id: string) => {
const server = servers.find((s) => s.id === id)
if (!server) return { ok: false, message: 'Server not found' } as ConnectivityResult
const client = new HydrusClient(server)
const res = await client.testConnectivity()
updateServer(id, { lastTest: { ...res, timestamp: Date.now() } })
return res
}, [servers, updateServer])
const testServerConfig = useCallback(async (cfg: Omit<Server, 'id' | 'lastTest'>) => {
const client = new HydrusClient(cfg as ServerConfig)
return client.testConnectivity()
}, [])
const value = useMemo(() => ({
servers,
activeServerId,
setActiveServerId,
addServer,
updateServer,
removeServer,
testServerById,
testServerConfig,
}), [servers, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig])
return (
<ServersContext.Provider value={value}>
{children}
</ServersContext.Provider>
)
}
export function useServers() {
const ctx = useContext(ServersContext)
if (!ctx) throw new Error('useServers must be used within ServersProvider')
return ctx
}

62
src/debugLog.ts Normal file
View File

@@ -0,0 +1,62 @@
import { makeId } from './api/hydrusClient'
export type DevLogItem = {
id: string
time: number
kind: 'debug' | 'error' | 'unhandledrejection'
category?: string
message: string
stack?: string
source?: string
details?: string
}
const MAX_LOGS = 200
let logs: DevLogItem[] = []
const listeners = new Set<(items: DevLogItem[]) => void>()
function notify() {
for (const listener of listeners) listener(logs)
}
function stringifyDetails(details: unknown) {
if (details == null) return undefined
if (typeof details === 'string') return details
try {
return JSON.stringify(details, null, 2)
} catch {
return String(details)
}
}
export function addDevLog(entry: Omit<DevLogItem, 'id' | 'time' | 'details'> & { details?: unknown }) {
if (!import.meta.env.DEV) return
const item: DevLogItem = {
id: makeId(),
time: Date.now(),
...entry,
details: stringifyDetails(entry.details),
}
logs = [item, ...logs].slice(0, MAX_LOGS)
notify()
}
export function clearDevLogs() {
logs = []
notify()
}
export function getDevLogs() {
return logs
}
export function subscribeDevLogs(listener: (items: DevLogItem[]) => void) {
listener(logs)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}

99
src/libraryCache.ts Normal file
View File

@@ -0,0 +1,99 @@
import type { Track } from './types'
type CacheServerDescriptor = {
id: string
host: string
port?: string | number
ssl?: boolean
}
type PersistedTrack = Omit<Track, 'id'>
type LibraryCacheRecord = {
cacheKey: string
updatedAt: number
tracks: PersistedTrack[]
searchCache: Record<string, number[]>
}
export type LibraryCacheSnapshot = {
tracks: PersistedTrack[]
searchCache: Record<string, number[]>
}
const DATABASE_NAME = 'api-mediaplayer-library-cache'
const DATABASE_VERSION = 1
const STORE_NAME = 'snapshots'
const MAX_TRACKS = 5000
const MAX_SEARCHES = 250
export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) {
return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|')
}
function openLibraryCacheDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open library cache database'))
})
}
function withStore<T>(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
openLibraryCacheDatabase()
.then((database) => {
const transaction = database.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
const request = handler(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
transaction.oncomplete = () => database.close()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
.catch(reject)
})
}
export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> {
if (!cacheKey || typeof indexedDB === 'undefined') return null
const record = await withStore<LibraryCacheRecord | undefined>('readonly', (store) => store.get(cacheKey))
if (!record) return null
return {
tracks: Array.isArray(record.tracks) ? record.tracks : [],
searchCache: record.searchCache && typeof record.searchCache === 'object' ? record.searchCache : {},
}
}
export async function saveLibraryCache(cacheKey: string, tracks: Track[], searchCache: Record<string, number[]>) {
if (!cacheKey || typeof indexedDB === 'undefined') return
const trimmedTracks = tracks
.filter((track) => track.serverId && track.fileId != null && track.url)
.slice(-MAX_TRACKS)
.map(({ id: _id, ...track }) => track)
const trimmedSearchCache = Object.fromEntries(Object.entries(searchCache).slice(-MAX_SEARCHES))
const record: LibraryCacheRecord = {
cacheKey,
updatedAt: Date.now(),
tracks: trimmedTracks,
searchCache: trimmedSearchCache,
}
await withStore('readwrite', (store) => store.put(record))
}

18
src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
import { registerServiceWorker, unregisterServiceWorker } from './serviceWorker'
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
if (import.meta.env.PROD) {
registerServiceWorker()
} else {
// In development, unregister service workers to avoid caching/fetch oddities while iterating
unregisterServiceWorker()
}

27
src/mediaCache.ts Normal file
View File

@@ -0,0 +1,27 @@
const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v1'
const MAX_MEDIA_CACHE_ITEMS = 12
async function trimMediaCache(cache: Cache) {
const keys = await cache.keys()
if (keys.length <= MAX_MEDIA_CACHE_ITEMS) return
const overflow = keys.length - MAX_MEDIA_CACHE_ITEMS
for (let index = 0; index < overflow; index += 1) {
await cache.delete(keys[index])
}
}
export async function cacheMediaFile(url: string) {
if (!url || typeof caches === 'undefined') return false
const cache = await caches.open(MEDIA_CACHE_NAME)
const existing = await cache.match(url)
if (existing) return true
const response = await fetch(url, { method: 'GET', mode: 'cors' }).catch(() => null)
if (!response || !response.ok || response.status !== 200) return false
await cache.put(url, response.clone())
await trimMediaCache(cache)
return true
}

1558
src/pages/Library.tsx Normal file

File diff suppressed because it is too large Load Diff

381
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,381 @@
import React, { useEffect, useState } from 'react'
import {
Box,
FormControl,
InputLabel,
Grid,
Typography,
List,
ListItem,
ListItemText,
Button,
IconButton,
TextField,
Switch,
FormControlLabel,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
MenuItem,
Select
} from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import DeleteIcon from '@mui/icons-material/Delete'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import EditIcon from '@mui/icons-material/Edit'
import AddIcon from '@mui/icons-material/Add'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import type { Server } from '../context/ServersContext'
import { useServers } from '../context/ServersContext'
import { HydrusClient, extractTitleFromTags } from '../api/hydrusClient'
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
import type { MediaSection, ServerSyncSummary, Track } from '../types'
const SYNC_SECTION_LIMIT = 2000
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
const SYNC_SECTIONS: Array<{ id: MediaSection; label: string; predicate: string }> = [
{ id: 'audio', label: 'Audio', predicate: 'system:filetype = audio' },
{ id: 'video', label: 'Video', predicate: 'system:filetype = video' },
{ id: 'image', label: 'Image', predicate: 'system:filetype = image' },
{ id: 'application', label: 'Applications', predicate: 'system:filetype = application' },
]
type SettingsPageProps = {
onClose?: () => void
devOverlayEnabled: boolean
onDevOverlayEnabledChange: (enabled: boolean) => void
libraryDisplayMode: 'grid' | 'table'
onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void
}
export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, libraryDisplayMode, onLibraryDisplayModeChange }: SettingsPageProps) {
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
const [editing, setEditing] = useState<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
const [testing, setTesting] = useState(false)
const [syncingServerId, setSyncingServerId] = useState<string | null>(null)
const [lastTest, setLastTest] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [detailsText, setDetailsText] = useState<string | null>(null)
const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${ns.toLowerCase()}:`
const values = tags
.filter((tag) => typeof tag === 'string' && tag.toLowerCase().startsWith(prefix))
.map((tag) => tag.slice(prefix.length).replace(/_/g, ' ').trim())
.filter(Boolean)
return values.sort((a, b) => b.length - a.length)[0] || null
}
const buildTrackCacheKey = (serverId?: string, fileId?: number) => (serverId && fileId != null ? `${serverId}:${fileId}` : '')
useEffect(() => {
setEditing(null)
setForm(DEFAULT_SERVER_FORM)
setLastTest(null)
setDetailsText(null)
setDetailsOpen(false)
}, [])
const startAdd = () => {
setEditing(null)
setForm(DEFAULT_SERVER_FORM)
}
const startEdit = (s: Server) => {
setEditing(s)
setForm({ name: s.name || '', host: s.host, port: s.port, apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery })
setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null)
}
const handleSave = () => {
if (editing) {
updateServer(editing.id, form)
} else {
const srv = addServer(form)
setActiveServerId(srv.id)
}
onClose && onClose()
}
const handleDelete = (s: Server) => {
if (confirm(`Delete server ${s.name || s.host}?`)) {
removeServer(s.id)
}
}
const handleTestExisting = async (s: Server) => {
setTesting(true)
try {
const res = await testServerById(s.id)
setLastTest(`${res.message}`)
} catch (e: any) {
setLastTest(`Error: ${e?.message ?? String(e)}`)
} finally {
setTesting(false)
}
}
const handleTestForm = async () => {
setTesting(true)
try {
const res = await testServerConfig(form)
setLastTest(`${res.message}`)
} catch (e: any) {
setLastTest(`Error: ${e?.message ?? String(e)}`)
} finally {
setTesting(false)
}
}
const handleSyncServer = async (server: Server) => {
setSyncingServerId(server.id)
try {
const client = new HydrusClient(server)
const cacheKey = buildLibraryCacheKey(servers)
const snapshot = await loadLibraryCache(cacheKey)
const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) }
const mergedTrackMap: Record<string, Track> = {}
let localCounter = Date.now()
for (const track of snapshot?.tracks ?? []) {
const hydratedTrack: Track = { ...track, id: ++localCounter }
const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId)
if (key) mergedTrackMap[key] = hydratedTrack
}
const counts: ServerSyncSummary['counts'] = {}
for (const section of SYNC_SECTIONS) {
const searchTags = [section.predicate]
const ids = await client.searchFiles(searchTags, SYNC_SECTION_LIMIT)
counts[section.id] = ids.length
mergedSearchCache[`${server.id}|${section.id}|tracks|${JSON.stringify(searchTags)}`] = ids
if (ids.length === 0) continue
const tagMap = await client.getFilesTags(ids, 8)
const mediaInfoMap = section.id === 'application' ? await client.getFilesMediaInfo(ids, 6) : {}
for (const fileId of ids) {
const tags = tagMap[fileId] || []
const key = buildTrackCacheKey(server.id, fileId)
if (!key) continue
mergedTrackMap[key] = {
id: ++localCounter,
fileId,
serverId: server.id,
serverName: server.name || server.host,
title: extractTitleFromTags(tags) || '',
artist: extractNamespaceValue(tags, 'artist') || undefined,
album: extractNamespaceValue(tags, 'album') || undefined,
tags: tags.length ? tags : undefined,
url: client.getFileUrl(fileId),
thumbnail: client.getThumbnailUrl(fileId),
mimeType: mediaInfoMap[fileId]?.mimeType,
isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined),
mediaKind: section.id,
}
}
}
await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache)
const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0)
const summary: ServerSyncSummary = {
updatedAt: Date.now(),
total,
counts,
message: `Synced ${total} cached items`,
}
updateServer(server.id, { syncSummary: summary })
setLastTest(summary.message ?? `Synced ${total} cached items`)
} catch (error: any) {
const message = error?.message ?? String(error)
updateServer(server.id, {
syncSummary: {
updatedAt: Date.now(),
total: 0,
counts: {},
message: `Sync failed: ${message}`,
},
})
setLastTest(`Sync failed: ${message}`)
} finally {
setSyncingServerId(null)
}
}
return (
<Box sx={{ p: { xs: 1, sm: 2, lg: 3 }, minHeight: '100%', bgcolor: 'background.default' }}>
<Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6">Hydrus Servers</Typography>
</Box>
</Box>
{import.meta.env.DEV && (
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Choose the default Library layout so browsing controls can stay compact.
</Typography>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: 2 }}>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={libraryDisplayMode}
label="Display"
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Developer tools</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Control development-only UI that can get in the way on smaller screens.
</Typography>
<FormControlLabel
control={<Switch checked={devOverlayEnabled} onChange={(event) => onDevOverlayEnabledChange(event.target.checked)} />}
label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Box>
)}
{!import.meta.env.DEV && (
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Choose the default Library layout so browsing controls can stay compact.
</Typography>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240 }}>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={libraryDisplayMode}
label="Display"
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
</Box>
)}
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
<Grid item xs={12} lg={5}>
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle1">Configured servers</Typography>
<Button startIcon={<AddIcon />} onClick={startAdd} size="large" className="touch-target">
Add
</Button>
</Box>
<List>
{servers.length === 0 && <Typography color="text.secondary">No servers configured yet</Typography>}
{servers.map((s) => (
<ListItem key={s.id} sx={{ flexWrap: 'wrap', alignItems: 'flex-start', px: 0, py: 1.25, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<ListItemText primary={s.name || s.host} secondary={s.host} sx={{ mr: 2 }} />
<Box sx={{ display: 'flex', gap: 1, mt: { xs: 1, md: 0 }, flexWrap: 'wrap', width: { xs: '100%', md: 'auto' } }}>
<Button size="large" onClick={() => setActiveServerId(s.id)} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
{s.id === activeServerId ? 'Active' : 'Set active'}
</Button>
<Button variant="outlined" size="large" onClick={() => handleTestExisting(s)} startIcon={<PlayArrowIcon />} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
Test
</Button>
<Button variant="outlined" size="large" onClick={() => handleSyncServer(s)} startIcon={<CloudDownloadIcon />} disabled={syncingServerId === s.id} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
{syncingServerId === s.id ? 'Syncing...' : 'Sync cache'}
</Button>
<IconButton size="large" aria-label="edit" onClick={() => startEdit(s)}>
<EditIcon />
</IconButton>
<IconButton size="large" aria-label="delete" onClick={() => handleDelete(s)}>
<DeleteIcon />
</IconButton>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', width: '100%', mt: 1 }}>
{s.lastTest && s.lastTest.message && <Chip label={s.lastTest.message} size="small" />}
{s.syncSummary?.message && <Chip label={s.syncSummary.message} size="small" color="primary" variant="outlined" />}
{s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => (
<Chip key={`${s.id}-${section}`} label={`${section}: ${count}`} size="small" variant="outlined" />
))}
{s.forceApiKeyInQuery && <Chip label="API key in query" size="small" />}
</Box>
{s.syncSummary?.updatedAt && (
<Typography variant="caption" color="text.secondary" sx={{ width: '100%', mt: 1 }}>
Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()}
</Typography>
)}
</ListItem>
))}
</List>
</Box>
</Grid>
<Grid item xs={12} lg={7}>
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
<Typography variant="subtitle1">{editing ? 'Edit server' : 'Add new server'}</Typography>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField label="Name (optional)" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} fullWidth />
<TextField label="Host (IP or host, e.g. 192.168.1.128)" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth />
<TextField label="Port (optional)" value={form.port as any ?? ''} onChange={(e) => setForm({ ...form, port: e.target.value })} fullWidth />
<TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth />
<FormControlLabel control={<Switch checked={!!form.ssl} onChange={(e) => setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" />
<FormControlLabel control={<Switch checked={!!form.forceApiKeyInQuery} onChange={(e) => setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" />
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
<Button variant="contained" onClick={handleSave} disabled={!form.host} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
Save
</Button>
<Button variant="outlined" onClick={handleTestForm} disabled={!form.host || testing} startIcon={<PlayArrowIcon />} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
{testing ? 'Testing...' : 'Test connection'}
</Button>
<Button onClick={() => { setEditing(null); setForm(DEFAULT_SERVER_FORM); setLastTest(null) }} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
Reset
</Button>
</Box>
{lastTest && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption">Last test: {lastTest}</Typography>
<Box sx={{ mt: 1 }}>
<Button size="small" onClick={() => {
const obj = editing?.lastTest ?? servers.find((s) => s.id === editing?.id)?.lastTest ?? null
setDetailsText(obj ? JSON.stringify(obj, null, 2) : lastTest)
setDetailsOpen(true)
}}>Show details</Button>
</Box>
</Box>
)}
<Dialog open={detailsOpen} onClose={() => setDetailsOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Connection details</DialogTitle>
<DialogContent>
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>{detailsText}</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailsOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
</Box>
</Grid>
</Grid>
</Box>
</Box>
)
}

5
src/sampleData.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Track } from './types'
// No bundled sample tracks by default
export const SAMPLE_TRACKS: Track[] = []

16
src/serviceWorker.ts Normal file
View File

@@ -0,0 +1,16 @@
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => console.log('Service worker registered:', reg))
.catch((err) => console.log('Service worker registration failed:', err))
})
}
}
export function unregisterServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((regs) => regs.forEach((r) => r.unregister()))
}
}

55
src/styles.css Normal file
View File

@@ -0,0 +1,55 @@
/* Navidrome-like dark theme variables */
:root {
--app-bg: #0f1113;
--surface: #151617;
--muted: rgba(255,255,255,0.6);
--accent: #1db954;
--sidebar-width: 240px;
}
body {
margin: 0;
font-family: Roboto, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: var(--app-bg);
color: #e6eef3;
}
#root { height: 100vh }
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
border-right: 1px solid rgba(255,255,255,0.03);
}
/* Library grid (album/artist style) */
.library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; padding: 12px; }
.card-media { position: relative; overflow: hidden; border-radius: 6px; background: #0b0b0b; }
.card-media img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 120ms ease-in-out; background: linear-gradient(180deg, rgba(0,0,0,0.0), rgba(0,0,0,0.32)); }
.card-media:hover .card-overlay,
.card-media:focus-within .card-overlay { opacity: 1; }
.card-badge { position: absolute; top: 8px; left: 8px; background: rgba(0,0,0,0.5); color: #fff; padding: 2px 6px; border-radius: 12px; font-size: 12px; }
/* Play icon in overlay */
.card-overlay .play-button { background: rgba(0,0,0,0.6); border-radius: 999px; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; }
/* card server badge variant */
.card-badge.server-badge { left: auto; right: 8px; background: rgba(255,255,255,0.04); }
/* Touch-friendly helpers and mobile layout */
.touch-target { min-height: 48px; min-width: 48px; padding: 8px 12px; }
@media (max-width:600px) {
.app-content { padding: 4px !important; }
.touch-target { min-height: 56px; padding: 10px 16px; font-size: 1rem; }
.library-grid { grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 10px; padding: 8px 0; }
.card-media img { height: 140px; object-fit: cover; }
}
@media (hover: none), (pointer: coarse) {
.card-overlay { opacity: 1; background: linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.38)); }
.card-overlay .play-button { width: 52px; height: 52px; }
}

26
src/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export type MediaSection = 'all' | 'audio' | 'video' | 'image' | 'application'
export type ServerSyncSummary = {
updatedAt: number
total: number
counts: Partial<Record<MediaSection, number>>
message?: string
}
export type Track = {
id: number // local unique id used by the UI
fileId?: number // original Hydrus file id (per-server)
serverId?: string // which server this file came from
serverName?: string // friendly server name for UI
title: string
artist?: string
album?: string
url: string
thumbnail?: string
duration?: number
tags?: string[]
// Optional MIME/type hints for rendering (video vs audio)
isVideo?: boolean
mimeType?: string
mediaKind?: MediaSection
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["DOM", "DOM.Iterable", "ES2021"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})