updated library and settings page

This commit is contained in:
2026-05-03 21:03:02 -07:00
parent d58686bce9
commit 38e825cf28
2 changed files with 269 additions and 126 deletions
+200 -86
View File
@@ -76,6 +76,14 @@ type SortOption = {
label: string
}
type TrackNamespacePresentation = {
trackLabel: string
primaryLabel: string
secondaryLabel: string
primaryNamespace: string
secondaryNamespace: string
}
const SECTION_CONFIG: Record<MediaSection, { label: string; systemPredicate?: string; views: Array<{ id: LibraryView; label: string }> }> = {
all: {
label: 'All Files',
@@ -96,9 +104,9 @@ const SECTION_CONFIG: Record<MediaSection, { label: string; systemPredicate?: st
label: 'Video',
systemPredicate: 'system:filetype = video',
views: [
{ id: 'tracks', label: 'Videos' },
{ id: 'albums', label: 'Albums' },
{ id: 'artists', label: 'Artists' },
{ id: 'artists', label: 'Series' },
{ id: 'albums', label: 'Season' },
{ id: 'tracks', label: 'Episode' },
],
},
image: {
@@ -126,14 +134,6 @@ type AlbumEntry = {
totalCount: number
}
const TRACK_SORT_OPTIONS: SortOption[] = [
{ id: 'artist', label: 'Artist' },
{ id: 'album', label: 'Album' },
{ id: 'title', label: 'Title' },
{ id: 'server', label: 'Server' },
{ id: 'fileId', label: 'File ID' },
]
const ENTRY_SORT_OPTIONS: SortOption[] = [
{ id: 'name', label: 'Name' },
{ id: 'count', label: 'Items' },
@@ -143,25 +143,93 @@ function compareText(left?: string | null, right?: string | null) {
return (left || '').localeCompare((right || ''), undefined, { numeric: true, sensitivity: 'base' })
}
function getTrackNamespacePresentation(section: MediaSection): TrackNamespacePresentation {
if (section === 'video') {
return {
trackLabel: 'Episode',
primaryLabel: 'Series',
secondaryLabel: 'Season',
primaryNamespace: 'series',
secondaryNamespace: 'season',
}
}
return {
trackLabel: 'Track',
primaryLabel: 'Artist',
secondaryLabel: 'Album',
primaryNamespace: 'artist',
secondaryNamespace: 'album',
}
}
function extractNamespaceValue(tags: string[] | null | undefined, namespace: string): string | null {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${namespace.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
}
return bestValue || null
}
function deriveTrackPrimaryValue(track: Track, section: MediaSection) {
if (section === 'video') return extractNamespaceValue(track.tags, 'series') || track.artist || null
return track.artist || null
}
function deriveTrackSecondaryValue(track: Track, section: MediaSection) {
if (section === 'video') return extractNamespaceValue(track.tags, 'season') || track.album || null
return track.album || null
}
function getTrackSortOptions(section: MediaSection): SortOption[] {
const namespacePresentation = getTrackNamespacePresentation(section)
return [
{ id: 'artist', label: namespacePresentation.primaryLabel },
{ id: 'album', label: namespacePresentation.secondaryLabel },
{ id: 'title', label: namespacePresentation.trackLabel },
{ id: 'server', label: 'Server' },
{ id: 'fileId', label: 'File ID' },
]
}
function getTrackDisplayTitle(track: Track) {
return track.title?.trim() || (track.fileId != null ? `File ${track.fileId}` : 'Untitled')
}
function sortTracks(tracks: Track[], sortBy: TrackSortField, sortDirection: SortDirection) {
function sortTracks(
tracks: Track[],
sortBy: TrackSortField,
sortDirection: SortDirection,
options?: {
getPrimaryValue?: (track: Track) => string | null | undefined
getSecondaryValue?: (track: Track) => string | null | undefined
}
) {
const direction = sortDirection === 'asc' ? 1 : -1
const getPrimaryValue = options?.getPrimaryValue || ((track: Track) => track.artist)
const getSecondaryValue = options?.getSecondaryValue || ((track: Track) => track.album)
return [...tracks].sort((left, right) => {
let comparison = 0
switch (sortBy) {
case 'artist':
comparison = compareText(left.artist, right.artist)
if (comparison === 0) comparison = compareText(left.album, right.album)
comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
if (comparison === 0) comparison = compareText(getSecondaryValue(left), getSecondaryValue(right))
if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
break
case 'album':
comparison = compareText(left.album, right.album)
if (comparison === 0) comparison = compareText(left.artist, right.artist)
comparison = compareText(getSecondaryValue(left), getSecondaryValue(right))
if (comparison === 0) comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
break
case 'server':
@@ -175,7 +243,7 @@ function sortTracks(tracks: Track[], sortBy: TrackSortField, sortDirection: Sort
case 'title':
default:
comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
if (comparison === 0) comparison = compareText(left.artist, right.artist)
if (comparison === 0) comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
break
}
@@ -325,28 +393,38 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const [detailsError, setDetailsError] = useState<string | null>(null)
const syncingSectionViewRef = useRef(false)
const sectionConfig = SECTION_CONFIG[mediaSection]
const namespacePresentation = getTrackNamespacePresentation(mediaSection)
const isAllSection = mediaSection === 'all'
const isTrackLikeView = view === 'tracks' || view === 'text' || view === 'data'
const hasAlbumsView = sectionConfig.views.some((item) => item.id === 'albums')
const hasArtistsView = sectionConfig.views.some((item) => item.id === 'artists')
const effectiveDisplayMode: DisplayMode = isAllSection ? 'table' : displayModePreference
const sortOptions = useMemo(() => isTrackLikeView ? TRACK_SORT_OPTIONS : ENTRY_SORT_OPTIONS, [isTrackLikeView])
const sortOptions = useMemo(() => isTrackLikeView ? getTrackSortOptions(mediaSection) : ENTRY_SORT_OPTIONS, [isTrackLikeView, mediaSection])
const detailsTrackDownloading = !!detailsTrack && isTrackDownloading(detailsTrack)
const trackNamespaceValues = useMemo(() => {
const values = new Map<string, { primary: string | null; secondary: string | null }>()
function extractNamespaceValue(tags: string[] | null | undefined, ns: string): string | null {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${ns.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
for (const track of results) {
const cacheKey = `${track.serverId || 'local'}:${track.fileId ?? track.id}`
values.set(cacheKey, {
primary: deriveTrackPrimaryValue(track, mediaSection),
secondary: deriveTrackSecondaryValue(track, mediaSection),
})
}
return bestValue || null
return values
}, [mediaSection, results])
function getTrackNamespaceCacheKey(track: Track) {
return `${track.serverId || 'local'}:${track.fileId ?? track.id}`
}
function getTrackPrimaryValue(track: Track) {
return trackNamespaceValues.get(getTrackNamespaceCacheKey(track))?.primary || null
}
function getTrackSecondaryValue(track: Track) {
return trackNamespaceValues.get(getTrackNamespaceCacheKey(track))?.secondary || null
}
function matchesMediaSection(track: Track, section: MediaSection) {
@@ -584,7 +662,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return /[,"]/.test(trimmed) ? `"${escaped}"` : escaped
}
function getNamespacedQueryValue(rawQuery: string, namespace: 'album' | 'artist') {
function getNamespacedQueryValue(rawQuery: string, namespace: string) {
const clauses = splitSearchClauses(rawQuery)
for (let index = clauses.length - 1; index >= 0; index -= 1) {
@@ -599,18 +677,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return null
}
function applyLibraryQueryFilter(namespace: 'album' | 'artist', value: string) {
const formattedValue = formatNamespacedSearchValue(value)
function applyLibraryQueryFilters(updates: Record<string, string | null | undefined>) {
const clauses = splitSearchClauses(query)
const targetNamespaces = new Set(Object.keys(updates).map((namespace) => namespace.toLowerCase()))
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'
return !targetNamespaces.has(clauseNamespace)
})
const nextQuery = [...retainedClauses, `${namespace}:${formattedValue}`].join(', ')
const nextClauses = [...retainedClauses]
for (const [namespace, value] of Object.entries(updates)) {
const trimmed = value?.trim()
if (!trimmed) continue
nextClauses.push(`${namespace}:${formatNamespacedSearchValue(trimmed)}`)
}
const nextQuery = nextClauses.join(', ')
onQueryChange(nextQuery)
}
@@ -655,11 +741,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, 250)
}
function buildNamespaceEntriesFromTracks(tracks: Track[], ns: 'album' | 'artist') {
function buildNamespaceEntriesFromTracks(tracks: Track[], getName: (track: Track) => string | null | undefined) {
const entries: Record<string, AlbumEntry> = {}
for (const track of tracks) {
const name = ns === 'album' ? track.album : track.artist
const name = getName(track)
if (!name) continue
if (!entries[name]) {
@@ -683,7 +769,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return Object.values(entries).sort((a, b) => a.name.localeCompare(b.name))
}
function buildArtistGroupsFromTracks(tracks: Track[]) {
function buildTrackGroupsFromTracks(
tracks: Track[],
getGroupName: (track: Track) => string | null | undefined,
getSubgroupName: (track: Track) => string | null | undefined,
) {
const groupsMap: Record<string, Track[]> = {}
const seen = new Set<string>()
@@ -692,17 +782,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (seen.has(trackKey)) continue
seen.add(trackKey)
const artistName = track.artist || 'Unknown'
if (!groupsMap[artistName]) groupsMap[artistName] = []
groupsMap[artistName].push(track)
const groupName = getGroupName(track) || 'Unknown'
if (!groupsMap[groupName]) groupsMap[groupName] = []
groupsMap[groupName].push(track)
}
const groups = Object.entries(groupsMap).map(([name, artistTracks]) => ({ name, tracks: [...artistTracks] }))
const groups = Object.entries(groupsMap).map(([name, groupTracks]) => ({ name, tracks: [...groupTracks] }))
for (const group of groups) {
group.tracks.sort((a, b) => {
const aAlbum = (a.album || '').toLowerCase()
const bAlbum = (b.album || '').toLowerCase()
if (aAlbum && bAlbum && aAlbum !== bAlbum) return aAlbum.localeCompare(bAlbum)
const aSubgroup = (getSubgroupName(a) || '').toLowerCase()
const bSubgroup = (getSubgroupName(b) || '').toLowerCase()
if (aSubgroup && bSubgroup && aSubgroup !== bSubgroup) return aSubgroup.localeCompare(bSubgroup)
return (a.title || '').toLowerCase().localeCompare((b.title || '').toLowerCase())
})
}
@@ -1029,7 +1119,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const getDisplayTitle = (track?: Track) => (track ? getTrackDisplayTitle(track) : '')
const getVisibleArtistGroups = (groups: Array<{ name: string; tracks: Track[] }>, limit: number) => {
const getVisibleTrackGroups = (groups: Array<{ name: string; tracks: Track[] }>, limit: number) => {
const visibleGroups: Array<{ name: string; tracks: Track[] }> = []
let remaining = limit
@@ -1047,38 +1137,52 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}
const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results])
const activeAlbumQuery = useMemo(() => getNamespacedQueryValue(query, 'album'), [query])
const activeArtistQuery = useMemo(() => getNamespacedQueryValue(query, 'artist'), [query])
const activeSecondaryQuery = useMemo(() => getNamespacedQueryValue(query, namespacePresentation.secondaryNamespace), [namespacePresentation.secondaryNamespace, query])
const activePrimaryQuery = useMemo(() => getNamespacedQueryValue(query, namespacePresentation.primaryNamespace), [namespacePresentation.primaryNamespace, query])
const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks])
const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'album'), [sectionTracks])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'artist'), [sectionTracks])
const baseArtistGroups = useMemo(() => {
if (!hasArtistsView || activeAlbumQuery || activeArtistQuery) return []
return buildArtistGroupsFromTracks(currentTrackResults)
}, [activeAlbumQuery, activeArtistQuery, currentTrackResults, hasArtistsView])
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection])
const sortedArtistGroups = useMemo(() => {
const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, (track) => getTrackSecondaryValue(track)), [sectionTracks, trackNamespaceValues])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, (track) => getTrackPrimaryValue(track)), [sectionTracks, trackNamespaceValues])
const baseTrackGroups = useMemo(() => {
if (!hasArtistsView || activeSecondaryQuery || activePrimaryQuery) return []
return buildTrackGroupsFromTracks(
currentTrackResults,
(track) => getTrackPrimaryValue(track),
(track) => getTrackSecondaryValue(track),
)
}, [activePrimaryQuery, activeSecondaryQuery, currentTrackResults, hasArtistsView, trackNamespaceValues])
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection, {
getPrimaryValue: (track) => getTrackPrimaryValue(track),
getSecondaryValue: (track) => getTrackSecondaryValue(track),
}), [currentTrackResults, sortBy, sortDirection, trackNamespaceValues])
const sortedTrackGroups = useMemo(() => {
if (!hasArtistsView) return []
return [...baseArtistGroups]
.map((group) => ({ name: group.name, tracks: sortTracks(group.tracks, 'title', sortDirection) }))
return [...baseTrackGroups]
.map((group) => ({
name: group.name,
tracks: sortTracks(group.tracks, 'title', sortDirection, {
getPrimaryValue: (track) => getTrackPrimaryValue(track),
getSecondaryValue: (track) => getTrackSecondaryValue(track),
}),
}))
.sort((left, right) => compareText(left.name, right.name) * (sortDirection === 'asc' ? 1 : -1))
}, [baseArtistGroups, hasArtistsView, sortDirection])
}, [baseTrackGroups, hasArtistsView, sortDirection, trackNamespaceValues])
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' && !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 shouldShowGroupedTracks = effectiveDisplayMode === 'grid' && !activeSecondaryQuery && !activePrimaryQuery && hasArtistsView && sortedTrackGroups.length > 0 && sortBy === 'artist'
const totalGroupedTrackCount = useMemo(() => sortedTrackGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedTrackGroups])
const visibleTrackGroups = useMemo(() => getVisibleTrackGroups(sortedTrackGroups, visibleCount), [sortedTrackGroups, 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 currentViewLabel = sectionConfig.views.find((item) => item.id === view)?.label || sectionConfig.label
const visibleRenderedTracks = useMemo(() => {
if (shouldShowGroupedArtists) return visibleArtistGroups.flatMap((group) => group.tracks)
if (shouldShowGroupedTracks) return visibleTrackGroups.flatMap((group) => group.tracks)
return visibleResults
}, [shouldShowGroupedArtists, visibleArtistGroups, visibleResults])
}, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults])
useEffect(() => {
const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track))
@@ -1131,8 +1235,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, [servers, visibleRenderedTracks])
const canLoadMore = isTrackLikeView
? shouldShowGroupedArtists
? visibleCount < totalArtistGroupTracks
? shouldShowGroupedTracks
? visibleCount < totalGroupedTrackCount
: visibleCount < sortedCurrentTrackResults.length
: view === 'albums'
? visibleCount < sortedAlbums.length
@@ -1163,7 +1267,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ mt: 1 }}>
{getDisplayTitle(track) ? <Typography variant="body2" sx={{ fontSize: 13 }}>{getDisplayTitle(track)}</Typography> : null}
{options?.showAlbum && track?.album && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{track.album}</Typography>}
{options?.showAlbum && track && getTrackSecondaryValue(track) && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{getTrackSecondaryValue(track)}</Typography>}
{options?.showFileIdFallback && track && !getDisplayTitle(track) && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>File {track.fileId}</Typography>}
</Box>
</Box>
@@ -1172,14 +1276,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Box>
)
const openAlbumEntry = (name: string) => {
const openAlbumEntry = (name: string, options?: { primaryName?: string | null }) => {
setView('tracks')
applyLibraryQueryFilter('album', name)
const primaryName = mediaSection === 'video'
? options?.primaryName?.trim() || activePrimaryQuery
: null
applyLibraryQueryFilters({
[namespacePresentation.secondaryNamespace]: name,
...(primaryName ? { [namespacePresentation.primaryNamespace]: primaryName } : {}),
})
}
const openArtistEntry = (name: string) => {
setView('tracks')
applyLibraryQueryFilter('artist', name)
applyLibraryQueryFilters({
[namespacePresentation.primaryNamespace]: name,
[namespacePresentation.secondaryNamespace]: null,
})
}
const handleSortRequest = (field: SortField) => {
@@ -1234,10 +1348,10 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>{getDisplayTitle(track)}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>
{track.artist || track.album || track.serverName || getTrackKindLabel(track, sectionConfig.label)}
{getTrackPrimaryValue(track) || getTrackSecondaryValue(track) || track.serverName || getTrackKindLabel(track, sectionConfig.label)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }} noWrap>
{[options?.showAlbum || track.album ? (track.album || null) : null, track.serverName || null, track.fileId != null ? `#${track.fileId}` : null].filter(Boolean).join(' • ')}
{[options?.showAlbum || getTrackSecondaryValue(track) ? (getTrackSecondaryValue(track) || null) : null, track.serverName || null, track.fileId != null ? `#${track.fileId}` : null].filter(Boolean).join(' • ')}
</Typography>
</Box>
</Box>
@@ -1249,9 +1363,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead>
<TableRow>
{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(namespacePresentation.trackLabel, 'title', { width: '38%' })}
{renderTrackHeaderCell(namespacePresentation.primaryLabel, 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })}
{renderTrackHeaderCell(namespacePresentation.secondaryLabel, '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>
@@ -1271,8 +1385,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Box>
</Box>
</TableCell>
<TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}><Typography variant="body2" noWrap>{track.artist || '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' } }}><Typography variant="body2" noWrap>{options?.showAlbum || track.album ? (track.album || '—') : '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}><Typography variant="body2" noWrap>{getTrackPrimaryValue(track) || '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' } }}><Typography variant="body2" noWrap>{options?.showAlbum || getTrackSecondaryValue(track) ? (getTrackSecondaryValue(track) || '—') : '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', lg: 'table-cell' } }}><Typography variant="body2" noWrap>{track.serverName || '—'}</Typography></TableCell>
<TableCell align="right">{track.fileId ?? '—'}</TableCell>
</TableRow>
@@ -1293,7 +1407,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
) : (
<Box sx={{ width: 48, height: 48, borderRadius: 1.25, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}>
{kind === 'album' ? 'AL' : 'AR'}
{(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()}
</Box>
)}
<Box sx={{ minWidth: 0, flex: 1 }}>
@@ -1311,7 +1425,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead>
<TableRow>
{renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })}
{renderEntryHeaderCell(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel, '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>
@@ -1325,7 +1439,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
) : (
<Box sx={{ width: 44, height: 44, borderRadius: 1, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}>
{kind === 'album' ? 'AL' : 'AR'}
{(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()}
</Box>
)}
<Typography variant="body2" noWrap>{entry.name}</Typography>
@@ -1416,9 +1530,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box>
{(view === 'tracks' || view === 'text' || view === 'data') && (
<>
{shouldShowGroupedArtists ? (
visibleArtistGroups.map((g) => {
const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean)))
{shouldShowGroupedTracks ? (
visibleTrackGroups.map((g) => {
const albumNames = Array.from(new Set(g.tracks.map((track) => (getTrackSecondaryValue(track) || '').trim()).filter(Boolean)))
return (
<Box key={g.name} sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>{g.name}</Typography>
@@ -1426,8 +1540,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
{albumNames.length > 0 && (
<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={() => openAlbumEntry(a)} />
const count = g.tracks.filter((track) => getTrackSecondaryValue(track) === a).length
return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => openAlbumEntry(a, { primaryName: g.name })} />
})}
</Box>
)}
@@ -1523,7 +1637,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
{loading && view !== 'tracks' && ((view === 'albums' && hasAlbumsView && albums.length === 0) || (view === 'artists' && hasArtistsView && artists.length === 0)) && (
<Alert severity="info" sx={{ mt: 2 }}>
Loading {view}...
Loading {currentViewLabel.toLowerCase()}...
</Alert>
)}
+69 -40
View File
@@ -34,56 +34,84 @@ import { useServers } from '../context/ServersContext'
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync'
import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
endpoint: string
}
function normalizeServerForm(form: Omit<Server, 'id' | 'lastTest'>) {
const DEFAULT_SERVER_FORM: ServerForm = { name: '', endpoint: '', apiKey: '', ssl: false, forceApiKeyInQuery: false }
function buildServerEndpointValue(server: Pick<Server, 'host' | 'port' | 'ssl'>) {
const rawHost = (server.host || '').trim()
if (!rawHost) return ''
let endpoint = rawHost
if (!/^https?:\/\//i.test(endpoint)) {
endpoint = `${server.ssl ? 'https' : 'http'}://${endpoint}`
}
try {
const parsed = new URL(endpoint)
if (server.port && !parsed.port) parsed.port = String(server.port)
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
return `${parsed.origin}${path}`
} catch {
return `${rawHost}${server.port ? `:${server.port}` : ''}`
}
}
function normalizeServerForm(form: ServerForm) {
return {
...form,
name: (form.name || '').trim(),
host: (form.host || '').trim(),
port: typeof form.port === 'string' ? form.port.trim() || undefined : form.port,
endpoint: (form.endpoint || '').trim(),
apiKey: (form.apiKey || '').replace(/\s+/g, ''),
}
}
function validateServerForm(form: Omit<Server, 'id' | 'lastTest'>) {
function validateServerForm(form: ServerForm) {
const normalized = normalizeServerForm(form)
if (!normalized.host) {
return { normalized, error: 'Host is required.' }
if (!normalized.endpoint) {
return { normalized, config: null, error: 'Server URL is required.' }
}
try {
let candidate = normalized.host
let candidate = normalized.endpoint
if (!/^https?:\/\//i.test(candidate)) {
candidate = `${normalized.ssl ? 'https' : 'http'}://${candidate}`
}
const parsed = new URL(candidate)
if (!parsed.hostname) {
return { normalized, error: 'Host is invalid. Use an IP, hostname, or full URL.' }
return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' }
}
if (parsed.port) {
const portNumber = Number(parsed.port)
if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
return { normalized, config: null, error: 'Port must be between 1 and 65535.' }
}
}
if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) {
return { normalized, config: null, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' }
}
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
const config: Omit<Server, 'id' | 'lastTest'> = {
name: normalized.name,
host: `${parsed.protocol}//${parsed.hostname}${path}`,
port: parsed.port || undefined,
apiKey: normalized.apiKey,
ssl: parsed.protocol === 'https:',
forceApiKeyInQuery: normalized.forceApiKeyInQuery,
syncSummary: form.syncSummary,
}
return { normalized: { ...normalized, ssl: config.ssl }, config, error: null }
} catch {
return { normalized, error: 'Host is invalid. Use an IP, hostname, or full URL.' }
return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' }
}
if (normalized.port !== undefined) {
const portText = String(normalized.port)
if (!/^\d+$/.test(portText)) {
return { normalized, error: 'Port must contain digits only.' }
}
const portNumber = Number(portText)
if (portNumber < 1 || portNumber > 65535) {
return { normalized, error: 'Port must be between 1 and 65535.' }
}
}
if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) {
return { normalized, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' }
}
return { normalized, error: null }
}
type SettingsPageProps = {
@@ -102,7 +130,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
const isFirstServerSetup = servers.length === 0
const [editing, setEditing] = useState<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
const [form, setForm] = useState<ServerForm>(DEFAULT_SERVER_FORM)
const [testing, setTesting] = useState(false)
const [syncingServerId, setSyncingServerId] = useState<string | null>(null)
const [lastTest, setLastTest] = useState<string | null>(null)
@@ -194,22 +222,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
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 })
setForm({ name: s.name || '', endpoint: buildServerEndpointValue(s), apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery, syncSummary: s.syncSummary })
setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null)
}
const handleSave = () => {
const { normalized, error } = validateServerForm(form)
const { normalized, config, error } = validateServerForm(form)
if (error) {
setLastTest(error)
return
}
setForm(normalized)
if (!config) return
if (editing) {
updateServer(editing.id, normalized)
updateServer(editing.id, config)
} else {
const srv = addServer(normalized)
const srv = addServer(config)
setActiveServerId(srv.id)
}
onClose && onClose()
@@ -234,16 +263,17 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
}
const handleTestForm = async () => {
const { normalized, error } = validateServerForm(form)
const { normalized, config, error } = validateServerForm(form)
if (error) {
setLastTest(error)
return
}
setForm(normalized)
if (!config) return
setTesting(true)
try {
const res = await testServerConfig(normalized)
const res = await testServerConfig(config)
setLastTest(`${res.message}`)
} catch (e: any) {
setLastTest(`Error: ${e?.message ?? String(e)}`)
@@ -509,7 +539,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
<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' : isFirstServerSetup ? 'Add your first server' : 'Add new server'}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Use the same host, port, and API key you use for the Hydrus Client API.
Paste the same Hydrus Client API address and API key you use elsewhere.
</Typography>
{isFirstServerSetup && (
<Alert severity="info" sx={{ mt: 2 }}>
@@ -518,17 +548,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
)}
<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 or URL" placeholder="192.168.1.128, hydrus.local, or http://192.168.1.128" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} />
<TextField label="Port" placeholder="45869" value={form.port as any ?? ''} onChange={(e) => setForm({ ...form, port: e.target.value })} fullWidth inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} />
<TextField label="Server URL" placeholder="http://192.168.1.128:45869 or hydrus.local:45869" value={form.endpoint} onChange={(e) => setForm({ ...form, endpoint: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Paste the full Hydrus Client API address here. Port can be included in the same field." />
<TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." />
<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 } }}>
<Button variant="contained" onClick={handleSave} disabled={!form.endpoint} 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 } }}>
<Button variant="outlined" onClick={handleTestForm} disabled={!form.endpoint || 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 } }}>