Merge pull request 'Reduce code duplication and update README' (#11) from cleanup-pass into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/11
This commit is contained in:
237
README.md
237
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=<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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
31
src/lib/media-utils.ts
Normal 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
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user