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() {
const { servers, updateServer } = useServers()
const { servers, onlineServerIds, healthChecksComplete, updateServer } = useServers()
const lastSyncSignatureRef = useRef<string | null>(null)
const syncSignature = useMemo(() => servers
.filter((server) => onlineServerIds.includes(server.id))
.map((server) => [
server.id,
server.host,
@@ -335,15 +336,18 @@ function StartupLibraryCacheSync() {
server.ssl,
server.forceApiKeyInQuery,
].join('|'))
.join(','), [servers])
.join(','), [onlineServerIds, servers])
useEffect(() => {
if (!servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return
if (!healthChecksComplete || !servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return
lastSyncSignatureRef.current = syncSignature
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) => {
if (cancelled) return
@@ -358,7 +362,7 @@ function StartupLibraryCacheSync() {
return () => {
cancelled = true
}
}, [servers, syncSignature, updateServer])
}, [healthChecksComplete, onlineServerIds, servers, syncSignature, updateServer])
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 { HydrusClient, makeId } from '../api/hydrusClient'
import type { ServerSyncSummary } from '../types'
@@ -11,8 +11,20 @@ export type Server = ServerConfig & {
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 = {
servers: Server[]
onlineServerIds: string[]
healthChecksComplete: boolean
activeServerId: string | null
setActiveServerId: (id: string | null) => void
addServer: (s: Omit<Server, 'id' | 'lastTest'>) => Server
@@ -44,6 +56,7 @@ function saveServers(servers: Server[]) {
export function ServersProvider({ children }: { children: React.ReactNode }) {
const [servers, setServers] = useState<Server[]>(() => loadServers())
const [verifiedConnectivitySignatures, setVerifiedConnectivitySignatures] = useState<Record<string, string>>({})
const [activeServerId, setActiveServerIdState] = useState<string | null>(() => {
try {
return localStorage.getItem(ACTIVE_KEY)
@@ -51,6 +64,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
return null
}
})
const connectivityRequestsRef = useRef<Record<string, Promise<ConnectivityResult>>>({})
useEffect(() => saveServers(servers), [servers])
@@ -102,25 +116,91 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
const removeServer = useCallback((id: string) => {
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)
}, [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 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])
return runPersistedConnectivityTest(server)
}, [runPersistedConnectivityTest, servers])
const testServerConfig = useCallback(async (cfg: Omit<Server, 'id' | 'lastTest'>) => {
const client = new HydrusClient(cfg as ServerConfig)
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(() => ({
servers,
onlineServerIds,
healthChecksComplete,
activeServerId,
setActiveServerId,
addServer,
@@ -128,7 +208,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
removeServer,
testServerById,
testServerConfig,
}), [servers, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig])
}), [servers, onlineServerIds, healthChecksComplete, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig])
return (
<ServersContext.Provider value={value}>
+144 -119
View File
@@ -1,5 +1,5 @@
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 DownloadIcon from '@mui/icons-material/Download'
import { useServers } from '../context/ServersContext'
@@ -229,19 +229,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const theme = useTheme()
const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm'))
const { servers } = useServers()
const { servers, onlineServerIds, healthChecksComplete } = useServers()
const hasServers = servers.length > 0
const onlineServerIdsKey = useMemo(() => [...onlineServerIds].sort().join(','), [onlineServerIds])
const serverCacheKey = buildLibraryCacheKey(servers)
const searchCacheRef = React.useRef<Record<string, number[]>>({})
const trackCacheRef = React.useRef<Record<string, Track>>({})
const albumTracksRef = React.useRef<Record<string, Track[]>>({})
const playMetadataAbortRef = useRef<AbortController | null>(null)
const persistTimeoutRef = useRef<number | null>(null)
const detailsAbortRef = useRef<AbortController | null>(null)
const longPressTimerRef = useRef<number | null>(null)
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 [detailsData, setDetailsData] = useState<HydrusFileDetails | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
@@ -273,10 +271,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return bestValue || null
}
function normalizeNamespaceValue(value?: string | null) {
return (value || '').trim().toLocaleLowerCase()
}
function matchesMediaSection(track: Track, section: MediaSection) {
if (section === 'all') return true
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) {
if (details?.extension) return details.extension
const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i)
@@ -523,20 +558,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
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() {
if (!serverCacheKey || typeof window === 'undefined') return
@@ -614,7 +635,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (!hasServers || !serverCacheKey) {
trackCacheRef.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([])
setError(null)
setLoading(false)
@@ -626,15 +664,16 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (cancelled) return
trackCacheRef.current = {}
albumTracksRef.current = {}
searchCacheRef.current = snapshot?.searchCache || {}
const onlineServerIdSet = new Set(onlineServerIds)
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) {
const cacheKey = getTrackCacheKey(track.serverId, track.fileId)
if (cacheKey) trackCacheRef.current[cacheKey] = track
addTrackToAlbumCache(track)
}
if (!cancelled) {
@@ -645,7 +684,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (cancelled) return
trackCacheRef.current = {}
searchCacheRef.current = {}
albumTracksRef.current = {}
setResults([])
} finally {
if (!cancelled) {
@@ -681,7 +719,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
}
}
}, [hasServers, serverCacheKey])
}, [hasServers, healthChecksComplete, onlineServerIdsKey, serverCacheKey])
useEffect(() => {
syncingSectionViewRef.current = true
@@ -721,23 +759,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
})
}, [sortBy, sortDirection])
useEffect(() => {
if (view !== 'tracks') {
setAlbumFilter(null)
setArtistFilter(null)
}
}, [view])
useEffect(() => {
if (query.trim().length > 0) {
setAlbumFilter(null)
setArtistFilter(null)
}
}, [query])
useEffect(() => {
setVisibleCount(RESULTS_PAGE_SIZE)
}, [view, query, albumFilter, artistFilter])
}, [view, query])
useEffect(() => {
return () => {
@@ -790,7 +814,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (nextTrack !== track) {
setResults((prev) => prev.map((r) => (r.id === track.id ? { ...r, ...nextTrack } : r)))
cacheTracks([nextTrack])
addTrackToAlbumCache(nextTrack)
track = nextTrack
}
}
@@ -819,8 +842,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}
const handleDetailsTagSearch = (tag: string) => {
setAlbumFilter(null)
setArtistFilter(null)
setView('tracks')
onQueryChange(tag)
closeDetails()
@@ -938,28 +959,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
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 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 currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'album'), [queryMatchedSectionTracks])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'artist'), [queryMatchedSectionTracks])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'album'), [sectionTracks])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'artist'), [sectionTracks])
const baseArtistGroups = useMemo(() => {
if (!hasArtistsView || albumFilter || artistFilter) return []
if (!hasArtistsView || activeAlbumQuery || activeArtistQuery) return []
return buildArtistGroupsFromTracks(currentTrackResults)
}, [albumFilter, artistFilter, 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])
}, [activeAlbumQuery, activeArtistQuery, currentTrackResults, hasArtistsView])
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection])
const sortedArtistGroups = useMemo(() => {
if (!hasArtistsView) return []
@@ -970,23 +980,18 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, [baseArtistGroups, hasArtistsView, 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 shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !albumFilter && !artistFilter && 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 shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !activeAlbumQuery && !activeArtistQuery && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist'
const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups])
const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount])
const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount])
const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount])
const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount])
const showToolbarSortControls = effectiveDisplayMode === 'table' && isCompactTableLayout
const canLoadMore = isTrackLikeView
? albumFilter
? visibleCount < sortedFilteredAlbumTracks.length
: artistFilter
? visibleCount < sortedFilteredArtistTracks.length
: shouldShowGroupedArtists
? visibleCount < totalArtistGroupTracks
: visibleCount < sortedCurrentTrackResults.length
? shouldShowGroupedArtists
? visibleCount < totalArtistGroupTracks
: visibleCount < sortedCurrentTrackResults.length
: view === 'albums'
? visibleCount < sortedAlbums.length
: visibleCount < sortedArtists.length
@@ -1026,19 +1031,57 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
)
const openAlbumEntry = (name: string) => {
setArtistFilter(null)
onQueryChange('')
setAlbumFilter(name)
setView('tracks')
applyLibraryQueryFilter('album', name)
}
const openArtistEntry = (name: string) => {
setAlbumFilter(null)
onQueryChange('')
setArtistFilter(name)
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 }) => (
isCompactTableLayout ? (
<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' }}>
<TableHead>
<TableRow>
<TableCell sx={{ width: '38%' }}>Track</TableCell>
<TableCell sx={{ width: '22%', display: { xs: 'none', md: 'table-cell' } }}>Artist</TableCell>
<TableCell sx={{ width: '22%', display: { xs: 'none', sm: 'table-cell' } }}>Album</TableCell>
<TableCell sx={{ width: '12%', display: { xs: 'none', lg: 'table-cell' } }}>Server</TableCell>
<TableCell align="right" sx={{ width: '6%' }}>ID</TableCell>
{renderTrackHeaderCell('Track', 'title', { width: '38%' })}
{renderTrackHeaderCell('Artist', 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })}
{renderTrackHeaderCell('Album', 'album', { width: '22%', sx: { display: { xs: 'none', sm: 'table-cell' } } })}
{renderTrackHeaderCell('Server', 'server', { width: '12%', sx: { display: { xs: 'none', lg: 'table-cell' } } })}
{renderTrackHeaderCell('ID', 'fileId', { width: '6%', align: 'right' })}
</TableRow>
</TableHead>
<TableBody>
@@ -1126,8 +1169,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead>
<TableRow>
<TableCell sx={{ width: '70%' }}>{kind === 'album' ? 'Album' : 'Artist'}</TableCell>
<TableCell align="right" sx={{ width: '15%' }}>Items</TableCell>
{renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })}
{renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })}
<TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell>
</TableRow>
</TableHead>
@@ -1187,6 +1230,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</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 && (
<Alert severity="error" sx={{ mt: 2 }}>
{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 }}>
{showToolbarSortControls && (
<>
<FormControl size="small" sx={{ minWidth: 132, flexShrink: 0 }}>
<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)}>
@@ -1209,11 +1260,13 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<MenuItem value="desc">Desc</MenuItem>
</Select>
</FormControl>
</>
)}
<Chip
size="small"
sx={{ flexShrink: 0, ml: 'auto' }}
label={isTrackLikeView
? `${albumFilter ? sortedFilteredAlbumTracks.length : artistFilter ? sortedFilteredArtistTracks.length : sortedCurrentTrackResults.length} items`
? `${sortedCurrentTrackResults.length} items`
: `${view === 'albums' ? sortedAlbums.length : sortedArtists.length} items`}
/>
</Box>
@@ -1221,35 +1274,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box>
{(view === 'tracks' || view === 'text' || view === 'data') && (
<>
{albumFilter ? (
<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 ? (
{shouldShowGroupedArtists ? (
visibleArtistGroups.map((g) => {
const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean)))
return (
@@ -1260,7 +1285,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{albumNames.map((a) => {
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>
)}
@@ -1342,7 +1367,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</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 }}>
Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}...
</Alert>