diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 6c72934..0aaf717 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' -import { scanDirectory } from '@/lib/files' +import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { requireLibraryAccess } from '@/lib/auth' export async function GET(request: NextRequest) { @@ -24,6 +24,9 @@ export async function GET(request: NextRequest) { } const root = resolveLibraryRoot(library) - const listing = scanDirectory(root, libraryId, subpath) + const recursive = request.nextUrl.searchParams.get('recursive') === 'true' + const listing = recursive + ? scanDirectoryRecursive(root, libraryId, subpath) + : scanDirectory(root, libraryId, subpath) return NextResponse.json(listing) } diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 6ac4118..7b839c9 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -31,6 +31,9 @@ export default function MixedView({ libraryId, initialPath }: Props) { const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) + const [recursiveEntries, setRecursiveEntries] = useState([]) + const [recursiveLoading, setRecursiveLoading] = useState(false) + const [recursiveLoaded, setRecursiveLoaded] = useState(false) const toggleTag = (tagId: string) => setSelectedTagIds((prev) => { @@ -73,6 +76,29 @@ export default function MixedView({ libraryId, initialPath }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) + const filtersActive = search !== '' || selectedTagIds.size > 0 + + // Fetch the full recursive listing the first time any filter becomes active + useEffect(() => { + if (!filtersActive || recursiveLoaded || recursiveLoading) return + setRecursiveLoading(true) + fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`) + .then((r) => r.json()) + .then((data: DirectoryListing) => { + setRecursiveEntries(data.entries) + setRecursiveLoaded(true) + }) + .catch(() => {}) + .finally(() => setRecursiveLoading(false)) + }, [filtersActive, libraryId, recursiveLoaded, recursiveLoading]) + + const mediaKeyFor = (entry: FileEntry) => { + // In recursive mode entry.name is already the full relative path from the library root + if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}` + const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name + return `${libraryId}:${encodeURIComponent(rel)}` + } + const handleEntry = (entry: FileEntry) => { if (entry.type === 'directory') { const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name @@ -85,15 +111,12 @@ export default function MixedView({ libraryId, initialPath }: Props) { } else if (entry.mediaType === 'image') { setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) }) } else { - // Download other file types window.open(entry.url, '_blank') } } const handleTagEntry = (entry: FileEntry) => { - const relativePath = currentPath ? `${currentPath}/${entry.name}` : entry.name - const mediaKey = `${libraryId}:${encodeURIComponent(relativePath)}` - setTagPanel({ entry, mediaKey }) + setTagPanel({ entry, mediaKey: mediaKeyFor(entry) }) } const navigateUp = () => { @@ -107,12 +130,9 @@ export default function MixedView({ libraryId, initialPath }: Props) { ? currentPath.split('/').filter(Boolean) : [] - const mediaKeyFor = (entry: FileEntry) => { - const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name - return `${libraryId}:${encodeURIComponent(rel)}` - } + const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) - const filteredEntries = (listing?.entries ?? []).filter((entry) => { + const filteredEntries = sourceEntries.filter((entry) => { if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0 && entry.type !== 'directory') { const entryTags = assignments[mediaKeyFor(entry)] ?? [] @@ -121,8 +141,6 @@ export default function MixedView({ libraryId, initialPath }: Props) { return true }) - const filtersActive = search !== '' || selectedTagIds.size > 0 - return ( <>
@@ -184,23 +202,23 @@ export default function MixedView({ libraryId, initialPath }: Props) { })} - {loading && } + {(loading || recursiveLoading) && } {error && (
{error}
)} - {!loading && !error && listing && ( + {!loading && !recursiveLoading && !error && (filtersActive || listing) && ( <> {filteredEntries.length === 0 ? (
- This folder is empty. + {filtersActive ? 'No results found.' : 'This folder is empty.'}
) : (
- {/* Up button */} - {breadcrumbs.length > 0 && ( + {/* Up button โ€” hidden during recursive search */} + {!filtersActive && breadcrumbs.length > 0 && (
+ {/* Tag button */} + {onTag && ( + + )}
{epLabel && ( @@ -62,6 +78,6 @@ export default function EpisodeCard({ episode, onClick }: Props) {

)}
- + ) } diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index a5bfce2..b213ba1 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { TvSeries, TvSeason, TvEpisode } from '@/types' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' +import TagSelector from '@/components/tags/TagSelector' import EpisodeCard from './EpisodeCard' interface Props { @@ -27,6 +28,7 @@ export default function TvView({ libraryId }: Props) { const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) + const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null) const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) @@ -141,6 +143,8 @@ export default function TvView({ libraryId }: Props) { { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onClose={() => setPlayingEpisode(null)} context="tv" /> @@ -225,10 +229,13 @@ export default function TvView({ libraryId }: Props) { ) : (
{filteredSeries.map((s) => ( -

@@ -255,7 +271,7 @@ export default function TvView({ libraryId }: Props) { {s.year ? `${s.year} ยท ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}

- + ))} )} @@ -416,12 +432,52 @@ export default function TvView({ libraryId }: Props) { key={ep.id} episode={ep} onClick={() => setPlayingEpisode(ep)} + onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} /> ))} )} )} + {tagPanel && ( +
{ if (e.target === e.currentTarget) setTagPanel(null) }} + > +
+
+
+

+ Tags +

+

+ {tagPanel.title} +

+
+ +
+
+ { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }} + /> +
+
+
+ )} ) } diff --git a/src/lib/files.ts b/src/lib/files.ts index 01c6059..2c7c20b 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -76,3 +76,55 @@ export function scanDirectory( return { path: subpath, entries } } + +/** + * Recursively walks every subdirectory under `subpath` and returns a flat list + * of all files. Directory entries are omitted. Each FileEntry.name is the full + * relative path from the library root (e.g. FolderA/SubFolder/video.mp4). + */ +export function scanDirectoryRecursive( + libraryRoot: string, + libraryId: string, + subpath: string +): DirectoryListing { + let rootAbsPath: string + try { + rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot + } catch { + return { path: subpath, entries: [] } + } + + const entries: FileEntry[] = [] + + function walk(absDir: string, relDir: string): void { + let dirents: fs.Dirent[] + try { + dirents = fs.readdirSync(absDir, { withFileTypes: true }) + } catch { + return + } + for (const d of dirents) { + if (HIDDEN_FILES.test(d.name)) continue + const relPath = relDir ? path.join(relDir, d.name) : d.name + if (d.isDirectory()) { + walk(path.join(absDir, d.name), relPath) + } else { + const mediaType = getMediaType(d.name) + const hasThumbnail = mediaType === 'image' || mediaType === 'video' + // name = full relative path from library root so media keys match + const fullRelPath = subpath ? path.join(subpath, relPath) : relPath + entries.push({ + name: fullRelPath, + type: 'file', + mediaType, + url: fileApiUrl(libraryId, fullRelPath), + thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null, + }) + } + } + } + + walk(rootAbsPath, '') + entries.sort((a, b) => a.name.localeCompare(b.name)) + return { path: subpath, entries } +}