updated library and settings page
This commit is contained in:
+200
-86
@@ -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
@@ -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 } }}>
|
||||
|
||||
Reference in New Issue
Block a user