Reduce code duplication and update README

- Extract shared utilities (HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl,
  thumbnailApiUrl, findFile) into new src/lib/media-utils.ts, removing
  identical copies from games.ts, movies.ts, tv.ts, files.ts, and scanner.ts
- Add comment in files.ts clarifying why its VIDEO_EXTENSIONS set intentionally
  differs from the media library set (web-playable formats for the mixed browser)
- Rewrite README to reflect the current feature set: Movies/TV libraries, auth
  system, tag system, background scanner, updated project structure, folder
  conventions for all four library types, and a complete API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-06 12:49:24 -04:00
parent 80d922263e
commit 6b5ff81654
7 changed files with 228 additions and 141 deletions

237
README.md
View File

@@ -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=<id>` | GET | Scans the games library and returns structured game entries |
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |
| `/api/thumbnail?libraryId=<id>&path=<relpath>` | 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

View File

@@ -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,

View File

@@ -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.

31
src/lib/media-utils.ts Normal file
View File

@@ -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
}

View File

@@ -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[]

View File

@@ -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

View File

@@ -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