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:
223
README.md
223
README.md
@@ -1,13 +1,18 @@
|
|||||||
# MediaLore
|
# 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
|
## 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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`.
|
- **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.
|
- **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -16,35 +21,77 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem.
|
|||||||
MediaLoreWeb/
|
MediaLoreWeb/
|
||||||
├── libraries.json # Runtime library config (managed via UI, do not edit by hand)
|
├── libraries.json # Runtime library config (managed via UI, do not edit by hand)
|
||||||
├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored)
|
├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored)
|
||||||
├── data/ # Example media (not committed to production)
|
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── layout.tsx
|
│ │ ├── layout.tsx
|
||||||
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
|
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
|
||||||
│ │ ├── manage/page.tsx # Library management — add/remove libraries
|
│ │ ├── manage/page.tsx # Library management
|
||||||
│ │ ├── library/[id]/page.tsx # Library view (games or mixed)
|
│ │ ├── 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/
|
│ │ └── api/
|
||||||
│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries
|
│ │ ├── auth/login/route.ts # POST /api/auth/login
|
||||||
│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id
|
│ │ ├── auth/logout/route.ts # POST /api/auth/logout
|
||||||
│ │ ├── games/route.ts # GET /api/games?libraryId=
|
│ │ ├── auth/register/route.ts # POST /api/auth/register
|
||||||
│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path=
|
│ │ ├── libraries/route.ts # GET, POST /api/libraries
|
||||||
│ │ ├── file/route.ts # GET /api/file?libraryId=&path=
|
│ │ ├── libraries/[id]/route.ts # PATCH, DELETE /api/libraries/:id
|
||||||
│ │ └── thumbnail/route.ts # GET /api/thumbnail?libraryId=&path=
|
│ │ ├── 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/
|
│ ├── components/
|
||||||
|
│ │ ├── FilterPanel.tsx
|
||||||
│ │ ├── LibraryCard.tsx
|
│ │ ├── LibraryCard.tsx
|
||||||
│ │ ├── NavLink.tsx
|
│ │ ├── NavLink.tsx
|
||||||
|
│ │ ├── DoomScrollView.tsx
|
||||||
│ │ ├── games/
|
│ │ ├── games/
|
||||||
│ │ │ ├── GamesView.tsx
|
│ │ │ ├── GamesView.tsx
|
||||||
│ │ │ └── GameDetailModal.tsx
|
│ │ │ └── GameDetailModal.tsx
|
||||||
│ │ └── mixed/
|
│ │ ├── movies/
|
||||||
│ │ ├── MixedView.tsx
|
│ │ │ ├── MoviesView.tsx
|
||||||
│ │ ├── VideoPlayerModal.tsx
|
│ │ │ └── MovieDetailModal.tsx
|
||||||
│ │ └── ImageLightbox.tsx
|
│ │ ├── tv/
|
||||||
|
│ │ │ └── TvView.tsx
|
||||||
|
│ │ ├── mixed/
|
||||||
|
│ │ │ ├── MixedView.tsx
|
||||||
|
│ │ │ ├── VideoPlayerModal.tsx
|
||||||
|
│ │ │ └── ImageLightbox.tsx
|
||||||
|
│ │ └── tags/
|
||||||
|
│ │ └── TagSelector.tsx
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers
|
│ │ ├── 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
|
│ │ ├── games.ts # Games library scanner
|
||||||
│ │ ├── files.ts # Mixed library directory scanner
|
│ │ ├── movies.ts # Movies library scanner
|
||||||
│ │ └── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg)
|
│ │ ├── 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/
|
│ └── types/
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
```
|
```
|
||||||
@@ -63,11 +110,14 @@ MediaLoreWeb/
|
|||||||
# 1. Install dependencies
|
# 1. Install dependencies
|
||||||
npm install
|
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
|
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:
|
Other available commands:
|
||||||
|
|
||||||
@@ -77,23 +127,37 @@ npm run start # Start production server (run build first)
|
|||||||
npm run lint # Run ESLint
|
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
|
## 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 |
|
| Field | Description |
|
||||||
|--------|-------------|
|
|-------|-------------|
|
||||||
| Name | Display name shown in the UI |
|
| Name | Display name shown in the UI |
|
||||||
| Path | Absolute or project-relative path to the library root folder on disk |
|
| Path | Absolute or project-relative path to the library root folder on disk |
|
||||||
| Type | `Games` or `Mixed Media` |
|
| Type | `games`, `movies`, `tv`, or `mixed` |
|
||||||
|
|
||||||
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.
|
The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Library Folder Conventions
|
## Library Folder Conventions
|
||||||
|
|
||||||
@@ -109,8 +173,41 @@ Games/
|
|||||||
└── widecover.jpg # Optional — landscape/hero cover art (case-insensitive)
|
└── widecover.jpg # Optional — landscape/hero cover art (case-insensitive)
|
||||||
```
|
```
|
||||||
|
|
||||||
- The `.zip` filename can be anything; the first `.zip` found in the folder is used.
|
Subdirectories without a `.zip` are treated as series containers — their child directories are scanned as individual games.
|
||||||
- Cover art filenames are matched case-insensitively against `cover.*` and `widecover.*`. Any image extension is accepted.
|
|
||||||
|
### 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"`)
|
### 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.
|
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 |
|
| Route | Method | Description |
|
||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `/api/libraries` | GET | Returns the full configured library list |
|
| `/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/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 |
|
### Media
|
||||||
| `/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 |
|
| 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
|
## 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/)
|
- [React 19](https://react.dev/)
|
||||||
- [TypeScript 5](https://www.typescriptlang.org/)
|
- [TypeScript 5](https://www.typescriptlang.org/)
|
||||||
- [Tailwind CSS 4](https://tailwindcss.com/)
|
- [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 path from 'path'
|
||||||
import type { DirectoryListing, FileEntry, MediaType } from '@/types'
|
import type { DirectoryListing, FileEntry, MediaType } from '@/types'
|
||||||
import { resolveAndJail } from '@/lib/libraries'
|
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 VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
@@ -15,14 +16,6 @@ function getMediaType(filename: string): MediaType {
|
|||||||
return 'other'
|
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(
|
export function scanDirectory(
|
||||||
libraryRoot: string,
|
libraryRoot: string,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Game, GameSeries } from '@/types'
|
import type { Game, GameSeries } from '@/types'
|
||||||
|
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to build a Game from a directory.
|
* 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 path from 'path'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import { parseMovieNfo } from './nfo'
|
import { parseMovieNfo } from './nfo'
|
||||||
|
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function findVideoFile(dir: string): string | null {
|
function findVideoFile(dir: string): string | null {
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { scanMoviesLibrary } from './movies'
|
|||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||||
import { scanGamesLibrary } from './games'
|
import { scanGamesLibrary } from './games'
|
||||||
import { getThumbnailPath } from './thumbnails'
|
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'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
|
||||||
|
|
||||||
let scanRunning = false
|
let scanRunning = false
|
||||||
|
|||||||
@@ -2,39 +2,12 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
import { parseTvShowNfo, parseEpisodeNfo } from './nfo'
|
import { parseTvShowNfo, parseEpisodeNfo } from './nfo'
|
||||||
|
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoFile(name: string): boolean {
|
function isVideoFile(name: string): boolean {
|
||||||
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
|
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[] {
|
function readDirs(dir: string): string[] {
|
||||||
try {
|
try {
|
||||||
return fs
|
return fs
|
||||||
|
|||||||
Reference in New Issue
Block a user