updated server management when its offline

This commit is contained in:
2026-04-15 23:37:28 -07:00
parent ca559de2c0
commit 6fbfdd0184
3 changed files with 240 additions and 131 deletions
+9 -5
View File
@@ -324,9 +324,10 @@ async function embedDownloadMetadata(blob: Blob, track: Track, details?: HydrusF
} }
function StartupLibraryCacheSync() { function StartupLibraryCacheSync() {
const { servers, updateServer } = useServers() const { servers, onlineServerIds, healthChecksComplete, updateServer } = useServers()
const lastSyncSignatureRef = useRef<string | null>(null) const lastSyncSignatureRef = useRef<string | null>(null)
const syncSignature = useMemo(() => servers const syncSignature = useMemo(() => servers
.filter((server) => onlineServerIds.includes(server.id))
.map((server) => [ .map((server) => [
server.id, server.id,
server.host, server.host,
@@ -335,15 +336,18 @@ function StartupLibraryCacheSync() {
server.ssl, server.ssl,
server.forceApiKeyInQuery, server.forceApiKeyInQuery,
].join('|')) ].join('|'))
.join(','), [servers]) .join(','), [onlineServerIds, servers])
useEffect(() => { useEffect(() => {
if (!servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return if (!healthChecksComplete || !servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return
lastSyncSignatureRef.current = syncSignature lastSyncSignatureRef.current = syncSignature
let cancelled = false let cancelled = false
const targetServerIds = onlineServerIds.filter((serverId) => servers.some((server) => server.id === serverId))
void syncLibraryCache(servers) if (targetServerIds.length === 0) return
void syncLibraryCache(servers, { targetServerIds })
.then((result) => { .then((result) => {
if (cancelled) return if (cancelled) return
@@ -358,7 +362,7 @@ function StartupLibraryCacheSync() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [servers, syncSignature, updateServer]) }, [healthChecksComplete, onlineServerIds, servers, syncSignature, updateServer])
return null return null
} }
+87 -7
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient' import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient'
import { HydrusClient, makeId } from '../api/hydrusClient' import { HydrusClient, makeId } from '../api/hydrusClient'
import type { ServerSyncSummary } from '../types' import type { ServerSyncSummary } from '../types'
@@ -11,8 +11,20 @@ export type Server = ServerConfig & {
syncSummary?: ServerSyncSummary | null syncSummary?: ServerSyncSummary | null
} }
function buildConnectivitySignature(server: Pick<ServerConfig, 'host' | 'port' | 'apiKey' | 'ssl' | 'forceApiKeyInQuery'>) {
return [
server.host?.trim().toLowerCase() || '',
server.port ?? '',
server.apiKey || '',
server.ssl ? '1' : '0',
server.forceApiKeyInQuery ? '1' : '0',
].join('|')
}
type ServersContextType = { type ServersContextType = {
servers: Server[] servers: Server[]
onlineServerIds: string[]
healthChecksComplete: boolean
activeServerId: string | null activeServerId: string | null
setActiveServerId: (id: string | null) => void setActiveServerId: (id: string | null) => void
addServer: (s: Omit<Server, 'id' | 'lastTest'>) => Server addServer: (s: Omit<Server, 'id' | 'lastTest'>) => Server
@@ -44,6 +56,7 @@ function saveServers(servers: Server[]) {
export function ServersProvider({ children }: { children: React.ReactNode }) { export function ServersProvider({ children }: { children: React.ReactNode }) {
const [servers, setServers] = useState<Server[]>(() => loadServers()) const [servers, setServers] = useState<Server[]>(() => loadServers())
const [verifiedConnectivitySignatures, setVerifiedConnectivitySignatures] = useState<Record<string, string>>({})
const [activeServerId, setActiveServerIdState] = useState<string | null>(() => { const [activeServerId, setActiveServerIdState] = useState<string | null>(() => {
try { try {
return localStorage.getItem(ACTIVE_KEY) return localStorage.getItem(ACTIVE_KEY)
@@ -51,6 +64,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
return null return null
} }
}) })
const connectivityRequestsRef = useRef<Record<string, Promise<ConnectivityResult>>>({})
useEffect(() => saveServers(servers), [servers]) useEffect(() => saveServers(servers), [servers])
@@ -102,25 +116,91 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
const removeServer = useCallback((id: string) => { const removeServer = useCallback((id: string) => {
setServers((prev) => prev.filter((s) => s.id !== id)) setServers((prev) => prev.filter((s) => s.id !== id))
setVerifiedConnectivitySignatures((prev) => {
if (!(id in prev)) return prev
const next = { ...prev }
delete next[id]
return next
})
if (activeServerId === id) setActiveServerId(null) if (activeServerId === id) setActiveServerId(null)
}, [activeServerId, setActiveServerId]) }, [activeServerId, setActiveServerId])
const runPersistedConnectivityTest = useCallback((server: Server) => {
const signature = buildConnectivitySignature(server)
const requestKey = `${server.id}|${signature}`
const existingRequest = connectivityRequestsRef.current[requestKey]
if (existingRequest) return existingRequest
const request = (async () => {
const client = new HydrusClient(server)
const res = await client.testConnectivity()
updateServer(server.id, { lastTest: { ...res, timestamp: Date.now() } })
setVerifiedConnectivitySignatures((prev) => {
if (prev[server.id] === signature) return prev
return { ...prev, [server.id]: signature }
})
return res
})()
connectivityRequestsRef.current[requestKey] = request
void request.finally(() => {
delete connectivityRequestsRef.current[requestKey]
})
return request
}, [updateServer])
useEffect(() => {
const activeServerIds = new Set(servers.map((server) => server.id))
setVerifiedConnectivitySignatures((prev) => {
const nextEntries = Object.entries(prev).filter(([serverId]) => activeServerIds.has(serverId))
if (nextEntries.length === Object.keys(prev).length) return prev
return Object.fromEntries(nextEntries)
})
Object.keys(connectivityRequestsRef.current).forEach((requestKey) => {
const [serverId] = requestKey.split('|')
if (!activeServerIds.has(serverId)) {
delete connectivityRequestsRef.current[requestKey]
}
})
for (const server of servers) {
const signature = buildConnectivitySignature(server)
if (verifiedConnectivitySignatures[server.id] === signature) continue
void runPersistedConnectivityTest(server)
}
}, [servers, runPersistedConnectivityTest, verifiedConnectivitySignatures])
const testServerById = useCallback(async (id: string) => { const testServerById = useCallback(async (id: string) => {
const server = servers.find((s) => s.id === id) const server = servers.find((s) => s.id === id)
if (!server) return { ok: false, message: 'Server not found' } as ConnectivityResult if (!server) return { ok: false, message: 'Server not found' } as ConnectivityResult
const client = new HydrusClient(server) return runPersistedConnectivityTest(server)
const res = await client.testConnectivity() }, [runPersistedConnectivityTest, servers])
updateServer(id, { lastTest: { ...res, timestamp: Date.now() } })
return res
}, [servers, updateServer])
const testServerConfig = useCallback(async (cfg: Omit<Server, 'id' | 'lastTest'>) => { const testServerConfig = useCallback(async (cfg: Omit<Server, 'id' | 'lastTest'>) => {
const client = new HydrusClient(cfg as ServerConfig) const client = new HydrusClient(cfg as ServerConfig)
return client.testConnectivity() return client.testConnectivity()
}, []) }, [])
const healthChecksComplete = useMemo(() => servers.every((server) => {
const signature = buildConnectivitySignature(server)
return verifiedConnectivitySignatures[server.id] === signature
}), [servers, verifiedConnectivitySignatures])
const onlineServerIds = useMemo(() => servers
.filter((server) => {
const signature = buildConnectivitySignature(server)
return verifiedConnectivitySignatures[server.id] === signature && server.lastTest?.ok === true
})
.map((server) => server.id), [servers, verifiedConnectivitySignatures])
const value = useMemo(() => ({ const value = useMemo(() => ({
servers, servers,
onlineServerIds,
healthChecksComplete,
activeServerId, activeServerId,
setActiveServerId, setActiveServerId,
addServer, addServer,
@@ -128,7 +208,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
removeServer, removeServer,
testServerById, testServerById,
testServerConfig, testServerConfig,
}), [servers, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig]) }), [servers, onlineServerIds, healthChecksComplete, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig])
return ( return (
<ServersContext.Provider value={value}> <ServersContext.Provider value={value}>
+142 -117
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, Typography, useMediaQuery } from '@mui/material' import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Tabs, Typography, useMediaQuery } from '@mui/material'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import { useServers } from '../context/ServersContext' import { useServers } from '../context/ServersContext'
@@ -229,19 +229,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const theme = useTheme() const theme = useTheme()
const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm'))
const { servers } = useServers() const { servers, onlineServerIds, healthChecksComplete } = useServers()
const hasServers = servers.length > 0 const hasServers = servers.length > 0
const onlineServerIdsKey = useMemo(() => [...onlineServerIds].sort().join(','), [onlineServerIds])
const serverCacheKey = buildLibraryCacheKey(servers) const serverCacheKey = buildLibraryCacheKey(servers)
const searchCacheRef = React.useRef<Record<string, number[]>>({}) const searchCacheRef = React.useRef<Record<string, number[]>>({})
const trackCacheRef = React.useRef<Record<string, Track>>({}) const trackCacheRef = React.useRef<Record<string, Track>>({})
const albumTracksRef = React.useRef<Record<string, Track[]>>({})
const playMetadataAbortRef = useRef<AbortController | null>(null) const playMetadataAbortRef = useRef<AbortController | null>(null)
const persistTimeoutRef = useRef<number | null>(null) const persistTimeoutRef = useRef<number | null>(null)
const detailsAbortRef = useRef<AbortController | null>(null) const detailsAbortRef = useRef<AbortController | null>(null)
const longPressTimerRef = useRef<number | null>(null) const longPressTimerRef = useRef<number | null>(null)
const longPressTriggeredRef = useRef(false) const longPressTriggeredRef = useRef(false)
const [albumFilter, setAlbumFilter] = useState<string | null>(null)
const [artistFilter, setArtistFilter] = useState<string | null>(null)
const [detailsTrack, setDetailsTrack] = useState<Track | null>(null) const [detailsTrack, setDetailsTrack] = useState<Track | null>(null)
const [detailsData, setDetailsData] = useState<HydrusFileDetails | null>(null) const [detailsData, setDetailsData] = useState<HydrusFileDetails | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false)
@@ -273,10 +271,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return bestValue || null return bestValue || null
} }
function normalizeNamespaceValue(value?: string | null) {
return (value || '').trim().toLocaleLowerCase()
}
function matchesMediaSection(track: Track, section: MediaSection) { function matchesMediaSection(track: Track, section: MediaSection) {
if (section === 'all') return true if (section === 'all') return true
if (track.mediaKind) return track.mediaKind === section if (track.mediaKind) return track.mediaKind === section
@@ -501,6 +495,47 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}) })
} }
function formatNamespacedSearchValue(value: string) {
const trimmed = value.trim()
if (!trimmed) return ''
const escaped = trimmed
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
return /[,"]/.test(trimmed) ? `"${escaped}"` : escaped
}
function getNamespacedQueryValue(rawQuery: string, namespace: 'album' | 'artist') {
const clauses = splitSearchClauses(rawQuery)
for (let index = clauses.length - 1; index >= 0; index -= 1) {
const namespaceMatch = clauses[index].match(SEARCH_NAMESPACE_PATTERN)
if (!namespaceMatch) continue
if (namespaceMatch[1].toLowerCase() !== namespace) continue
const normalized = normalizeSearchPattern(namespaceMatch[2])
return normalized || null
}
return null
}
function applyLibraryQueryFilter(namespace: 'album' | 'artist', value: string) {
const formattedValue = formatNamespacedSearchValue(value)
const clauses = splitSearchClauses(query)
const retainedClauses = clauses.filter((clause) => {
const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN)
if (!namespaceMatch) return true
const clauseNamespace = namespaceMatch[1].toLowerCase()
return clauseNamespace !== 'album' && clauseNamespace !== 'artist'
})
const nextQuery = [...retainedClauses, `${namespace}:${formattedValue}`].join(', ')
onQueryChange(nextQuery)
}
function getTrackExtension(track: Track, details?: HydrusFileDetails | null) { function getTrackExtension(track: Track, details?: HydrusFileDetails | null) {
if (details?.extension) return details.extension if (details?.extension) return details.extension
const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i) const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i)
@@ -523,20 +558,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
schedulePersistLibraryCache() schedulePersistLibraryCache()
} }
function addTrackToAlbumCache(track: Track) {
if (!track.album) return
const cacheKey = getTrackCacheKey(track.serverId, track.fileId)
if (!cacheKey) return
const currentTracks = albumTracksRef.current[track.album] || []
const existingIndex = currentTracks.findIndex((candidate) => getTrackCacheKey(candidate.serverId, candidate.fileId) === cacheKey)
if (existingIndex >= 0) currentTracks[existingIndex] = { ...currentTracks[existingIndex], ...track }
else currentTracks.push(track)
albumTracksRef.current[track.album] = currentTracks
}
function schedulePersistLibraryCache() { function schedulePersistLibraryCache() {
if (!serverCacheKey || typeof window === 'undefined') return if (!serverCacheKey || typeof window === 'undefined') return
@@ -614,7 +635,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (!hasServers || !serverCacheKey) { if (!hasServers || !serverCacheKey) {
trackCacheRef.current = {} trackCacheRef.current = {}
searchCacheRef.current = {} searchCacheRef.current = {}
albumTracksRef.current = {} setResults([])
setError(null)
setLoading(false)
return
}
if (!healthChecksComplete) {
trackCacheRef.current = {}
searchCacheRef.current = {}
setResults([])
setError(null)
setLoading(true)
return
}
if (onlineServerIds.length === 0) {
trackCacheRef.current = {}
searchCacheRef.current = {}
setResults([]) setResults([])
setError(null) setError(null)
setLoading(false) setLoading(false)
@@ -626,15 +664,16 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (cancelled) return if (cancelled) return
trackCacheRef.current = {} trackCacheRef.current = {}
albumTracksRef.current = {}
searchCacheRef.current = snapshot?.searchCache || {} searchCacheRef.current = snapshot?.searchCache || {}
const onlineServerIdSet = new Set(onlineServerIds)
let localCounter = Date.now() let localCounter = Date.now()
const hydratedTracks = (snapshot?.tracks || []).map((track) => ({ ...track, id: ++localCounter })) const hydratedTracks = (snapshot?.tracks || [])
.filter((track) => track.serverId && onlineServerIdSet.has(track.serverId))
.map((track) => ({ ...track, id: ++localCounter }))
for (const track of hydratedTracks) { for (const track of hydratedTracks) {
const cacheKey = getTrackCacheKey(track.serverId, track.fileId) const cacheKey = getTrackCacheKey(track.serverId, track.fileId)
if (cacheKey) trackCacheRef.current[cacheKey] = track if (cacheKey) trackCacheRef.current[cacheKey] = track
addTrackToAlbumCache(track)
} }
if (!cancelled) { if (!cancelled) {
@@ -645,7 +684,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (cancelled) return if (cancelled) return
trackCacheRef.current = {} trackCacheRef.current = {}
searchCacheRef.current = {} searchCacheRef.current = {}
albumTracksRef.current = {}
setResults([]) setResults([])
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -681,7 +719,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener) window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
} }
} }
}, [hasServers, serverCacheKey]) }, [hasServers, healthChecksComplete, onlineServerIdsKey, serverCacheKey])
useEffect(() => { useEffect(() => {
syncingSectionViewRef.current = true syncingSectionViewRef.current = true
@@ -721,23 +759,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}) })
}, [sortBy, sortDirection]) }, [sortBy, sortDirection])
useEffect(() => {
if (view !== 'tracks') {
setAlbumFilter(null)
setArtistFilter(null)
}
}, [view])
useEffect(() => {
if (query.trim().length > 0) {
setAlbumFilter(null)
setArtistFilter(null)
}
}, [query])
useEffect(() => { useEffect(() => {
setVisibleCount(RESULTS_PAGE_SIZE) setVisibleCount(RESULTS_PAGE_SIZE)
}, [view, query, albumFilter, artistFilter]) }, [view, query])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -790,7 +814,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (nextTrack !== track) { if (nextTrack !== track) {
setResults((prev) => prev.map((r) => (r.id === track.id ? { ...r, ...nextTrack } : r))) setResults((prev) => prev.map((r) => (r.id === track.id ? { ...r, ...nextTrack } : r)))
cacheTracks([nextTrack]) cacheTracks([nextTrack])
addTrackToAlbumCache(nextTrack)
track = nextTrack track = nextTrack
} }
} }
@@ -819,8 +842,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
} }
const handleDetailsTagSearch = (tag: string) => { const handleDetailsTagSearch = (tag: string) => {
setAlbumFilter(null)
setArtistFilter(null)
setView('tracks') setView('tracks')
onQueryChange(tag) onQueryChange(tag)
closeDetails() closeDetails()
@@ -938,28 +959,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return visibleGroups return visibleGroups
} }
const filteredAlbumTracks = useMemo(() => {
if (!albumFilter) return []
return filterTracksForSection(albumTracksRef.current[albumFilter] || results.filter((track) => track.album === albumFilter), mediaSection)
}, [albumFilter, mediaSection, results])
const filteredArtistTracks = useMemo(() => {
if (!artistFilter) return []
return filterTracksForSection(Object.values(trackCacheRef.current), mediaSection)
.filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(artistFilter))
}, [artistFilter, mediaSection, results])
const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results]) const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results])
const activeAlbumQuery = useMemo(() => getNamespacedQueryValue(query, 'album'), [query])
const activeArtistQuery = useMemo(() => getNamespacedQueryValue(query, 'artist'), [query])
const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks]) const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks])
const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view]) const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'album'), [queryMatchedSectionTracks]) const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'album'), [sectionTracks])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'artist'), [queryMatchedSectionTracks]) const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'artist'), [sectionTracks])
const baseArtistGroups = useMemo(() => { const baseArtistGroups = useMemo(() => {
if (!hasArtistsView || albumFilter || artistFilter) return [] if (!hasArtistsView || activeAlbumQuery || activeArtistQuery) return []
return buildArtistGroupsFromTracks(currentTrackResults) return buildArtistGroupsFromTracks(currentTrackResults)
}, [albumFilter, artistFilter, currentTrackResults, hasArtistsView]) }, [activeAlbumQuery, activeArtistQuery, currentTrackResults, hasArtistsView])
const sortedFilteredAlbumTracks = useMemo(() => sortTracks(filteredAlbumTracks, sortBy as TrackSortField, sortDirection), [filteredAlbumTracks, sortBy, sortDirection])
const sortedFilteredArtistTracks = useMemo(() => sortTracks(filteredArtistTracks, sortBy as TrackSortField, sortDirection), [filteredArtistTracks, sortBy, sortDirection])
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection]) const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection])
const sortedArtistGroups = useMemo(() => { const sortedArtistGroups = useMemo(() => {
if (!hasArtistsView) return [] if (!hasArtistsView) return []
@@ -970,21 +980,16 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, [baseArtistGroups, hasArtistsView, sortDirection]) }, [baseArtistGroups, hasArtistsView, sortDirection])
const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection]) const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection])
const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection]) const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection])
const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !albumFilter && !artistFilter && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist' const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !activeAlbumQuery && !activeArtistQuery && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist'
const visibleFilteredAlbumTracks = useMemo(() => sortedFilteredAlbumTracks.slice(0, visibleCount), [sortedFilteredAlbumTracks, visibleCount])
const visibleFilteredArtistTracks = useMemo(() => sortedFilteredArtistTracks.slice(0, visibleCount), [sortedFilteredArtistTracks, visibleCount])
const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups]) const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups])
const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount]) const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount])
const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount]) const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount])
const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount]) const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount])
const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount]) const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount])
const showToolbarSortControls = effectiveDisplayMode === 'table' && isCompactTableLayout
const canLoadMore = isTrackLikeView const canLoadMore = isTrackLikeView
? albumFilter ? shouldShowGroupedArtists
? visibleCount < sortedFilteredAlbumTracks.length
: artistFilter
? visibleCount < sortedFilteredArtistTracks.length
: shouldShowGroupedArtists
? visibleCount < totalArtistGroupTracks ? visibleCount < totalArtistGroupTracks
: visibleCount < sortedCurrentTrackResults.length : visibleCount < sortedCurrentTrackResults.length
: view === 'albums' : view === 'albums'
@@ -1026,19 +1031,57 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
) )
const openAlbumEntry = (name: string) => { const openAlbumEntry = (name: string) => {
setArtistFilter(null)
onQueryChange('')
setAlbumFilter(name)
setView('tracks') setView('tracks')
applyLibraryQueryFilter('album', name)
} }
const openArtistEntry = (name: string) => { const openArtistEntry = (name: string) => {
setAlbumFilter(null)
onQueryChange('')
setArtistFilter(name)
setView('tracks') setView('tracks')
applyLibraryQueryFilter('artist', name)
} }
const handleSortRequest = (field: SortField) => {
if (sortBy === field) {
setSortDirection((prev) => prev === 'asc' ? 'desc' : 'asc')
return
}
setSortBy(field)
setSortDirection('asc')
}
const renderTrackHeaderCell = (
label: string,
field: TrackSortField,
options?: { width?: string; align?: 'left' | 'right'; sx?: Record<string, unknown> }
) => (
<TableCell align={options?.align} sx={{ width: options?.width, ...(options?.sx || {}) }}>
<TableSortLabel
active={sortBy === field}
direction={sortBy === field ? sortDirection : 'asc'}
onClick={() => handleSortRequest(field)}
>
{label}
</TableSortLabel>
</TableCell>
)
const renderEntryHeaderCell = (
label: string,
field: EntrySortField,
options?: { width?: string; align?: 'left' | 'right'; sx?: Record<string, unknown> }
) => (
<TableCell align={options?.align} sx={{ width: options?.width, ...(options?.sx || {}) }}>
<TableSortLabel
active={sortBy === field}
direction={sortBy === field ? sortDirection : 'asc'}
onClick={() => handleSortRequest(field)}
>
{label}
</TableSortLabel>
</TableCell>
)
const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => ( const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => (
isCompactTableLayout ? ( isCompactTableLayout ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
@@ -1064,11 +1107,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}> <Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ width: '38%' }}>Track</TableCell> {renderTrackHeaderCell('Track', 'title', { width: '38%' })}
<TableCell sx={{ width: '22%', display: { xs: 'none', md: 'table-cell' } }}>Artist</TableCell> {renderTrackHeaderCell('Artist', 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })}
<TableCell sx={{ width: '22%', display: { xs: 'none', sm: 'table-cell' } }}>Album</TableCell> {renderTrackHeaderCell('Album', 'album', { width: '22%', sx: { display: { xs: 'none', sm: 'table-cell' } } })}
<TableCell sx={{ width: '12%', display: { xs: 'none', lg: 'table-cell' } }}>Server</TableCell> {renderTrackHeaderCell('Server', 'server', { width: '12%', sx: { display: { xs: 'none', lg: 'table-cell' } } })}
<TableCell align="right" sx={{ width: '6%' }}>ID</TableCell> {renderTrackHeaderCell('ID', 'fileId', { width: '6%', align: 'right' })}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -1126,8 +1169,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}> <Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ width: '70%' }}>{kind === 'album' ? 'Album' : 'Artist'}</TableCell> {renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })}
<TableCell align="right" sx={{ width: '15%' }}>Items</TableCell> {renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })}
<TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell> <TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -1187,6 +1230,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Alert> </Alert>
)} )}
{hasServers && healthChecksComplete && onlineServerIds.length === 0 && (
<Alert severity="info" sx={{ mt: 2 }}>
No Hydrus servers are currently online. Cached results stay hidden until a server passes a connection test.
</Alert>
)}
{error && ( {error && (
<Alert severity="error" sx={{ mt: 2 }}> <Alert severity="error" sx={{ mt: 2 }}>
{error} {error}
@@ -1194,6 +1243,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
)} )}
<Box sx={{ mt: 2, mb: 2, display: 'flex', gap: 1, flexWrap: 'nowrap', alignItems: 'center', overflowX: 'auto', pb: 0.5 }}> <Box sx={{ mt: 2, mb: 2, display: 'flex', gap: 1, flexWrap: 'nowrap', alignItems: 'center', overflowX: 'auto', pb: 0.5 }}>
{showToolbarSortControls && (
<>
<FormControl size="small" sx={{ minWidth: 132, flexShrink: 0 }}> <FormControl size="small" sx={{ minWidth: 132, flexShrink: 0 }}>
<InputLabel id="library-sort-by-label">Sort</InputLabel> <InputLabel id="library-sort-by-label">Sort</InputLabel>
<Select labelId="library-sort-by-label" value={sortBy} label="Sort by" onChange={(event) => setSortBy(event.target.value as SortField)}> <Select labelId="library-sort-by-label" value={sortBy} label="Sort by" onChange={(event) => setSortBy(event.target.value as SortField)}>
@@ -1209,11 +1260,13 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<MenuItem value="desc">Desc</MenuItem> <MenuItem value="desc">Desc</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</>
)}
<Chip <Chip
size="small" size="small"
sx={{ flexShrink: 0, ml: 'auto' }} sx={{ flexShrink: 0, ml: 'auto' }}
label={isTrackLikeView label={isTrackLikeView
? `${albumFilter ? sortedFilteredAlbumTracks.length : artistFilter ? sortedFilteredArtistTracks.length : sortedCurrentTrackResults.length} items` ? `${sortedCurrentTrackResults.length} items`
: `${view === 'albums' ? sortedAlbums.length : sortedArtists.length} items`} : `${view === 'albums' ? sortedAlbums.length : sortedArtists.length} items`}
/> />
</Box> </Box>
@@ -1221,35 +1274,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box> <Box>
{(view === 'tracks' || view === 'text' || view === 'data') && ( {(view === 'tracks' || view === 'text' || view === 'data') && (
<> <>
{albumFilter ? ( {shouldShowGroupedArtists ? (
<Box>
<Box sx={{ mb: 2, display: 'flex', alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h6">Album: {albumFilter}</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button size="small" onClick={() => setAlbumFilter(null)} sx={{ mr: 1 }}>Clear</Button>
{hasAlbumsView && <Button size="small" onClick={() => { setView('albums'); onQueryChange(albumFilter || '') }}>Open in Albums</Button>}
</Box>
</Box>
{effectiveDisplayMode === 'table'
? renderTrackTable(visibleFilteredAlbumTracks, { showAlbum: true })
: renderTrackGrid(visibleFilteredAlbumTracks, { showAlbum: true })}
</Box>
) : artistFilter ? (
<Box>
<Box sx={{ mb: 2, display: 'flex', alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h6">Artist: {artistFilter}</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button size="small" onClick={() => setArtistFilter(null)} sx={{ mr: 1 }}>Clear</Button>
{hasArtistsView && <Button size="small" onClick={() => { setView('artists'); onQueryChange(artistFilter || '') }}>Open in Artists</Button>}
</Box>
</Box>
{effectiveDisplayMode === 'table'
? renderTrackTable(visibleFilteredArtistTracks, { showAlbum: true })
: renderTrackGrid(visibleFilteredArtistTracks, { showAlbum: true })}
</Box>
) : shouldShowGroupedArtists ? (
visibleArtistGroups.map((g) => { visibleArtistGroups.map((g) => {
const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean))) const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean)))
return ( return (
@@ -1260,7 +1285,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{albumNames.map((a) => { {albumNames.map((a) => {
const count = g.tracks.filter((t: any) => (t.album || '') === a).length const count = g.tracks.filter((t: any) => (t.album || '') === a).length
return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => { setArtistFilter(null); onQueryChange(''); setAlbumFilter(a); setView('tracks') }} /> return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => openAlbumEntry(a)} />
})} })}
</Box> </Box>
)} )}
@@ -1342,7 +1367,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Box> </Box>
)} )}
{loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && !albumFilter && !artistFilter && ( {loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && (
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" sx={{ mt: 2 }}>
Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}... Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}...
</Alert> </Alert>