first commit
This commit is contained in:
+357
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Track } from './types'
|
||||
|
||||
// No bundled sample tracks by default
|
||||
export const SAMPLE_TRACKS: Track[] = []
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user