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
+357
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
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
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
}
}
+128
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
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
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>
</>
)
}
+144
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
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
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
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
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
}
File diff suppressed because it is too large Load Diff
+381
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
View File
@@ -0,0 +1,5 @@
import type { Track } from './types'
// No bundled sample tracks by default
export const SAMPLE_TRACKS: Track[] = []
+16
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
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
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
}