From f2ce3e82fa99a78ed85bda3e9634ee2076a5d6fe Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 20 Jun 2026 17:28:17 -0700 Subject: [PATCH] update --- .../api-media-player-open-in-mpv.user.js | 7 +- scripts/README.md | 10 +++ .../linux-mpv-handler.cpython-314.pyc | Bin 0 -> 8612 bytes scripts/config.toml | 5 ++ scripts/linux-mpv-handler.py | 57 +++++++++++- src/App.tsx | 4 +- src/pages/Library.tsx | 85 +++++++++++++++++- 7 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 scripts/__pycache__/linux-mpv-handler.cpython-314.pyc diff --git a/public/userscripts/api-media-player-open-in-mpv.user.js b/public/userscripts/api-media-player-open-in-mpv.user.js index c604035..93b47f7 100644 --- a/public/userscripts/api-media-player-open-in-mpv.user.js +++ b/public/userscripts/api-media-player-open-in-mpv.user.js @@ -112,8 +112,11 @@ function buildDesktopMpvUrl(url, metadata) { const encodedUrl = encodeUrlSafeBase64(url) - const encodedTitle = metadata.title ? encodeUrlSafeBase64(metadata.title) : '' - const query = encodedTitle ? `?v_title=${encodedTitle}` : '' + const queryParts = ['enqueue=append'] + if (metadata.title) { + queryParts.push(`v_title=${encodeUrlSafeBase64(metadata.title)}`) + } + const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : '' return `mpv-handler://play/${encodedUrl}/${query}` } diff --git a/scripts/README.md b/scripts/README.md index 91dfffd..0117572 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -59,6 +59,16 @@ npm run setup:mpv-handler -- --skip-config npm run setup:mpv-handler -- --dry-run ``` +## Reuse existing mpv window (playlist append) + +Desktop links now include an `enqueue=append` hint. For the bundled Linux Python fallback handler, this can append into an already-running mpv instance when you set an IPC socket in `config.toml`: + +```toml +ipc_socket = "/tmp/mpv-handler.sock" +``` + +With this set, the first launch starts mpv with that IPC socket and later launches can add new URLs to the current playlist instead of opening a new window. + `--root` points at the extracted upstream `mpv-handler` folder. On Windows it is optional because this repo already ships the handler files under `scripts/`. On Linux it is normally required. ## What the helper does diff --git a/scripts/__pycache__/linux-mpv-handler.cpython-314.pyc b/scripts/__pycache__/linux-mpv-handler.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9d8903144ff8b75f768e7bb4cce08015cb874ef GIT binary patch literal 8612 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 literal 0 HcmV?d00001 diff --git a/scripts/config.toml b/scripts/config.toml index 05c7190..3fd7a08 100644 --- a/scripts/config.toml +++ b/scripts/config.toml @@ -13,6 +13,11 @@ # Optional, Type: String # HTTP(S) proxy server address +#ipc_socket = "/tmp/mpv-handler.sock" +# Optional, Type: String +# mpv IPC socket path used for appending new URLs to an already-running mpv instance. +# If set, API Media Player desktop links can enqueue into the existing player instead of opening a new window. + # 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 diff --git a/scripts/linux-mpv-handler.py b/scripts/linux-mpv-handler.py index 9fcdf22..a884606 100644 --- a/scripts/linux-mpv-handler.py +++ b/scripts/linux-mpv-handler.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 import base64 +import json import os import re +import socket import subprocess import sys from pathlib import Path @@ -33,15 +35,57 @@ def resolve_mpv_command(config_path: Path) -> list[str]: return ['mpv'] +def get_ipc_socket_path(config_path: Path) -> str: + configured_socket = read_config_value(config_path, 'ipc_socket') + if configured_socket: + return configured_socket + + return os.environ.get('MPV_HANDLER_IPC_SOCKET', '').strip() + + +def try_enqueue_via_ipc(ipc_socket_path: str, target_url: str, title: str) -> bool: + if not ipc_socket_path: + return False + + socket_path = Path(ipc_socket_path).expanduser() + if not socket_path.exists(): + return False + + # Try with per-entry title first, then fallback to plain append if unsupported. + commands = [ + {'command': ['loadfile', target_url, 'append-play', f'force-media-title={title}']} if title else {'command': ['loadfile', target_url, 'append-play']}, + {'command': ['loadfile', target_url, 'append-play']}, + ] + + for command in commands: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(0.4) + client.connect(str(socket_path)) + client.sendall((json.dumps(command) + '\n').encode('utf-8')) + return True + except Exception: + continue + + return False + + def build_mpv_command(target_url: str, title: str, config_path: Path) -> list[str]: command = resolve_mpv_command(config_path) + ipc_socket_path = get_ipc_socket_path(config_path) + + if ipc_socket_path: + expanded_socket = str(Path(ipc_socket_path).expanduser()) + # Ensure new mpv instances expose the same IPC socket for future append requests. + command.append(f'--input-ipc-server={expanded_socket}') + if title: command.append(f'--force-media-title={title}') command.append(target_url) return command -def parse_protocol_url(raw_url: str) -> tuple[str, str]: +def parse_protocol_url(raw_url: str) -> tuple[str, str, str]: parsed = urlparse(raw_url) if parsed.scheme not in {'mpv-handler', 'mpv-handler-debug'}: raise ValueError(f'Unsupported scheme: {parsed.scheme}') @@ -57,7 +101,8 @@ def parse_protocol_url(raw_url: str) -> tuple[str, str]: title = '' if query.get('v_title'): title = decode_urlsafe_base64(query['v_title'][0]) - return target_url, title + enqueue_mode = (query.get('enqueue') or [''])[0].strip().lower() + return target_url, title, enqueue_mode def main() -> int: @@ -66,7 +111,7 @@ def main() -> int: return 1 try: - target_url, title = parse_protocol_url(sys.argv[1]) + target_url, title, enqueue_mode = parse_protocol_url(sys.argv[1]) except Exception as error: print(f'Failed to parse mpv-handler URL: {error}', file=sys.stderr) return 1 @@ -74,6 +119,12 @@ def main() -> int: config_home = os.environ.get('XDG_CONFIG_HOME', '').strip() config_dir = Path(config_home) / 'mpv-handler' if config_home else Path.home() / '.config' / 'mpv-handler' config_path = config_dir / 'config.toml' + + if enqueue_mode == 'append': + ipc_socket_path = get_ipc_socket_path(config_path) + if try_enqueue_via_ipc(ipc_socket_path, target_url, title): + return 0 + command = build_mpv_command(target_url, title, config_path) try: diff --git a/src/App.tsx b/src/App.tsx index 4e39c62..e57282f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -200,7 +200,9 @@ function buildAndroidMpvIntentUrl(track: Track) { function buildDesktopMpvHandlerUrl(track: Track) { const metadata = buildExternalPlayerMetadata(track) const encodedUrl = encodeUrlSafeBase64(track.url) - const query = metadata.title ? `?v_title=${encodeUrlSafeBase64(metadata.title)}` : '' + const queryParts = ['enqueue=append'] + if (metadata.title) queryParts.push(`v_title=${encodeUrlSafeBase64(metadata.title)}`) + const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : '' return `mpv-handler://play/${encodedUrl}/${query}` } diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index cb68eb4..03e31a3 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -71,6 +71,24 @@ const STREAMABLE_FILE_EXTENSIONS = new Set([ 'flv', ]) +const MIME_EXTENSION_FALLBACKS: Array<[string, string]> = [ + ['audio/flac', 'flac'], + ['audio/x-flac', 'flac'], + ['audio/mpeg', 'mp3'], + ['audio/mp3', 'mp3'], + ['audio/mp4', 'm4a'], + ['audio/aac', 'aac'], + ['audio/wav', 'wav'], + ['audio/x-wav', 'wav'], + ['audio/ogg', 'ogg'], + ['audio/opus', 'opus'], + ['video/mp4', 'mp4'], + ['video/webm', 'webm'], + ['video/x-matroska', 'mkv'], + ['video/quicktime', 'mov'], + ['application/vnd.apple.mpegurl', 'm3u8'], +] + type SortOption = { id: SortField label: string @@ -373,6 +391,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const [visibleCount, setVisibleCount] = useState(RESULTS_PAGE_SIZE) const theme = useTheme() const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) + const isMobileDialogLayout = useMediaQuery(theme.breakpoints.down('md')) const { servers, onlineServerIds, healthChecksComplete } = useServers() const hasServers = servers.length > 0 @@ -604,6 +623,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (tokens.length === 0) return true return tokens.every((token) => { + const namespaceMatch = token.match(SEARCH_NAMESPACE_PATTERN) + if (namespaceMatch) { + return matchesNamespacedClause(track, namespaceMatch[1].toLowerCase(), namespaceMatch[2]) + } + const term = normalizeSearchTerm(token) if (!term) return true @@ -640,7 +664,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (clauses.length === 0) return true return clauses.every((clause) => { - const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) + const namespaceMatch = !/\s/.test(clause) ? clause.match(SEARCH_NAMESPACE_PATTERN) : null if (namespaceMatch) { const namespace = namespaceMatch[1].toLowerCase() const rawValue = namespaceMatch[2] @@ -701,9 +725,22 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr } function getTrackExtension(track: Track, details?: HydrusFileDetails | null) { - if (details?.extension) return details.extension + const detailsExtension = (details?.extension || '').trim().replace(/^\./, '').toLowerCase() + if (detailsExtension) return detailsExtension + const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i) - return match ? match[1].toLowerCase() : undefined + if (match) return match[1].toLowerCase() + + const normalizedMimeType = (track.mimeType || '').trim().toLowerCase() + if (!normalizedMimeType) return undefined + + for (const [mime, extension] of MIME_EXTENSION_FALLBACKS) { + if (normalizedMimeType.startsWith(mime)) return extension + } + + const mimeSuffix = normalizedMimeType.match(/^[a-z0-9.+-]+\/([a-z0-9.+-]+)/) + if (!mimeSuffix?.[1]) return undefined + return mimeSuffix[1].replace(/^x-/, '').split('+')[0] } function canStreamTrack(track: Track, details?: HydrusFileDetails | null) { @@ -1087,6 +1124,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const handleDownloadTrack = () => { if (!detailsTrack?.url || detailsTrackDownloading) return + closeDetails() onDownloadTrack(detailsTrack, detailsData) } @@ -1184,6 +1222,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return visibleResults }, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults]) + useEffect(() => { + if (!detailsOpen || !isMobileDialogLayout || typeof document === 'undefined') return + + const viewportMeta = document.querySelector('meta[name="viewport"]') + if (!viewportMeta) return + + const previousContent = viewportMeta.content + const normalized = previousContent + .replace(/\s*,?\s*maximum-scale\s*=\s*[^,]+/gi, '') + .replace(/\s*,?\s*user-scalable\s*=\s*[^,]+/gi, '') + .trim() + .replace(/,+$/g, '') + + viewportMeta.content = `${normalized},maximum-scale=1,user-scalable=no` + + return () => { + viewportMeta.content = previousContent + } + }, [detailsOpen, isMobileDialogLayout]) + useEffect(() => { const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track)) if (candidates.length === 0) return @@ -1647,7 +1705,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} - + {detailsTrack ? getDisplayTitle(detailsTrack) : 'Item details'} {detailsLoading && Loading item details...}