Compare commits

..

15 Commits

Author SHA1 Message Date
fe51f9f6c8 Merge pull request 'auth' (#4) from auth into main
Some checks failed
Container Publish / backend (push) Failing after 1m43s
Container Publish / frontend (push) Failing after 9s
Reviewed-on: #4
2026-08-03 20:12:03 +00:00
Garret Patti
4ea6f4f18c update docker compose 2026-08-03 15:11:42 -05:00
Garret Patti
0907f292ae ignore macos ds_store files 2026-07-29 07:22:18 -04:00
Garret Patti
2b0b19eb91 add auth 2026-06-29 10:41:29 -04:00
Garret Patti
d48c1e973e add agents and readme 2026-06-28 16:54:51 -04:00
Garret Patti
39bd815ff0 fix centering issue in media viewer 2026-05-20 17:14:51 -04:00
cab5b28a4d mute by default 2026-05-17 18:49:46 -04:00
a65d86bed6 readd controls 2026-05-17 18:46:54 -04:00
8152ab4a7a more control updates 2026-05-17 18:43:44 -04:00
1987ea4c96 play video inline and mute by default 2026-05-17 18:33:40 -04:00
d84600bce8 touch media viewer fixes 2026-05-17 17:02:53 -04:00
0f30400c7d doom scroll touch edit 2026-05-17 10:34:01 -04:00
80423c3ca2 Merge pull request 'mobile fixes' (#3) from responsive-ui into main
Reviewed-on: http://gitea.lan/gpatti/MediaLore-Web-App/pulls/3
2026-05-17 04:01:36 +00:00
9cd21f9568 mobile fixes 2026-05-17 00:01:21 -04:00
fbe78ae396 switch data to docker volume 2026-05-16 23:05:24 -04:00
27 changed files with 1603 additions and 53 deletions

View File

@@ -2,3 +2,19 @@
# Library paths you configure in the app must be subdirectories of this path.
# Inside the container, this maps to /media.
MEDIA_ROOT=/mnt/nas
# Authentication settings
# SECRET_KEY is used to sign JWT tokens. Leave unset to auto-generate one
# (all sessions will be invalidated on restart).
SECRET_KEY=
# Admin user created on first startup.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
# JWT token expiry in days (default: 30).
ACCESS_TOKEN_EXPIRE_DAYS=30
# Gitea container registry owner (username or org). Used by docker-compose.yml
# to pull pre-built images. Defaults to "gpatti".
OWNER=gpatti

View File

@@ -0,0 +1,93 @@
name: Container Publish
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
run: |
REGISTRY=git.gpatti.com
OWNER="${{ gitea.repository_owner }}"
IMAGE_NAME="${REGISTRY}/${OWNER}/medialore-backend"
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_type }}" == "tag" ]]; then
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
echo "tags=${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
fi
- name: Log in to registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.meta.outputs.registry }}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build and push
uses: https://github.com/docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
frontend:
runs-on: ubuntu-latest
needs: []
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
run: |
REGISTRY=git.gpatti.com
OWNER="${{ gitea.repository_owner }}"
IMAGE_NAME="${REGISTRY}/${OWNER}/medialore-frontend"
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_type }}" == "tag" ]]; then
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
echo "tags=${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
fi
- name: Log in to registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.meta.outputs.registry }}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build and push
uses: https://github.com/docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ frontend/dist/
__pycache__/
*.pyc
.venv/
.DS_Store

315
AGENTS.md Normal file
View File

@@ -0,0 +1,315 @@
# AGENTS.md
## Project Overview
MediaLore is a **self-hosted media library browser** for images and videos. It provides a web UI for browsing, searching, tagging, and viewing media with thumbnail previews and doom-scroll/TikTok-style consumption. **All access requires authentication** — users must log in before accessing any media or UI pages.
**Monorepo** with two independent apps:
- `backend/` — Python FastAPI server
- `frontend/` — React SPA (TypeScript, Vite)
## Tech Stack
| Layer | Technology |
|-------------|---------------------------------------------------------|
| Backend | Python 3.12+, FastAPI, Uvicorn |
| Database | SQLite via SQLAlchemy (async aiosqlite), WAL mode |
| Migrations | Alembic |
| Auth | JWT (python-jose) + bcrypt password hashing |
| Thumbnails | Pillow (images), ffmpeg (videos) — generated on-demand |
| File Watch | watchdog — live filesystem monitoring |
| Frontend | React 19, TypeScript 6, Vite 8, React Router 7, TanStack Query v5 |
| Infra | Docker Compose (backend: Python 3.12-slim, frontend: Nginx Alpine) |
## Directory Structure
```
medialore-web-app/
├── docker-compose.yml # Orchestration (backend, frontend, volumes)
├── .env.example # Environment variable template
├── .gitea/
│ └── workflows/
│ └── container-publish.yml # CI: build & push Docker images
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml # Python package config (hatchling build)
│ ├── alembic.ini
│ ├── alembic/ # DB migrations
│ │ ├── env.py
│ │ └── versions/
│ └── app/
│ ├── main.py # FastAPI app, CORS, lifespan, router registration
│ ├── config.py # Pydantic Settings (DATABASE_URL, MEDIA_ROOT, etc.)
│ ├── database.py # SQLAlchemy async engine, session, WAL pragma
│ ├── models.py # ORM models: Library, MediaItem, Tag, User, media_item_tags
│ ├── schemas.py # Pydantic request/response schemas (incl. auth)
│ ├── auth.py # bcrypt hashing, JWT create/decode, auth dependencies
│ ├── routers/
│ │ ├── auth.py # Login, token validation, user CRUD (admin)
│ │ ├── libraries.py # Library CRUD, browse, doom-scroll, scan-status, rescan
│ │ ├── media.py # Media item get, file serve, thumbnail, tag assignment
│ │ ├── tags.py # Tag CRUD, grouped by category
│ │ └── search.py # Fuzzy filename + tag search
│ └── services/
│ ├── scanner.py # Dir walk, SHA-256 hashing, moved file detection
│ ├── watcher.py # watchdog event handler (create/move/delete)
│ └── thumbnails.py # Pillow + ffmpeg thumbnail generation
└── frontend/
├── Dockerfile # Multi-stage: Node build + Nginx serve
├── nginx.conf # SPA fallback, /api proxy to backend
├── package.json
├── tsconfig.json # Root references
├── tsconfig.app.json # App TS config
├── tsconfig.node.json # Vite config TS config
├── vite.config.ts # Vite + React plugin, dev proxy /api -> localhost:8000
├── eslint.config.js # Flat config: JS + TS + React hooks + refresh
├── index.html
├── public/
│ ├── favicon.svg
│ └── icons.svg
└── src/
├── main.tsx # React entry point
├── App.tsx # Router, QueryClient, sidebar, theme, responsive layout
├── index.css # CSS custom properties for light/dark themes
├── api/
│ └── client.ts # Typed fetch wrapper; all endpoint functions
├── auth/
│ ├── AuthTypes.ts # AuthUser, AuthState interfaces, AuthContext
│ ├── AuthContext.tsx # AuthProvider component (login/logout/token mgmt)
│ └── useAuth.ts # useAuth hook
├── pages/
│ ├── LoginPage.tsx
│ ├── SearchPage.tsx
│ ├── BrowserPage.tsx
│ ├── SettingsPage.tsx
├── components/
│ ├── FileBrowser/FileBrowser.tsx
│ ├── MediaViewer/MediaViewer.tsx
│ ├── DoomScrollViewer/DoomScrollViewer.tsx
│ └── TagPanel/TagPanel.tsx
└── assets/
├── hero.png
└── vite.svg
```
## Common Commands
### Backend
```bash
# Install (from backend/)
pip install -e .
# Run dev server
cd backend && uvicorn app.main:app --reload --port 8000
# Generate a new Alembic migration
cd backend && alembic revision --autogenerate -m "description"
# Apply migrations
cd backend && alembic upgrade head
```
### Frontend
```bash
# Install dependencies (from frontend/)
npm install
# Dev server (proxies /api to localhost:8000)
npm run dev
# Production build
npm run build
# Lint
npm run lint
# Preview production build
npm run preview
```
### Docker
```bash
docker compose up -d
```
## Environment Variables
| Variable | Default | Purpose |
|-------------------------|-------------------------------------|------------------------------|
| `DATABASE_URL` | `sqlite+aiosqlite:////data/medialore.db` | SQLite connection string |
| `MEDIA_ROOT` | `/media` | Root path for media libraries |
| `THUMBNAIL_DIR` | `/data/thumbnails` | Cached thumbnail storage |
| `SECRET_KEY` | auto-generated | JWT signing key |
| `ADMIN_USERNAME` | `admin` | Initial admin user |
| `ADMIN_PASSWORD` | (required) | Initial admin password |
| `ACCESS_TOKEN_EXPIRE_DAYS` | `30` | JWT token lifetime in days |
Pydantic Settings reads from `.env` at startup (via `model_config = {"env_file": ".env"}`). For Docker, these are set in `docker-compose.yml`.
## Database
SQLite with **WAL mode** and a 10-second busy timeout (see `app/database.py:14-18`). Tables are auto-created at startup via `Base.metadata.create_all` in the FastAPI lifespan.
### Schema
- **libraries**: `id`, `name`, `path` (unique)
- **media_items**: `id`, `library_id` (FK), `rel_path`, `filename`, `file_hash` (SHA-256), `media_type` ("image" or "video"), `size_bytes`, `missing`, `created_at`, `updated_at` — unique on `(library_id, rel_path)`
- **tags**: `id`, `name`, `category` — unique on `(name, category)`
- **media_item_tags**: `media_item_id` (FK), `tag_id` (FK), composite PK
- **users**: `id`, `username` (unique), `password_hash`, `is_admin`, `created_at`
**Migrations**: Use Alembic. The initial migration is at `backend/alembic/versions/0001_initial_schema.py` and the users table migration at `0002_add_users_table.py`.
## API Conventions
All endpoints are prefixed with `/api`. All responses are JSON except `/api/media/:id/file` (binary stream) and `/api/media/:id/thumbnail` (JPEG).
**All endpoints require authentication** except `POST /api/auth/login`. The `Authorization: Bearer <token>` header must be included. File and thumbnail endpoints also accept `?token=` as a query parameter (for `<img>`/`<video>` tags that can't send headers).
### Auth Endpoints
| Method | Path | Auth | Purpose |
|--------|-------------------|--------|-----------------------------|
| `POST` | `/auth/login` | Public | Returns JWT `access_token` |
| `GET` | `/auth/me` | User | Current user info |
| `GET` | `/auth/users` | Admin | List all users |
| `POST` | `/auth/users` | Admin | Create a new user |
| `DELETE` | `/auth/users/{id}` | Admin | Delete a user (not self) |
### Async Database Access
Routers use FastAPI's `Depends` injection with the `get_db` async generator from `database.py`:
```python
from app.database import get_db
@router.get("/...")
async def endpoint(db: AsyncSession = Depends(get_db)):
...
```
### Authentication Dependency
All protected endpoints use `get_current_user` (from `app/auth.py`) as a FastAPI dependency:
```python
from app.auth import get_current_user
from app.models import User
@router.get("/...")
async def endpoint(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
...
```
Admin-only endpoints chain `get_current_admin_user`:
```python
from app.auth import get_current_admin_user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_admin_user),
...
):
```
File and thumbnail endpoints use `get_current_user_from_query_or_header` which accepts the token via `Authorization` header or `?token=` query parameter.
### Frontend API Client
All API calls go through the typed `api` object in `frontend/src/api/client.ts`. Use `useQuery` / `useMutation` from TanStack Query. Never call `fetch` directly from components.
```ts
import { api } from "../api/client";
import { useQuery } from "@tanstack/react-query";
const { data } = useQuery({
queryKey: ["libraries", id, "browse", path],
queryFn: () => api.libraries.browse(id, path),
});
```
## Code Patterns & Conventions
### Backend (Python)
- **No linter configured** — follow the existing style (4-space indent, snake_case)
- Models use SQLAlchemy 2.0-style `mapped_column` (not `Column()`)
- Schemas use Pydantic `BaseModel`
- Service modules handle business logic; routers handle HTTP concerns
- Background scanning runs via `asyncio.create_task`; watchers run in separate threads via `watchdog.Observer`
- Logging: use `logging.getLogger(__name__)` — the `app` namespace has a StreamHandler configured in `main.py`
- Auth: use `bcrypt` directly (not passlib) for password hashing; `python-jose` for JWT tokens
- All protected endpoints must include `_user: User = Depends(get_current_user)` as a parameter
### Frontend (TypeScript/React)
- **ESLint flat config** with strict TypeScript-ESLint rules
- Inline `style={{ ... }}` objects for CSS (no CSS modules, no Tailwind)
- TypeScript interfaces for all API types live in `client.ts`
- Components use `useState`/`useEffect`/`useQuery` hooks, not class components
- Theme state persisted in `localStorage` key `"theme"`, applied as `data-theme` attribute
- Auth token persisted in `localStorage` key `"token"`, managed via `AuthProvider` context
- Responsive via `window.matchMedia("(max-width: 767px)")` listener
- File/thumbnail URLs constructed with `api.media.fileUrl(id)` and `api.media.thumbnailUrl(id)` (not fetched)
- API client automatically injects `Authorization: Bearer <token>` header; handles 401 by clearing token
### TypeScript Configuration
- `tsconfig.app.json`: strict mode, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`
- `tsconfig.node.json`: for `vite.config.ts` only
- Build command: `tsc -b && vite build` (type-check via project references)
## Testing
**There are no test files in this project.** No test framework is configured. When adding tests:
- Frontend: consider Vitest (pairs with Vite) + React Testing Library
- Backend: consider pytest + pytest-asyncio + httpx (for FastAPI TestClient)
- No existing test commands in CI, so add them and consider a GitHub Action or similar
## Docker / Deployment
- `docker compose up -d` starts both services
- Images are pulled from the Gitea container registry (`git.gpatti.com/<owner>/medialore-backend` and `...frontend`)
- Backend runs on port 8000 (internal), frontend Nginx on port 80 (mapped to 8080 on host)
- Nginx proxies `/api/` to `http://backend:8000`
- Media paths from the host are volume-mounted into the container at `/media`
- Persistent data (DB, thumbnails) stored in Docker volume `medialore-data`
- Set `OWNER` in `.env` to control which registry namespace images are pulled from
### CI/CD (Gitea Actions)
The workflow at `.gitea/workflows/container-publish.yml` automatically builds and pushes Docker images to the Gitea container registry on push to `main` or version tags (`v*`).
**Required secrets** (set in repo Settings → Actions → Secrets):
| Secret | Purpose |
|--------|---------|
| `REGISTRY_USER` | Gitea username for docker login |
| `REGISTRY_TOKEN` | Gitea PAT with `read:packages` and `write:packages` scopes |
To pull images from the private registry on a host, run `docker login git.gpatti.com` first with the same credentials.
### Local Development (without registry)
For local development without pulling from the registry, switch docker-compose.yml back to local builds:
```yaml
services:
backend:
build: ./backend # instead of image: ...
frontend:
build: ./frontend # instead of image: ...
```
## Important Notes
- **Authentication is required** for all endpoints and UI pages except `/api/auth/login`. JWT tokens are signed with `SECRET_KEY` (auto-generated if unset, so set it for persistent sessions). File/thumbnail endpoints also accept `?token=` as a query parameter since `<img>`/`<video>` tags can't send headers.
- Thumbnails are generated on first request and cached to disk (`THUMBNAIL_DIR`)
- The scanner computes SHA-256 hashes to detect moved/renamed files and avoid re-processing
- File watcher uses watchdog with per-library `PollingObserver` (necessary for Docker volumes)
- `media_items.missing` flag is set when a scanned file no longer exists on disk
- The frontend `build` command runs `tsc -b` first, which checks types across project references — type errors will block the build
- Admin user is created automatically on first startup from `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars; additional users can be created via the Settings page by an admin

193
README.md Normal file
View File

@@ -0,0 +1,193 @@
# MediaLore
A self-hosted media library browser for images and videos. Organize your local media collections with tagged browsing, a file-system explorer, and a full-screen "doom scroll" viewer — all via a clean, responsive web UI.
## Features
- **Media Libraries** — Add local directories as named libraries; files are indexed by relative path within each library
- **File Browser** — Navigate your library's directory structure with a thumbnail grid, breadcrumbs, and lazy-loaded previews
- **Tagging System** — Organize media with named tags grouped into categories; create tags inline while viewing items
- **Search** — Filter by filename (fuzzy match), tag, and/or library
- **Doom Scroll** — Full-screen, swipe/scroll-driven media viewer for an immersive, social-media-like experience
- **Live File Watching** — Automatically detects new, moved, and deleted files via `watchdog` (no manual rescans needed)
- **Auto-Generated Thumbnails** — JPEG thumbnails for images (via Pillow) and videos (via ffmpeg)
- **Dark / Light Theme** — Toggle with persistence via `localStorage`
- **Responsive** — Mobile-friendly with a collapsible sidebar and touch gestures
## Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Frontend │──────▶│ Backend │──────▶│ SQLite DB │
│ React + │ HTTP │ FastAPI + │ ORM │ (SQLite + │
│ TypeScript │ │ Uvicorn │ │ WAL mode) │
│ Vite │ │ │ │ │
└─────────────┘ └──────┬───────┘ └─────────────┘
┌───────▼────────┐
│ File System │
│ (media root) │
└─────────────────┘
```
**Backend** — Python 3.12+, FastAPI, SQLAlchemy (async), aiosqlite, Alembic for migrations, `watchdog` for file-system events, Pillow for image thumbnails, ffmpeg for video thumbnails.
**Frontend** — React 19, TypeScript, Vite 8, React Router 7, TanStack Query, Nginx (static serving + API proxy).
## Quick Start (Docker Compose)
### Prerequisites
- Docker & Docker Compose
- ffmpeg (bundled in the backend container)
- Your media files accessible on the host (e.g. mounted from a NAS)
### 1. Clone & configure
```bash
git clone <repo-url>
cd MediaLore-Web-App
cp .env.example .env
```
Edit `.env` to point `MEDIA_ROOT` to your host media directory:
```env
MEDIA_ROOT=/mnt/nas
```
> **Note:** Inside the container, `$MEDIA_ROOT` maps to `/media`. Library paths you add in the UI must be subdirectories of this mount.
### 2. Start the stack
```bash
docker compose up --build -d
```
The frontend is available at `http://localhost:8085`.
### 3. Add a library
1. Open **Settings****Libraries**
2. Enter a name and a path (e.g. `/media/Images/Photos`)
3. Click **Add Library** — scanning begins in the background
The scanner walks the directory tree, computes SHA-256 hashes, detects moved files by hash, and starts a file watcher for live updates.
## Docker Compose Configuration
```yaml
services:
backend:
build: ./backend
volumes:
- medialore-data:/data # DB + thumbnails
- /data/smb/adult/Images:/media/Images
- /data/smb/adult/Video Clips:/media/Video Clips
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- THUMBNAIL_DIR=/data/thumbnails
frontend:
build: ./frontend
ports:
- "8085:80"
```
Adjust the volume mounts to match your media layout. The `medialore-data` named volume persists the SQLite database and generated thumbnails across restarts.
## API Endpoints
All endpoints are under `/api` and return JSON (except file/thumbnail responses).
### Libraries
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/libraries` | List all libraries |
| `POST` | `/libraries` | Create a library (`{ name, path }`) |
| `GET` | `/libraries/:id/scan-status` | Check if a library is currently scanning |
| `POST` | `/libraries/:id/rescan` | Trigger a manual rescan |
| `GET` | `/libraries/:id/browse` | Browse directory entries (`?path=/sub/dir`) |
| `GET` | `/libraries/:id/doom-scroll` | Get all media items in a library (optionally under a path) |
| `DELETE` | `/libraries/:id` | Remove a library (stops watcher, deletes records) |
### Media
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/media/:id` | Get media item details (with tags) |
| `GET` | `/media/:id/file` | Stream the original media file |
| `GET` | `/media/:id/thumbnail` | Get or generate a thumbnail (JPEG) |
| `PUT` | `/media/:id/tags` | Set tags on an item (`{ tag_ids: [1, 2] }`) |
### Tags
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/tags` | List all tags grouped by category |
| `POST` | `/tags` | Create a tag (`{ name, category }`) |
| `DELETE` | `/tags/:id` | Delete a tag |
### Search
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/search?q=foo&tags=1,2&library_id=3` | Search media by filename, tags, and/or library |
## Development
### Backend (local)
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]" # or: pip install fastapi uvicorn[standard] sqlalchemy aiosqlite alembic pydantic-settings watchdog Pillow python-multipart
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### Frontend (local)
```bash
cd frontend
npm install
npm run dev
```
The Vite dev server runs at `http://localhost:5173`. Configure your proxy or set `VITE_API_BASE` to point to the backend.
## Data Model
```
┌──────────┐ 1..* ┌────────────┐ *..* ┌─────┐
│ Library │─────────────────▶│ MediaItem │─────────────────▶│ Tag │
└──────────┘ └────────────┘ └─────┘
• id • id
• name • library_id
• path • rel_path
• filename
• file_hash (SHA-256)
• media_type (image | video)
• size_bytes
• missing (file deleted from disk)
• created_at
• updated_at
• tags[]
```
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, TypeScript, Vite, React Router, TanStack Query |
| Backend | Python 3.12+, FastAPI, Uvicorn |
| Database | SQLite (async via aiosqlite, WAL mode) |
| Migrations | Alembic |
| Thumbnails | Pillow (images), ffmpeg (videos) |
| File Watching | watchdog |
| Containerization | Docker (Python 3.12-slim, Node 20-alpine, Nginx Alpine) |
## License
[Add your license here]

View File

@@ -0,0 +1,29 @@
"""add users table
Revision ID: 0002
Revises: 0001
Create Date: 2026-06-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(), nullable=False, unique=True),
sa.Column("password_hash", sa.String(), nullable=False),
sa.Column("is_admin", sa.Boolean(), default=False),
sa.Column("created_at", sa.DateTime(), default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("users")

81
backend/app/auth.py Normal file
View File

@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from jose import JWTError, jwt
from app.config import settings
from app.database import get_db
from app.models import User
security = HTTPBearer()
_optional_security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.access_token_expire_days)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
except JWTError:
raise HTTPException(401, "Invalid or expired token")
async def _resolve_user(token_str: str, db: AsyncSession) -> User:
payload = decode_access_token(token_str)
user_id_str: str = payload.get("sub")
if user_id_str is None:
raise HTTPException(401, "Invalid token payload")
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
raise HTTPException(401, "Invalid token payload")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(401, "User not found")
return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
return await _resolve_user(credentials.credentials, db)
async def get_current_user_from_query_or_header(
token: str | None = Query(default=None),
credentials: HTTPAuthorizationCredentials | None = Depends(_optional_security),
db: AsyncSession = Depends(get_db),
) -> User:
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
else:
raise HTTPException(401, "Not authenticated")
return await _resolve_user(token_str, db)
async def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_admin:
raise HTTPException(403, "Admin privileges required")
return current_user

View File

@@ -1,15 +1,34 @@
import secrets
import logging
from pydantic_settings import BaseSettings
from pathlib import Path
log = logging.getLogger(__name__)
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:////data/medialore.db"
media_root: str = "/media"
thumbnail_dir: str = "/data/thumbnails"
secret_key: str = ""
admin_username: str = "admin"
admin_password: str = ""
access_token_expire_days: int = 30
model_config = {"env_file": ".env"}
settings = Settings()
if not settings.secret_key:
settings.secret_key = secrets.token_urlsafe(32)
log.warning(
"SECRET_KEY not set — generated random key: %s. "
"All sessions will be invalidated on restart. "
"Set SECRET_KEY in .env for persistent sessions.",
settings.secret_key,
)
THUMBNAIL_DIR = Path(settings.thumbnail_dir)
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import select
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
# ends up with no handler, so app.* records are silently discarded.
@@ -17,18 +18,45 @@ _app_logger.propagate = False
log = logging.getLogger(__name__)
from app.database import engine, Base
from app.routers import libraries, media, tags, search
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import libraries, media, tags, search, auth
from app.services import watcher as watcher_service
from app.auth import hash_password
import app.models # noqa: F401 — registers models with Base.metadata
async def _ensure_admin_user():
from app.models import User
if not settings.admin_password:
log.warning("ADMIN_PASSWORD not set — no admin user will be created.")
return
async with SessionLocal() as db:
result = await db.execute(select(User).where(User.username == settings.admin_username))
if result.scalars().first():
log.info("Admin user '%s' already exists.", settings.admin_username)
return
user = User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
is_admin=True,
)
db.add(user)
await db.commit()
log.info("Created admin user '%s'.", settings.admin_username)
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _ensure_admin_user()
log.info("Starting library watchers...")
await watcher_service.start_all()
@@ -54,6 +82,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api")
app.include_router(libraries.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tags.router, prefix="/api")

View File

@@ -54,3 +54,13 @@ class Tag(Base):
category: Mapped[str] = mapped_column(String, nullable=False)
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String, nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(String, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, TokenResponse, UserOut, UserCreate
from app.auth import (
verify_password,
hash_password,
create_access_token,
get_current_user,
get_current_admin_user,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalars().first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid username or password")
token = create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/users", response_model=list[UserOut])
async def list_users(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin_user),
):
result = await db.execute(select(User).order_by(User.username))
return result.scalars().all()
@router.post("/users", response_model=UserOut, status_code=201)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin_user),
):
existing = await db.execute(select(User).where(User.username == body.username))
if existing.scalars().first():
raise HTTPException(409, "Username already exists")
user = User(
username=body.username,
password_hash=hash_password(body.password),
is_admin=False,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=204)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(404, "User not found")
await db.delete(user)
await db.commit()

View File

@@ -5,15 +5,19 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import Library, MediaItem
from app.models import Library, MediaItem, User
from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service
from app.auth import get_current_user
router = APIRouter(prefix="/libraries", tags=["libraries"])
@router.get("", response_model=list[LibraryOut])
async def list_libraries(db: AsyncSession = Depends(get_db)):
async def list_libraries(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library))
return result.scalars().all()
@@ -23,6 +27,7 @@ async def create_library(
body: LibraryCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
path = Path(body.path)
if not path.is_dir():
@@ -44,7 +49,10 @@ async def create_library(
@router.get("/{library_id}/scan-status")
async def get_scan_status(library_id: int):
async def get_scan_status(
library_id: int,
_user: User = Depends(get_current_user),
):
return {"scanning": scanner.is_scanning(library_id)}
@@ -53,6 +61,7 @@ async def rescan_library(
library_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
@@ -69,6 +78,7 @@ async def doom_scroll(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first():
@@ -87,7 +97,11 @@ async def doom_scroll(
@router.delete("/{library_id}", status_code=204)
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
async def delete_library(
library_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:
@@ -98,7 +112,12 @@ async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{library_id}/browse", response_model=BrowseResult)
async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)):
async def browse_library(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:

View File

@@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Library, MediaItem, Tag
from app.models import Library, MediaItem, Tag, User
from app.schemas import MediaItemOut, TagIdList
from app.services.thumbnails import get_or_create_thumbnail
from app.auth import get_current_user, get_current_user_from_query_or_header
router = APIRouter(prefix="/media", tags=["media"])
@@ -33,7 +34,11 @@ def _resolve_safe(lib: Library, item: MediaItem) -> Path:
@router.get("/{media_id}", response_model=MediaItemOut)
async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
async def get_media_item(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(
select(MediaItem).where(MediaItem.id == media_id)
)
@@ -46,7 +51,11 @@ async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{media_id}/file")
async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
async def serve_file(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user_from_query_or_header),
):
item, lib = await _get_item_and_lib(media_id, db)
if item.missing:
raise HTTPException(404, "File is missing from disk")
@@ -57,7 +66,11 @@ async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{media_id}/thumbnail")
async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
async def serve_thumbnail(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user_from_query_or_header),
):
item, lib = await _get_item_and_lib(media_id, db)
abs_path = _resolve_safe(lib, item)
thumb = get_or_create_thumbnail(media_id, str(abs_path), item.media_type)
@@ -67,7 +80,12 @@ async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
@router.put("/{media_id}/tags", response_model=MediaItemOut)
async def set_tags(media_id: int, body: TagIdList, db: AsyncSession = Depends(get_db)):
async def set_tags(
media_id: int,
body: TagIdList,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
item = result.scalars().first()
if not item:

View File

@@ -4,8 +4,9 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import MediaItem, media_item_tags
from app.models import MediaItem, media_item_tags, User
from app.schemas import MediaItemOut
from app.auth import get_current_user
router = APIRouter(prefix="/search", tags=["search"])
@@ -16,6 +17,7 @@ async def search(
tags: str = Query(default=""),
library_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
stmt = (
select(MediaItem)

View File

@@ -3,14 +3,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Tag
from app.models import Tag, User
from app.schemas import TagCreate, TagOut, TagsByCategory
from app.auth import get_current_user
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("", response_model=list[TagsByCategory])
async def list_tags(db: AsyncSession = Depends(get_db)):
async def list_tags(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
tags = result.scalars().all()
@@ -22,7 +26,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
@router.post("", response_model=TagOut, status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
async def create_tag(
body: TagCreate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
existing = await db.execute(
select(Tag).where(Tag.name == body.name, Tag.category == body.category)
)
@@ -36,7 +44,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
async def delete_tag(
tag_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalars().first()
if not tag:

View File

@@ -86,3 +86,28 @@ class SearchResult(BaseModel):
class TagIdList(BaseModel):
tag_ids: list[int]
# --- Auth ---
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
username: str
password: str

View File

@@ -12,6 +12,8 @@ dependencies = [
"watchdog>=4.0",
"Pillow>=10.0",
"python-multipart>=0.0.9",
"python-jose[cryptography]>=3.3",
"bcrypt>=4.0",
]
[build-system]

View File

@@ -1,18 +1,24 @@
services:
backend:
build: ./backend
image: git.gpatti.com/${OWNER:-gpatti}/medialore-backend:latest
restart: unless-stopped
volumes:
- ./data:/data
- /data/smb/adult/Images:/media/Images
- /data/smb/adult/Video Clips:/media/Video Clips
- medialore-data:/data
# set volumes for media
- data:/media
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- THUMBNAIL_DIR=/data/thumbnails
- SECRET_KEY=${SECRET_KEY:-}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
frontend:
build: ./frontend
image: git.gpatti.com/${OWNER:-gpatti}/medialore-frontend:latest
restart: unless-stopped
ports:
- "8080:80"
depends_on:
- backend
volumes:
medialore-data:

View File

@@ -1,13 +1,34 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { BrowserRouter, Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, type Library } from "./api/client";
import { AuthProvider } from "./auth/AuthContext";
import { useAuth } from "./auth/useAuth";
import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage";
import LoginPage from "./pages/LoginPage";
const queryClient = new QueryClient();
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
<span style={{ color: "var(--text-secondary)" }}>Loading</span>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function useTheme() {
const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark"
@@ -21,10 +42,13 @@ function useTheme() {
return { dark, toggle: () => setDark((d) => !d) };
}
function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boolean }) {
function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
enabled: !!user,
});
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
@@ -37,6 +61,11 @@ function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boo
fontWeight: isActive ? 600 : 400,
});
function handleLogout() {
logout();
navigate("/login");
}
return (
<nav style={{
width: 220,
@@ -52,7 +81,7 @@ function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boo
MediaLore
</div>
<NavLink to="/search" style={linkStyle}>Search</NavLink>
<NavLink to="/search" style={linkStyle} onClick={onClose}>Search</NavLink>
{libraries.length > 0 && (
<>
@@ -60,7 +89,7 @@ function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boo
Libraries
</div>
{libraries.map((lib) => (
<NavLink key={lib.id} to={`/library/${lib.id}`} style={linkStyle}>
<NavLink key={lib.id} to={`/library/${lib.id}`} style={linkStyle} onClick={onClose}>
{lib.name}
</NavLink>
))}
@@ -68,7 +97,7 @@ function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boo
)}
<div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 4 }}>
<NavLink to="/settings" style={linkStyle}>Settings</NavLink>
<NavLink to="/settings" style={linkStyle} onClick={onClose}>Settings</NavLink>
<button
onClick={onToggleTheme}
title={dark ? "Switch to light mode" : "Switch to dark mode"}
@@ -76,23 +105,85 @@ function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boo
>
{dark ? "☀ Light mode" : "☾ Dark mode"}
</button>
{user && (
<div style={{ fontSize: 11, color: "var(--text-muted)", padding: "4px 12px 0" }}>
{user.username}
</div>
)}
<button
onClick={handleLogout}
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--danger)", borderRadius: 4 }}
>
Sign out
</button>
</div>
</nav>
);
}
function AppShell() {
const { isAuthenticated } = useAuth();
const { dark, toggle } = useTheme();
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
setIsMobile(mq.matches);
const handler = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
if (!e.matches) setSidebarOpen(false);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
return (
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
<Sidebar onToggleTheme={toggle} dark={dark} />
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
{/* Mobile hamburger button */}
{isMobile && (
<button
onClick={() => setSidebarOpen((v) => !v)}
style={{ position: "fixed", top: 12, left: 12, zIndex: 301, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 6, padding: "6px 10px", color: "var(--text)", fontSize: 18, cursor: "pointer" }}
aria-label="Toggle menu"
>
</button>
)}
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<div
onClick={() => setSidebarOpen(false)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 299 }}
/>
)}
{/* Sidebar */}
<div style={isMobile ? {
position: "fixed", top: 0, left: 0, bottom: 0, zIndex: 300,
transform: sidebarOpen ? "translateX(0)" : "translateX(-100%)",
transition: "transform 0.2s ease",
} : {}}>
<Sidebar onToggleTheme={toggle} dark={dark} onClose={isMobile ? () => setSidebarOpen(false) : undefined} />
</div>
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/library/:libraryId" element={<BrowserPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/library/:libraryId" element={<ProtectedRoute><BrowserPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/login" element={<LoginPage />} />
</Routes>
</main>
</div>
@@ -102,9 +193,11 @@ function AppShell() {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppShell />
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<AppShell />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -1,5 +1,9 @@
const BASE = "/api";
function getToken(): string | null {
return localStorage.getItem("token");
}
export interface Library {
id: number;
name: string;
@@ -42,11 +46,28 @@ export interface BrowseResult {
entries: BrowseEntry[];
}
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
headers,
...init,
});
if (res.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`);
@@ -56,6 +77,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
auth: {
login: (username: string, password: string) =>
request<{ access_token: string; token_type: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<AuthUser>("/auth/me"),
listUsers: () => request<AuthUser[]>("/auth/users"),
createUser: (username: string, password: string) =>
request<AuthUser>("/auth/users", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
deleteUser: (id: number) =>
request<void>(`/auth/users/${id}`, { method: "DELETE" }),
},
libraries: {
list: () => request<Library[]>("/libraries"),
create: (name: string, path: string) =>
@@ -77,8 +115,16 @@ export const api = {
media: {
get: (id: number) => request<MediaItem>(`/media/${id}`),
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
fileUrl: (id: number) => {
const token = getToken();
const base = `${BASE}/media/${id}/file`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
},
thumbnailUrl: (id: number) => {
const token = getToken();
const base = `${BASE}/media/${id}/thumbnail`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
},
setTags: (id: number, tagIds: number[]) =>
request<MediaItem>(`/media/${id}/tags`, {
method: "PUT",

View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AuthContext, type AuthUser } from "./AuthTypes";
const BASE = "/api";
async function fetchMe(token: string) {
try {
const res = await fetch(`${BASE}/auth/me`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function validateToken() {
if (!token) return;
const u = await fetchMe(token);
if (cancelled) return;
if (u) {
setUser(u);
} else {
localStorage.removeItem("token");
setToken(null);
}
}
validateToken().finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
const login = useCallback(async (username: string, password: string) => {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`);
}
const data = await res.json();
const newToken = data.access_token;
localStorage.setItem("token", newToken);
setToken(newToken);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,19 @@
import { createContext } from "react";
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthState | null>(null);

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext, type AuthState } from "./AuthTypes";
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type MediaItem } from "../../api/client";
interface Props {
@@ -11,10 +11,12 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
const [index, setIndex] = useState(0);
const [fading, setFading] = useState(false);
const wheelLock = useRef(false);
const touchStartY = useRef<number | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const item = items[index];
function go(delta: 1 | -1) {
const go = useCallback((delta: 1 | -1) => {
if (wheelLock.current) return;
const next = index + delta;
if (next < 0 || next >= items.length) return;
@@ -25,7 +27,7 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
setFading(false);
wheelLock.current = false;
}, 200);
}
}, [index, items.length]);
useEffect(() => {
const onWheel = (e: WheelEvent) => { e.deltaY > 0 ? go(1) : go(-1); };
@@ -40,7 +42,62 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
window.removeEventListener("wheel", onWheel);
window.removeEventListener("keydown", onKey);
};
}, [index, fading]);
}, [go, onClose]);
useEffect(() => {
const onTouchStart = (e: TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
if (contentRef.current) contentRef.current.style.transition = "none";
};
const onTouchMove = (e: TouchEvent) => {
e.preventDefault();
if (touchStartY.current === null || !contentRef.current) return;
const offset = e.touches[0].clientY - touchStartY.current;
contentRef.current.style.transform = `translateY(${offset}px)`;
contentRef.current.style.opacity = String(Math.max(0.3, 1 - Math.abs(offset) / 300));
};
const onTouchEnd = (e: TouchEvent) => {
if (touchStartY.current === null) return;
const delta = touchStartY.current - e.changedTouches[0].clientY;
touchStartY.current = null;
if (Math.abs(delta) > 80) {
// Hand off to the fading animation
if (contentRef.current) {
contentRef.current.style.transition = "";
contentRef.current.style.transform = "";
contentRef.current.style.opacity = "";
}
go(delta > 0 ? 1 : -1);
} else {
// Snap back to center
if (contentRef.current) {
const el = contentRef.current;
el.style.transition = "opacity 0.25s ease, transform 0.25s ease";
el.style.transform = "translateY(0)";
el.style.opacity = "1";
setTimeout(() => {
if (contentRef.current) {
contentRef.current.style.transition = "";
contentRef.current.style.transform = "";
contentRef.current.style.opacity = "";
}
}, 260);
}
}
};
window.addEventListener("touchstart", onTouchStart);
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
return () => {
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
};
}, [go]);
return (
<>
@@ -49,6 +106,7 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
{/* Media area */}
<div
ref={contentRef}
style={{
position: "fixed", inset: 0, zIndex: 201,
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
@@ -58,7 +116,6 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
transform: fading ? "translateY(-12px)" : "translateY(0)",
}}
>
<div style={{ color: "#ccc", fontSize: 13 }}>{item?.filename}</div>
{item?.media_type === "image" && (
<img
key={item.id}
@@ -71,8 +128,11 @@ export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Pr
<video
key={item.id}
src={api.media.fileUrl(item.id)}
controls
autoPlay
muted
playsInline
controls
loop
style={{ maxWidth: "90vw", maxHeight: "82vh" }}
/>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, type BrowseEntry, type MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel";
@@ -11,7 +11,14 @@ interface Props {
}
export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) {
const [showTags, setShowTags] = useState(true);
const TAG_PANEL_WIDTH = 260;
const EDGE_GAP = 16;
const BASE_CARD_TRANSFORM = "translate(-50%, -50%)";
const [showTags, setShowTags] = useState(() => window.innerWidth >= 768);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
const swipeAxis = useRef<"horizontal" | "vertical" | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { data: item } = useQuery<MediaItem>({
queryKey: ["media", mediaId],
@@ -22,6 +29,16 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
const currentIndex = mediaSiblings.findIndex((e) => e.media_item_id === mediaId);
const prevId = currentIndex > 0 ? mediaSiblings[currentIndex - 1].media_item_id : null;
const nextId = currentIndex < mediaSiblings.length - 1 ? mediaSiblings[currentIndex + 1].media_item_id : null;
const cardCenterX = showTags ? `calc((100vw - ${TAG_PANEL_WIDTH}px) / 2)` : "50%";
// Clear inline styles when a new item loads so the card appears cleanly
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.transition = "";
contentRef.current.style.transform = BASE_CARD_TRANSFORM;
contentRef.current.style.opacity = "1";
}
}, [mediaId, BASE_CARD_TRANSFORM]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -29,9 +46,93 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
if (e.key === "ArrowLeft" && prevId) onNavigate(prevId);
if (e.key === "ArrowRight" && nextId) onNavigate(nextId);
}
const onTouchStart = (e: TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
swipeAxis.current = null;
if (contentRef.current) contentRef.current.style.transition = "none";
};
const onTouchMove = (e: TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
const dx = e.touches[0].clientX - touchStartX.current;
const dy = e.touches[0].clientY - touchStartY.current;
// Commit to an axis on the first significant movement
if (swipeAxis.current === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
swipeAxis.current = Math.abs(dx) >= Math.abs(dy) ? "horizontal" : "vertical";
}
// Vertical gestures (tag panel scroll, etc.) pass through untouched
if (swipeAxis.current !== "horizontal") return;
e.preventDefault();
if (!contentRef.current) return;
contentRef.current.style.transform = `translate(calc(-50% + ${dx}px), -50%)`;
contentRef.current.style.opacity = String(Math.max(0.4, 1 - Math.abs(dx) / 400));
};
const onTouchEnd = (e: TouchEvent) => {
if (touchStartX.current === null) return;
const delta = touchStartX.current - e.changedTouches[0].clientX;
touchStartX.current = null;
touchStartY.current = null;
// Non-horizontal gesture: just reset the transition we disabled on touchstart
if (swipeAxis.current !== "horizontal") {
swipeAxis.current = null;
if (contentRef.current) {
contentRef.current.style.transition = "";
contentRef.current.style.transform = BASE_CARD_TRANSFORM;
contentRef.current.style.opacity = "1";
}
return;
}
swipeAxis.current = null;
const targetId = delta > 0 ? nextId : prevId;
if (Math.abs(delta) > 80 && targetId) {
const el = contentRef.current;
if (el) {
const slideX = delta > 0 ? -120 : 120;
el.style.transition = "opacity 0.2s ease, transform 0.2s ease";
el.style.transform = `translate(calc(-50% + ${slideX}px), -50%)`;
el.style.opacity = "0";
setTimeout(() => onNavigate(targetId), 200);
} else {
onNavigate(targetId);
}
} else {
// Snap back to center
const el = contentRef.current;
if (el) {
el.style.transition = "opacity 0.25s ease, transform 0.25s ease";
el.style.transform = "translate(-50%, -50%)";
el.style.opacity = "1";
setTimeout(() => {
if (contentRef.current) {
contentRef.current.style.transition = "";
contentRef.current.style.transform = BASE_CARD_TRANSFORM;
contentRef.current.style.opacity = "1";
}
}, 260);
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [prevId, nextId, onClose, onNavigate]);
window.addEventListener("touchstart", onTouchStart);
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
};
}, [prevId, nextId, onClose, onNavigate, BASE_CARD_TRANSFORM]);
return (
<>
@@ -54,15 +155,16 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ position: "fixed", right: showTags ? 276 : 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
style={{ position: "fixed", right: showTags ? TAG_PANEL_WIDTH + EDGE_GAP : EDGE_GAP, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Media card */}
<div
ref={contentRef}
onClick={(e) => e.stopPropagation()}
style={{ position: "fixed", top: "50%", left: "50%", transform: "translate(-50%, -50%)", zIndex: 101, background: "#1a1a1a", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 12, maxWidth: "80vw", maxHeight: "90vh", overflow: "auto" }}
style={{ position: "fixed", top: "50%", left: cardCenterX, transform: BASE_CARD_TRANSFORM, zIndex: 101, background: "#1a1a1a", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 12, maxWidth: "80vw", maxHeight: "90vh", overflow: "auto" }}
>
{item?.filename && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
@@ -103,7 +205,7 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
{showTags && item && (
<div
onClick={(e) => e.stopPropagation()}
style={{ position: "fixed", top: 0, right: 0, height: "100%", width: 260, background: "#1a1a1a", borderLeft: "1px solid #333", padding: "48px 16px 16px", zIndex: 101, overflowY: "auto" }}
style={{ position: "fixed", top: 0, right: 0, height: "100%", width: TAG_PANEL_WIDTH, background: "#1a1a1a", borderLeft: "1px solid #333", padding: "48px 16px 16px", zIndex: 101, overflowY: "auto" }}
>
<TagPanel item={item} />
</div>

View File

@@ -0,0 +1,100 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(username, password);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: "var(--bg)",
}}>
<form
onSubmit={handleSubmit}
style={{
width: 320,
display: "flex",
flexDirection: "column",
gap: 16,
padding: 32,
borderRadius: 8,
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
<h1 style={{ margin: 0, fontSize: 24, color: "var(--text)", textAlign: "center" }}>
MediaLore
</h1>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Username</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
autoComplete="username"
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
{error && (
<p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
style={{
background: "var(--accent)",
color: "#fff",
border: "none",
padding: "10px 0",
fontWeight: 600,
}}
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Library } from "../api/client";
import { api, type Library, type AuthUser } from "../api/client";
import { useAuth } from "../auth/useAuth";
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const qc = useQueryClient();
@@ -48,6 +49,91 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
);
}
function UserManagement() {
const qc = useQueryClient();
const { user: currentUser } = useAuth();
const { data: users = [] } = useQuery<AuthUser[]>({
queryKey: ["users"],
queryFn: api.auth.listUsers,
});
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [userError, setUserError] = useState("");
const createMutation = useMutation({
mutationFn: () => api.auth.createUser(newUsername, newPassword),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["users"] });
setNewUsername("");
setNewPassword("");
setUserError("");
},
onError: (e: Error) => setUserError(e.message),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.auth.deleteUser(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
onError: (e: Error) => setUserError(e.message),
});
return (
<div>
<h2 style={{ color: "var(--text)", marginTop: 32 }}>Users</h2>
<form
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(); }}
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
>
<input
placeholder="Username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
autoComplete="off"
/>
<input
type="password"
placeholder="Password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
/>
{userError && <p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{userError}</p>}
<button type="submit" disabled={createMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
{createMutation.isPending ? "Creating…" : "Add User"}
</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{users.map((u) => (
<li key={u.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
<div>
<strong style={{ color: "var(--text)" }}>{u.username}</strong>
{u.is_admin && (
<span style={{ marginLeft: 8, fontSize: 11, color: "var(--accent)", fontWeight: 600 }}>
ADMIN
</span>
)}
</div>
{u.id !== currentUser?.id && (
<button
onClick={() => deleteMutation.mutate(u.id)}
disabled={deleteMutation.isPending}
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
>
Remove
</button>
)}
</li>
))}
</ul>
</div>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { data: libraries = [] } = useQuery<Library[]>({
@@ -97,6 +183,8 @@ export default function SettingsPage() {
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
))}
</ul>
<UserManagement />
</div>
);
}