diff --git a/README.md b/README.md index 5d408e7..0764509 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ # MediaLore -A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — currently supporting **Games** and **Mixed Media** library types. +A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — supporting **Games**, **Movies**, **TV**, and **Mixed Media** library types. ## Features -- **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip. -- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Image and video files display auto-generated square thumbnails. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab. -- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated automatically when the source file changes. -- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`. +- **Games library** — grid of game cover art scanned from folders. Each game folder contains a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link. +- **Movies library** — grid of movie posters scanned from per-movie folders. Reads `.nfo` sidecar files (Kodi-compatible) for metadata (title, year, plot, rating, genres). Clicking a movie opens a full-screen video player. +- **TV library** — browse TV series → seasons → episodes. Reads `tvshow.nfo` and per-episode `.nfo` files for metadata. Supports standard season folder layouts and flat (seasonless) series. +- **Mixed media library** — folder-navigable browser that mirrors the directory structure on disk. Images open in a lightbox; videos open in an inline player with full seek support. +- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated when the source file changes. +- **Tag system** — create tag categories and items, then assign tags to individual media items per-library. Filter any library view by one or more tags. +- **Library management UI** — add and remove libraries at `/manage` without touching config files. Configuration persists across restarts in `libraries.json`. +- **User authentication** — iron-session cookie auth with `admin` and `user` roles. Admins manage libraries, scan settings, tags, and users. Users have read-only access, optionally restricted to specific libraries. +- **Background scanner** — scans all libraries on demand (or on a schedule) to pre-generate thumbnails and populate metadata caches. - **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served. ## Project Structure @@ -16,35 +21,77 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem. MediaLoreWeb/ ├── libraries.json # Runtime library config (managed via UI, do not edit by hand) ├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored) -├── data/ # Example media (not committed to production) ├── src/ │ ├── app/ │ │ ├── layout.tsx -│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty) -│ │ ├── manage/page.tsx # Library management — add/remove libraries -│ │ ├── library/[id]/page.tsx # Library view (games or mixed) +│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty) +│ │ ├── manage/page.tsx # Library management +│ │ ├── manage/tags/page.tsx # Tag management +│ │ ├── manage/users/page.tsx # User management (admin only) +│ │ ├── manage/scan/page.tsx # Scan settings and manual trigger +│ │ ├── library/[id]/page.tsx # Library view (games / movies / tv / mixed) │ │ └── api/ -│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries -│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id -│ │ ├── games/route.ts # GET /api/games?libraryId= -│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path= -│ │ ├── file/route.ts # GET /api/file?libraryId=&path= -│ │ └── thumbnail/route.ts # GET /api/thumbnail?libraryId=&path= +│ │ ├── auth/login/route.ts # POST /api/auth/login +│ │ ├── auth/logout/route.ts # POST /api/auth/logout +│ │ ├── auth/register/route.ts # POST /api/auth/register +│ │ ├── libraries/route.ts # GET, POST /api/libraries +│ │ ├── libraries/[id]/route.ts # PATCH, DELETE /api/libraries/:id +│ │ ├── games/route.ts # GET /api/games +│ │ ├── movies/route.ts # GET /api/movies +│ │ ├── tv/route.ts # GET /api/tv (series, seasons, episodes) +│ │ ├── browse/route.ts # GET /api/browse (mixed media) +│ │ ├── file/route.ts # GET /api/file +│ │ ├── thumbnail/route.ts # GET /api/thumbnail +│ │ ├── game-cover/route.ts # POST /api/game-cover (upload cover art) +│ │ ├── library-cover/[id]/route.ts # GET /api/library-cover/:id +│ │ ├── scan/route.ts # POST /api/scan +│ │ ├── scan-settings/route.ts # GET, POST /api/scan-settings +│ │ ├── settings/route.ts # GET, POST /api/settings +│ │ ├── tags/categories/route.ts # GET, POST /api/tags/categories +│ │ ├── tags/categories/[id]/route.ts# PATCH, DELETE /api/tags/categories/:id +│ │ ├── tags/items/route.ts # GET, POST /api/tags/items +│ │ ├── tags/items/[id]/route.ts # PATCH, DELETE /api/tags/items/:id +│ │ ├── tags/assignments/route.ts # GET, POST, DELETE /api/tags/assignments +│ │ ├── tags/library-assignments/route.ts # GET /api/tags/library-assignments +│ │ ├── users/route.ts # GET, POST /api/users +│ │ ├── users/[id]/route.ts # PATCH, DELETE /api/users/:id +│ │ └── users/[id]/permissions/route.ts # GET, POST /api/users/:id/permissions │ ├── components/ +│ │ ├── FilterPanel.tsx │ │ ├── LibraryCard.tsx │ │ ├── NavLink.tsx +│ │ ├── DoomScrollView.tsx │ │ ├── games/ │ │ │ ├── GamesView.tsx │ │ │ └── GameDetailModal.tsx -│ │ └── mixed/ -│ │ ├── MixedView.tsx -│ │ ├── VideoPlayerModal.tsx -│ │ └── ImageLightbox.tsx +│ │ ├── movies/ +│ │ │ ├── MoviesView.tsx +│ │ │ └── MovieDetailModal.tsx +│ │ ├── tv/ +│ │ │ └── TvView.tsx +│ │ ├── mixed/ +│ │ │ ├── MixedView.tsx +│ │ │ ├── VideoPlayerModal.tsx +│ │ │ └── ImageLightbox.tsx +│ │ └── tags/ +│ │ └── TagSelector.tsx │ ├── lib/ -│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers -│ │ ├── games.ts # Games library scanner -│ │ ├── files.ts # Mixed library directory scanner -│ │ └── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg) +│ │ ├── auth.ts # iron-session auth, requireAdmin / requireLibraryAccess helpers +│ │ ├── db.ts # SQLite setup and migrations (better-sqlite3) +│ │ ├── libraries.ts # Config read/write, path resolution, path-jailing +│ │ ├── media-utils.ts # Shared constants (HIDDEN_FILES, VIDEO_EXTENSIONS) and helpers +│ │ ├── games.ts # Games library scanner +│ │ ├── movies.ts # Movies library scanner +│ │ ├── tv.ts # TV library scanner (series / seasons / episodes) +│ │ ├── files.ts # Mixed media directory scanner +│ │ ├── nfo.ts # Kodi-compatible .nfo XML parser +│ │ ├── scanner.ts # Full-library background scan orchestrator +│ │ ├── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg) +│ │ ├── tags.ts # Tag CRUD and assignment helpers +│ │ ├── users.ts # User CRUD and permission helpers +│ │ └── app-settings.ts # App-level settings (scan schedule, etc.) +│ ├── hooks/ +│ │ └── useUserSettings.ts │ └── types/ │ └── index.ts ``` @@ -55,19 +102,22 @@ MediaLoreWeb/ > Video thumbnails require `ffmpeg` and `ffprobe` to be installed and available on `$PATH`. If they are missing, video tiles gracefully fall back to a generic icon — no errors are thrown. > -> **macOS:** `brew install ffmpeg` -> **Ubuntu/Debian:** `sudo apt install ffmpeg` +> **macOS:** `brew install ffmpeg` +> **Ubuntu/Debian:** `sudo apt install ffmpeg` > **Windows:** Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to `PATH` ```bash # 1. Install dependencies npm install -# 2. Start the development server +# 2. Copy the example env file and fill in SESSION_SECRET +cp .env.example .env.local + +# 3. Start the development server npm run dev ``` -Open [http://localhost:3000](http://localhost:3000). +Open [http://localhost:3000](http://localhost:3000). On first run you will be prompted to create an admin account. Other available commands: @@ -77,23 +127,37 @@ npm run start # Start production server (run build first) npm run lint # Run ESLint ``` +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SESSION_SECRET` | Yes | Secret used to sign session cookies. Must be at least 32 characters. | +| `COOKIE_SECURE` | No | Set to `true` in production (HTTPS). Defaults to `false`. | + +## Authentication + +MediaLore uses cookie-based sessions (iron-session). On first launch, navigate to `/register` to create the initial admin account. Subsequent registrations require an existing admin to create accounts via `/manage/users`. + +**Roles:** + +| Role | Capabilities | +|------|-------------| +| `admin` | Full access: manage libraries, users, tags, scan settings | +| `user` | Read-only access to assigned libraries | + +Library-level permissions can be configured per user at `/manage/users`. + ## Library Configuration -Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. No manual file editing is required. +Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. -When you add a library via the UI, you provide: +| Field | Description | +|-------|-------------| +| Name | Display name shown in the UI | +| Path | Absolute or project-relative path to the library root folder on disk | +| Type | `games`, `movies`, `tv`, or `mixed` | -| Field | Description | -|--------|-------------| -| Name | Display name shown in the UI | -| Path | Absolute or project-relative path to the library root folder on disk | -| Type | `Games` or `Mixed Media` | - -The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root and persists across restarts. - -If no libraries are configured, navigating to `/` automatically redirects to `/manage`. - -Paths can point anywhere on the filesystem — they do not need to be inside the project directory. +The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root. ## Library Folder Conventions @@ -109,8 +173,41 @@ Games/ └── widecover.jpg # Optional — landscape/hero cover art (case-insensitive) ``` -- The `.zip` filename can be anything; the first `.zip` found in the folder is used. -- Cover art filenames are matched case-insensitively against `cover.*` and `widecover.*`. Any image extension is accepted. +Subdirectories without a `.zip` are treated as series containers — their child directories are scanned as individual games. + +### Movies (`"type": "movies"`) + +Each movie is a subdirectory containing a single video file and optional sidecar files: + +``` +Movies/ +└── The Matrix (1999)/ + ├── The Matrix (1999).mkv # Required — any supported video extension + ├── movie.nfo # Optional — Kodi-compatible metadata + ├── poster.jpg # Optional — portrait poster art + └── backdrop.jpg # Optional — backdrop/fanart image +``` + +Supported video extensions: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.wmv`, `.ts`, `.m2ts` + +Poster filenames are matched case-insensitively against `poster`, `cover`, or `folder`. Backdrop filenames are matched against `backdrop`, `fanart`, or `background`. + +### TV (`"type": "tv"`) + +``` +TV/ +└── Breaking Bad/ + ├── tvshow.nfo # Optional — series metadata + ├── poster.jpg # Optional — series poster + ├── Season 01/ + │ ├── s01e01.mkv + │ ├── s01e01.nfo # Optional — episode metadata + │ └── ... + └── Season 02/ + └── ... +``` + +Season directory names are matched against patterns: `Season 01`, `S01`, `1`, `01`. If no season subdirectories contain video files, the series root itself is treated as a flat (seasonless) season. ### Mixed Media (`"type": "mixed"`) @@ -123,15 +220,57 @@ No specific structure is required. The UI mirrors the directory tree exactly as All API routes are server-side. File paths are never exposed in client-side state — only opaque `/api/file?...` URLs are sent to the browser. +### Auth + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/auth/login` | POST | Authenticate. Body: `{ username, password }` | +| `/api/auth/logout` | POST | Clear session cookie | +| `/api/auth/register` | POST | Create account. Body: `{ username, password }`. First user becomes admin. | + +### Libraries + | Route | Method | Description | |-------|--------|-------------| | `/api/libraries` | GET | Returns the full configured library list | -| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }`. Validates the path exists. | +| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }` | +| `/api/libraries/:id` | PATCH | Updates a library | | `/api/libraries/:id` | DELETE | Removes a library by id | -| `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries | -| `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library | -| `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback | -| `/api/thumbnail?libraryId=&path=` | GET | Returns a cached square thumbnail (JPEG) for an image or video file; `404` if generation fails or ffmpeg is unavailable | + +### Media + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries | +| `/api/movies?libraryId=` | GET | Scans the movies library and returns movie entries | +| `/api/tv?libraryId=` | GET | Returns TV series list | +| `/api/tv?libraryId=&seriesId=` | GET | Returns seasons for a series | +| `/api/tv?libraryId=&seriesId=&seasonId=` | GET | Returns episodes for a season | +| `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library | +| `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video | +| `/api/thumbnail?libraryId=&path=` | GET | Returns a cached square thumbnail (JPEG); `404` if generation fails | + +### Tags + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/tags/categories` | GET, POST | List or create tag categories | +| `/api/tags/categories/:id` | PATCH, DELETE | Update or delete a category | +| `/api/tags/items` | GET, POST | List or create tag items | +| `/api/tags/items/:id` | PATCH, DELETE | Update or delete a tag item | +| `/api/tags/assignments` | GET, POST, DELETE | Get, add, or remove tag assignments on a media item | +| `/api/tags/library-assignments?libraryId=` | GET | All tag assignments for a library (used by filter panel) | + +### Users & Settings + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/users` | GET, POST | List or create users (admin only) | +| `/api/users/:id` | PATCH, DELETE | Update or delete a user | +| `/api/users/:id/permissions` | GET, POST | Get or set library-level permissions for a user | +| `/api/settings` | GET, POST | App-level settings | +| `/api/scan` | POST | Trigger a full library scan | +| `/api/scan-settings` | GET, POST | Get or update scan schedule settings | ## Tech Stack @@ -139,3 +278,7 @@ All API routes are server-side. File paths are never exposed in client-side stat - [React 19](https://react.dev/) - [TypeScript 5](https://www.typescriptlang.org/) - [Tailwind CSS 4](https://tailwindcss.com/) +- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — SQLite database +- [iron-session](https://github.com/vvo/iron-session) — cookie-based sessions +- [sharp](https://sharp.pixelplumbing.com/) — image thumbnail generation +- [ffmpeg](https://ffmpeg.org/) — video thumbnail extraction diff --git a/src/lib/files.ts b/src/lib/files.ts index 2c7c20b..73fa333 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -2,9 +2,10 @@ import fs from 'fs' import path from 'path' import type { DirectoryListing, FileEntry, MediaType } from '@/types' import { resolveAndJail } from '@/lib/libraries' +import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl } from './media-utils' -const HIDDEN_FILES = /^\./ - +// Web-playable video formats for the Mixed Media browser (distinct from the +// broader set used by dedicated movie/TV scanners). const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) @@ -15,14 +16,6 @@ function getMediaType(filename: string): MediaType { return 'other' } -function fileApiUrl(libraryId: string, relativePath: string): string { - return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - -function thumbnailApiUrl(libraryId: string, relativePath: string): string { - return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - export function scanDirectory( libraryRoot: string, libraryId: string, diff --git a/src/lib/games.ts b/src/lib/games.ts index accd450..742b2af 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -1,33 +1,7 @@ import fs from 'fs' import path from 'path' import type { Game, GameSeries } from '@/types' - -const HIDDEN_FILES = /^\./ - -/** - * Finds the first file in a directory whose basename (without extension) - * matches the given pattern (case-insensitive). - */ -function findFile(dir: string, pattern: RegExp): string | null { - let entries: string[] - try { - entries = fs.readdirSync(dir) - } catch { - return null - } - const match = entries.find( - (entry) => !HIDDEN_FILES.test(entry) && pattern.test(path.basename(entry, path.extname(entry))) - ) - return match ?? null -} - -function fileApiUrl(libraryId: string, relativePath: string): string { - return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - -function thumbnailApiUrl(libraryId: string, relativePath: string): string { - return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} +import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' /** * Attempts to build a Game from a directory. diff --git a/src/lib/media-utils.ts b/src/lib/media-utils.ts new file mode 100644 index 0000000..f005d7c --- /dev/null +++ b/src/lib/media-utils.ts @@ -0,0 +1,31 @@ +import fs from 'fs' +import path from 'path' + +export const HIDDEN_FILES = /^\./ + +/** Video extensions for dedicated media libraries (movies, TV). */ +export const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) + +export function fileApiUrl(libraryId: string, relativePath: string): string { + return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +export function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +/** + * Finds the first non-hidden file in a directory whose basename (without extension) + * matches the given pattern (case-insensitive). + */ +export function findFile(dir: string, pattern: RegExp): string | null { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return null + } + return entries.find( + (e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e))) + ) ?? null +} diff --git a/src/lib/movies.ts b/src/lib/movies.ts index 72dfd8d..43b3c82 100644 --- a/src/lib/movies.ts +++ b/src/lib/movies.ts @@ -2,34 +2,7 @@ import fs from 'fs' import path from 'path' import type { Movie } from '@/types' import { parseMovieNfo } from './nfo' - -const HIDDEN_FILES = /^\./ - -const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) - -function fileApiUrl(libraryId: string, relativePath: string): string { - return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - -function thumbnailApiUrl(libraryId: string, relativePath: string): string { - return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - -/** - * Finds the first file in a directory whose basename (without extension) - * matches the given pattern (case-insensitive). - */ -function findFile(dir: string, pattern: RegExp): string | null { - let entries: string[] - try { - entries = fs.readdirSync(dir) - } catch { - return null - } - return entries.find( - (e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e))) - ) ?? null -} +import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' function findVideoFile(dir: string): string | null { let entries: string[] diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 7f3e8c5..67e3cb1 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -8,8 +8,8 @@ import { scanMoviesLibrary } from './movies' import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv' import { scanGamesLibrary } from './games' import { getThumbnailPath } from './thumbnails' +import { VIDEO_EXTENSIONS } from './media-utils' -const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']) let scanRunning = false diff --git a/src/lib/tv.ts b/src/lib/tv.ts index 41bd620..3d3b3b6 100644 --- a/src/lib/tv.ts +++ b/src/lib/tv.ts @@ -2,39 +2,12 @@ import fs from 'fs' import path from 'path' import type { TvSeries, TvSeason, TvEpisode } from '@/types' import { parseTvShowNfo, parseEpisodeNfo } from './nfo' - -const HIDDEN_FILES = /^\./ - -const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) - -function fileApiUrl(libraryId: string, relativePath: string): string { - return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} - -function thumbnailApiUrl(libraryId: string, relativePath: string): string { - return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` -} +import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' function isVideoFile(name: string): boolean { return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase()) } -/** - * Finds the first file in a directory whose basename (without extension) - * matches the given pattern (case-insensitive). - */ -function findFile(dir: string, pattern: RegExp): string | null { - let entries: string[] - try { - entries = fs.readdirSync(dir) - } catch { - return null - } - return entries.find( - (e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e))) - ) ?? null -} - function readDirs(dir: string): string[] { try { return fs