diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index b299da0..cb68eb4 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -76,6 +76,14 @@ type SortOption = { label: string } +type TrackNamespacePresentation = { + trackLabel: string + primaryLabel: string + secondaryLabel: string + primaryNamespace: string + secondaryNamespace: string +} + const SECTION_CONFIG: Record }> = { all: { label: 'All Files', @@ -96,9 +104,9 @@ const SECTION_CONFIG: Record 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(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() - 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) { 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 = {} 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 = {} const seen = new Set() @@ -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 {getDisplayTitle(track) ? {getDisplayTitle(track)} : null} - {options?.showAlbum && track?.album && {track.album}} + {options?.showAlbum && track && getTrackSecondaryValue(track) && {getTrackSecondaryValue(track)}} {options?.showFileIdFallback && track && !getDisplayTitle(track) && File {track.fileId}} @@ -1172,14 +1276,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr ) - 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 {getDisplayTitle(track)} - {track.artist || track.album || track.serverName || getTrackKindLabel(track, sectionConfig.label)} + {getTrackPrimaryValue(track) || getTrackSecondaryValue(track) || track.serverName || getTrackKindLabel(track, sectionConfig.label)} - {[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(' • ')} @@ -1249,9 +1363,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr - {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' })} @@ -1271,8 +1385,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr - {track.artist || '—'} - {options?.showAlbum || track.album ? (track.album || '—') : '—'} + {getTrackPrimaryValue(track) || '—'} + {options?.showAlbum || getTrackSecondaryValue(track) ? (getTrackSecondaryValue(track) || '—') : '—'} {track.serverName || '—'} {track.fileId ?? '—'} @@ -1293,7 +1407,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr ) : ( - {kind === 'album' ? 'AL' : 'AR'} + {(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()} )} @@ -1311,7 +1425,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
- {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' })} Servers @@ -1325,7 +1439,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr ) : ( - {kind === 'album' ? 'AL' : 'AR'} + {(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()} )} {entry.name} @@ -1416,9 +1530,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr {(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 ( {g.name} @@ -1426,8 +1540,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr {albumNames.length > 0 && ( {albumNames.map((a) => { - const count = g.tracks.filter((t: any) => (t.album || '') === a).length - return openAlbumEntry(a)} /> + const count = g.tracks.filter((track) => getTrackSecondaryValue(track) === a).length + return openAlbumEntry(a, { primaryName: g.name })} /> })} )} @@ -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)) && ( - Loading {view}... + Loading {currentViewLabel.toLowerCase()}... )} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 97d28f4..8e6906f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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 & { + endpoint: string +} -function normalizeServerForm(form: Omit) { +const DEFAULT_SERVER_FORM: ServerForm = { name: '', endpoint: '', apiKey: '', ssl: false, forceApiKeyInQuery: false } + +function buildServerEndpointValue(server: Pick) { + 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) { +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 = { + 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(null) - const [form, setForm] = useState>(DEFAULT_SERVER_FORM) + const [form, setForm] = useState(DEFAULT_SERVER_FORM) const [testing, setTesting] = useState(false) const [syncingServerId, setSyncingServerId] = useState(null) const [lastTest, setLastTest] = useState(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 {editing ? 'Edit server' : isFirstServerSetup ? 'Add your first server' : 'Add new server'} - 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. {isFirstServerSetup && ( @@ -518,17 +548,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE )} setForm({ ...form, name: e.target.value })} fullWidth /> - setForm({ ...form, host: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} /> - setForm({ ...form, port: e.target.value })} fullWidth inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} /> + 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." /> setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." /> setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" /> setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" /> - -