add tagging to tv
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||||
import { scanDirectory } from '@/lib/files'
|
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -24,6 +24,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
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)
|
return NextResponse.json(listing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(true)
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
|
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
||||||
|
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
||||||
|
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -73,6 +76,29 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
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) => {
|
const handleEntry = (entry: FileEntry) => {
|
||||||
if (entry.type === 'directory') {
|
if (entry.type === 'directory') {
|
||||||
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
||||||
@@ -85,15 +111,12 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
} else if (entry.mediaType === 'image') {
|
} else if (entry.mediaType === 'image') {
|
||||||
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
|
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
|
||||||
} else {
|
} else {
|
||||||
// Download other file types
|
|
||||||
window.open(entry.url, '_blank')
|
window.open(entry.url, '_blank')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagEntry = (entry: FileEntry) => {
|
const handleTagEntry = (entry: FileEntry) => {
|
||||||
const relativePath = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
setTagPanel({ entry, mediaKey: mediaKeyFor(entry) })
|
||||||
const mediaKey = `${libraryId}:${encodeURIComponent(relativePath)}`
|
|
||||||
setTagPanel({ entry, mediaKey })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateUp = () => {
|
const navigateUp = () => {
|
||||||
@@ -107,12 +130,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
? currentPath.split('/').filter(Boolean)
|
? currentPath.split('/').filter(Boolean)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const mediaKeyFor = (entry: FileEntry) => {
|
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||||
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
|
||||||
return `${libraryId}:${encodeURIComponent(rel)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredEntries = (listing?.entries ?? []).filter((entry) => {
|
const filteredEntries = sourceEntries.filter((entry) => {
|
||||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||||
const entryTags = assignments[mediaKeyFor(entry)] ?? []
|
const entryTags = assignments[mediaKeyFor(entry)] ?? []
|
||||||
@@ -121,8 +141,6 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
@@ -184,23 +202,23 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{loading && <LoadingSkeleton />}
|
{(loading || recursiveLoading) && <LoadingSkeleton />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && listing && (
|
{!loading && !recursiveLoading && !error && (filtersActive || listing) && (
|
||||||
<>
|
<>
|
||||||
{filteredEntries.length === 0 ? (
|
{filteredEntries.length === 0 ? (
|
||||||
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
This folder is empty.
|
{filtersActive ? 'No results found.' : 'This folder is empty.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
{/* Up button */}
|
{/* Up button — hidden during recursive search */}
|
||||||
{breadcrumbs.length > 0 && (
|
{!filtersActive && breadcrumbs.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={navigateUp}
|
onClick={navigateUp}
|
||||||
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"
|
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import type { TvEpisode } from '@/types'
|
|||||||
interface Props {
|
interface Props {
|
||||||
episode: TvEpisode
|
episode: TvEpisode
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onTag?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpisodeCard({ episode, onClick }: Props) {
|
export default function EpisodeCard({ episode, onClick, onTag }: Props) {
|
||||||
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
@@ -42,6 +46,18 @@ export default function EpisodeCard({ episode, onClick }: Props) {
|
|||||||
>
|
>
|
||||||
<span className="text-3xl text-white">▶</span>
|
<span className="text-3xl text-white">▶</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tag button */}
|
||||||
|
{onTag && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTag() }}
|
||||||
|
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
|
aria-label={`Tag ${episode.title}`}
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{epLabel && (
|
{epLabel && (
|
||||||
@@ -62,6 +78,6 @@ export default function EpisodeCard({ episode, onClick }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
|
|||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import EpisodeCard from './EpisodeCard'
|
import EpisodeCard from './EpisodeCard'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +28,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(true)
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
|
const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
@@ -141,6 +143,8 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
<VideoPlayerModal
|
<VideoPlayerModal
|
||||||
url={videoUrl}
|
url={videoUrl}
|
||||||
name={playingEpisode.title}
|
name={playingEpisode.title}
|
||||||
|
mediaKey={`${libraryId}:${playingEpisode.id}`}
|
||||||
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onClose={() => setPlayingEpisode(null)}
|
onClose={() => setPlayingEpisode(null)}
|
||||||
context="tv"
|
context="tv"
|
||||||
/>
|
/>
|
||||||
@@ -225,10 +229,13 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
{filteredSeries.map((s) => (
|
{filteredSeries.map((s) => (
|
||||||
<button
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => openSeries(s)}
|
onClick={() => openSeries(s)}
|
||||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSeries(s) } }}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
@@ -246,6 +253,15 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setTagPanel({ mediaKey: `${libraryId}:${s.id}`, title: s.title }) }}
|
||||||
|
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
|
aria-label={`Tag ${s.title}`}
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
|
||||||
@@ -255,7 +271,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
|
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -416,12 +432,52 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
key={ep.id}
|
key={ep.id}
|
||||||
episode={ep}
|
episode={ep}
|
||||||
onClick={() => setPlayingEpisode(ep)}
|
onClick={() => setPlayingEpisode(ep)}
|
||||||
|
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{tagPanel && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{tagPanel.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setTagPanel(null)}
|
||||||
|
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<TagSelector
|
||||||
|
mediaKey={tagPanel.mediaKey}
|
||||||
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,3 +76,55 @@ export function scanDirectory(
|
|||||||
|
|
||||||
return { path: subpath, entries }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user