This commit is contained in:
2026-05-09 14:51:25 -04:00
parent 97fabc2c17
commit f23a8a2be6
20 changed files with 382 additions and 185 deletions

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim FROM docker.io/library/python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
@@ -13,4 +13,4 @@ COPY alembic.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
COPY app/ app/ COPY app/ app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

View File

@@ -1,14 +1,11 @@
import asyncio
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import pool from sqlalchemy import create_engine, pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# Import models so Alembic can detect them
from app.database import Base # noqa: F401 from app.database import Base # noqa: F401
import app.models # noqa: F401 import app.models # noqa: F401
@@ -22,24 +19,17 @@ def run_migrations_offline() -> None:
context.run_migrations() context.run_migrations()
def do_run_migrations(connection): def run_migrations_online() -> None:
# Alembic requires a sync engine; strip the async driver prefix
url = config.get_main_option("sqlalchemy.url").replace("+aiosqlite", "")
connectable = create_engine(url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata) context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:
asyncio.run(run_migrations_online()) run_migrations_online()

View File

@@ -1,3 +1,4 @@
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.config import settings from app.config import settings
@@ -6,6 +7,17 @@ engine = create_async_engine(settings.database_url, echo=False)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False) SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
# Enable WAL mode so concurrent reads don't block on an open write transaction.
# Also set a generous busy timeout so transient lock contention retries instead
# of immediately raising OperationalError.
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_conn, _record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=10000") # 10 s
cursor.close()
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass

View File

@@ -1,29 +1,52 @@
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from alembic.config import Config from fastapi.responses import JSONResponse
from alembic import command
from app.database import engine # uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
# ends up with no handler, so app.* records are silently discarded.
# Give the app namespace its own StreamHandler to guarantee output.
_app_logger = logging.getLogger("app")
_app_logger.setLevel(logging.INFO)
if not _app_logger.handlers:
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("%(levelname)-8s [%(name)s] %(message)s"))
_app_logger.addHandler(_h)
_app_logger.propagate = False
log = logging.getLogger(__name__)
from app.database import engine, Base
from app.routers import libraries, media, tags, search from app.routers import libraries, media, tags, search
from app.services import watcher as watcher_service from app.services import watcher as watcher_service
import app.models # noqa: F401 — registers models with Base.metadata
def run_migrations():
alembic_cfg = Config("/backend/alembic.ini")
command.upgrade(alembic_cfg, "head")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
run_migrations() log.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
log.info("Starting library watchers...")
await watcher_service.start_all() await watcher_service.start_all()
log.info("Startup complete.")
yield yield
await watcher_service.stop_all() await watcher_service.stop_all()
app = FastAPI(title="MediaLore", lifespan=lifespan) app = FastAPI(title="MediaLore", lifespan=lifespan)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
log.exception("Unhandled error on %s %s", request.method, request.url)
return JSONResponse(status_code=500, content={"detail": str(exc)})
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],

View File

@@ -1,6 +1,5 @@
import os
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -19,7 +18,11 @@ async def list_libraries(db: AsyncSession = Depends(get_db)):
@router.post("", response_model=LibraryOut, status_code=201) @router.post("", response_model=LibraryOut, status_code=201)
async def create_library(body: LibraryCreate, db: AsyncSession = Depends(get_db)): async def create_library(
body: LibraryCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
path = Path(body.path) path = Path(body.path)
if not path.is_dir(): if not path.is_dir():
raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}") raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}")
@@ -30,19 +33,18 @@ async def create_library(body: LibraryCreate, db: AsyncSession = Depends(get_db)
lib = Library(name=body.name, path=str(path)) lib = Library(name=body.name, path=str(path))
db.add(lib) db.add(lib)
await db.flush()
await db.refresh(lib)
lib_id = lib.id
lib_path = lib.path
await db.commit() await db.commit()
await db.refresh(lib)
await scanner.scan_library(lib_id, lib_path, db) # Scan runs in the background so the HTTP response returns immediately
watcher_service.start_watcher(lib_id, lib_path) background_tasks.add_task(scanner.scan_library_background, lib.id, lib.path)
watcher_service.start_watcher(lib.id, lib.path)
return lib
async with db.begin():
pass @router.get("/{library_id}/scan-status")
result = await db.execute(select(Library).where(Library.id == lib_id)) async def get_scan_status(library_id: int):
return result.scalars().first() return {"scanning": scanner.is_scanning(library_id)}
@router.delete("/{library_id}", status_code=204) @router.delete("/{library_id}", status_code=204)
@@ -72,8 +74,6 @@ async def browse_library(library_id: int, path: str = "", db: AsyncSession = Dep
if not target.is_dir(): if not target.is_dir():
raise HTTPException(404, "Directory not found") raise HTTPException(404, "Directory not found")
# Load all media items in this directory (non-recursive)
rel_prefix = path.strip("/")
items_result = await db.execute( items_result = await db.execute(
select(MediaItem).where( select(MediaItem).where(
MediaItem.library_id == library_id, MediaItem.library_id == library_id,
@@ -84,19 +84,32 @@ async def browse_library(library_id: int, path: str = "", db: AsyncSession = Dep
for item in items_result.scalars().all(): for item in items_result.scalars().all():
db_items[item.rel_path] = item db_items[item.rel_path] = item
from app.services.scanner import classify
entries: list[BrowseEntry] = [] entries: list[BrowseEntry] = []
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())): for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
rel_entry = str(entry.relative_to(root)) rel_entry = str(entry.relative_to(root))
if entry.is_dir(): if entry.is_dir():
entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry)) entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry))
elif entry.is_file() and rel_entry in db_items: elif entry.is_file():
item = db_items[rel_entry] db_item = db_items.get(rel_entry)
if db_item:
entries.append(BrowseEntry( entries.append(BrowseEntry(
name=entry.name, name=entry.name,
type=item.media_type, type=db_item.media_type,
rel_path=rel_entry, rel_path=rel_entry,
media_item_id=item.id, media_item_id=db_item.id,
))
else:
# File exists on disk but scan hasn't indexed it yet; show by extension
media_type = classify(entry)
if media_type:
entries.append(BrowseEntry(
name=entry.name,
type=media_type,
rel_path=rel_entry,
media_item_id=None,
)) ))
return BrowseResult(path=path, entries=entries) return BrowseResult(path=path, entries=entries)

View File

@@ -1,9 +1,22 @@
import asyncio
import hashlib import hashlib
import logging
import os
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models import MediaItem from app.models import MediaItem
from app.database import SessionLocal
log = logging.getLogger(__name__)
_scanning: set[int] = set()
def is_scanning(library_id: int) -> bool:
return library_id in _scanning
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif", ".heic"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif", ".heic"}
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"} VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"}
@@ -26,24 +39,50 @@ def hash_file(path: Path) -> str:
return h.hexdigest() return h.hexdigest()
async def scan_library_background(library_id: int, library_path: str) -> None:
"""Run a full library scan in a fresh session. Safe to call as a background task."""
_scanning.add(library_id)
try:
async with SessionLocal() as db:
await _do_scan(library_id, library_path, db)
except Exception:
log.exception("Scan failed for library %d at %s", library_id, library_path)
finally:
_scanning.discard(library_id)
async def scan_library(library_id: int, library_path: str, db: AsyncSession) -> None: async def scan_library(library_id: int, library_path: str, db: AsyncSession) -> None:
await _do_scan(library_id, library_path, db)
async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None:
root = Path(library_path) root = Path(library_path)
log.info("Starting scan for library %d at %s", library_id, library_path)
existing = await db.execute( existing = await db.execute(
select(MediaItem).where(MediaItem.library_id == library_id) select(MediaItem).where(MediaItem.library_id == library_id)
) )
db_items = {item.rel_path: item for item in existing.scalars().all()} db_items = {item.rel_path: item for item in existing.scalars().all()}
seen_paths: set[str] = set() seen_paths: set[str] = set()
loop = asyncio.get_running_loop()
total_dirs = 0
for file_path in root.rglob("*"): for dirpath, dirnames, filenames in os.walk(library_path):
if not file_path.is_file(): dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
continue dir = Path(dirpath)
rel_dir = str(dir.relative_to(root)) if dir != root else "."
found_in_dir = 0
for filename in sorted(f for f in filenames if not f.startswith(".")):
file_path = dir / filename
media_type = classify(file_path) media_type = classify(file_path)
if not media_type: if not media_type:
continue continue
rel = str(file_path.relative_to(root)) rel = str(file_path.relative_to(root))
seen_paths.add(rel) seen_paths.add(rel)
found_in_dir += 1
if rel in db_items: if rel in db_items:
item = db_items[rel] item = db_items[rel]
@@ -51,8 +90,7 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
item.missing = False item.missing = False
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()
else: else:
file_hash = hash_file(file_path) file_hash = await loop.run_in_executor(None, hash_file, file_path)
# Check if this hash matches an orphaned (missing) item — file was moved while offline
moved = await _find_by_hash(library_id, file_hash, db) moved = await _find_by_hash(library_id, file_hash, db)
if moved: if moved:
moved.rel_path = rel moved.rel_path = rel
@@ -60,7 +98,7 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
moved.missing = False moved.missing = False
moved.updated_at = datetime.utcnow() moved.updated_at = datetime.utcnow()
else: else:
item = MediaItem( db.add(MediaItem(
library_id=library_id, library_id=library_id,
rel_path=rel, rel_path=rel,
filename=file_path.name, filename=file_path.name,
@@ -68,16 +106,21 @@ async def scan_library(library_id: int, library_path: str, db: AsyncSession) ->
media_type=media_type, media_type=media_type,
size_bytes=file_path.stat().st_size, size_bytes=file_path.stat().st_size,
missing=False, missing=False,
) ))
db.add(item)
log.info("Scanned directory %s%d media file(s) found", rel_dir, found_in_dir)
total_dirs += 1
# Mark items no longer on disk as missing
for rel_path, item in db_items.items(): for rel_path, item in db_items.items():
if rel_path not in seen_paths and not item.missing: if rel_path not in seen_paths and not item.missing:
item.missing = True item.missing = True
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()
await db.commit() await db.commit()
log.info(
"Scan complete for library %d%d director%s, %d media file(s) indexed",
library_id, total_dirs, "y" if total_dirs == 1 else "ies", len(seen_paths),
)
async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> MediaItem | None: async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> MediaItem | None:

View File

@@ -111,12 +111,15 @@ class LibraryEventHandler(FileSystemEventHandler):
def start_watcher(library_id: int, library_path: str): def start_watcher(library_id: int, library_path: str):
if library_id in _observers: if library_id in _observers:
return return
try:
handler = LibraryEventHandler(library_id, library_path) handler = LibraryEventHandler(library_id, library_path)
observer = Observer() observer = Observer()
observer.schedule(handler, library_path, recursive=True) observer.schedule(handler, library_path, recursive=True)
observer.start() observer.start()
_observers[library_id] = observer _observers[library_id] = observer
log.info("Started watcher for library %d at %s", library_id, library_path) log.info("Started watcher for library %d at %s", library_id, library_path)
except Exception:
log.exception("Failed to start watcher for library %d at %s", library_id, library_path)
def stop_watcher(library_id: int): def stop_watcher(library_id: int):

View File

@@ -7,13 +7,14 @@ services:
- ${MEDIA_ROOT:-/media}:/media:ro - ${MEDIA_ROOT:-/media}:/media:ro
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db - DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- MEDIA_ROOT=/media
- THUMBNAIL_DIR=/data/thumbnails - THUMBNAIL_DIR=/data/thumbnails
env_file:
- .env
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "8080:80"
depends_on: depends_on:
- backend - backend

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS build FROM docker.io/library/node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@@ -6,7 +6,7 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine FROM docker.io/library/nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80

View File

@@ -4,7 +4,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>MediaLore</title>
<script>
// Apply saved theme before first paint to avoid flash
const t = localStorage.getItem("theme");
if (t === "dark" || (!t && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.setAttribute("data-theme", "dark");
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,13 +1,27 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom"; import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, Library } from "./api/client"; import { api, type Library } from "./api/client";
import BrowserPage from "./pages/BrowserPage"; import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage"; import SearchPage from "./pages/SearchPage";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
function Sidebar() { function useTheme() {
const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark"
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
localStorage.setItem("theme", dark ? "dark" : "light");
}, [dark]);
return { dark, toggle: () => setDark((d) => !d) };
}
function Sidebar({ onToggleTheme, dark }: { onToggleTheme: () => void; dark: boolean }) {
const { data: libraries = [] } = useQuery<Library[]>({ const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"], queryKey: ["libraries"],
queryFn: api.libraries.list, queryFn: api.libraries.list,
@@ -18,20 +32,31 @@ function Sidebar() {
padding: "6px 12px", padding: "6px 12px",
textDecoration: "none", textDecoration: "none",
borderRadius: 4, borderRadius: 4,
color: isActive ? "#3b82f6" : "inherit", color: isActive ? "var(--accent)" : "var(--text)",
background: isActive ? "#eff6ff" : "transparent", background: isActive ? "var(--accent-bg)" : "transparent",
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
}); });
return ( return (
<nav style={{ width: 220, borderRight: "1px solid #e5e7eb", padding: "1rem", display: "flex", flexDirection: "column", gap: 4 }}> <nav style={{
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12 }}>MediaLore</div> width: 220,
borderRight: "1px solid var(--border)",
background: "var(--bg)",
padding: "1rem",
display: "flex",
flexDirection: "column",
gap: 4,
flexShrink: 0,
}}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12, color: "var(--text)" }}>
MediaLore
</div>
<NavLink to="/search" style={linkStyle}>Search</NavLink> <NavLink to="/search" style={linkStyle}>Search</NavLink>
{libraries.length > 0 && ( {libraries.length > 0 && (
<> <>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#9ca3af", margin: "12px 0 4px", padding: "0 12px" }}> <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", margin: "12px 0 4px", padding: "0 12px" }}>
Libraries Libraries
</div> </div>
{libraries.map((lib) => ( {libraries.map((lib) => (
@@ -42,18 +67,27 @@ function Sidebar() {
</> </>
)} )}
<div style={{ marginTop: "auto" }}> <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 4 }}>
<NavLink to="/settings" style={linkStyle}>Settings</NavLink> <NavLink to="/settings" style={linkStyle}>Settings</NavLink>
<button
onClick={onToggleTheme}
title={dark ? "Switch to light mode" : "Switch to dark mode"}
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--text-secondary)", borderRadius: 4 }}
>
{dark ? "☀ Light mode" : "☾ Dark mode"}
</button>
</div> </div>
</nav> </nav>
); );
} }
function AppShell() { function AppShell() {
const { dark, toggle } = useTheme();
return ( return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}> <div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
<Sidebar /> <Sidebar onToggleTheme={toggle} dark={dark} />
<main style={{ flex: 1, overflow: "auto" }}> <main style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
<Routes> <Routes>
<Route path="/" element={<SearchPage />} /> <Route path="/" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />

View File

@@ -67,6 +67,8 @@ export const api = {
request<void>(`/libraries/${id}`, { method: "DELETE" }), request<void>(`/libraries/${id}`, { method: "DELETE" }),
browse: (id: number, path = "") => browse: (id: number, path = "") =>
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`), request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
scanStatus: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
}, },
media: { media: {

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, BrowseResult } from "../../api/client"; import { api, type BrowseResult } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer"; import MediaViewer from "../MediaViewer/MediaViewer";
interface Props { interface Props {
@@ -27,15 +27,15 @@ export default function FileBrowser({ libraryId }: Props) {
<div style={{ padding: "1rem" }}> <div style={{ padding: "1rem" }}>
{/* Breadcrumb */} {/* Breadcrumb */}
<nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}> <nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}> <button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600, color: "var(--text)", padding: "2px 4px" }}>
Root Root
</button> </button>
{pathParts.map((part, i) => { {pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/"); const partPath = pathParts.slice(0, i + 1).join("/");
return ( return (
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}> <span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span style={{ color: "#888" }}>/</span> <span style={{ color: "var(--text-muted)" }}>/</span>
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer" }}> <button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "2px 4px" }}>
{part} {part}
</button> </button>
</span> </span>
@@ -43,7 +43,7 @@ export default function FileBrowser({ libraryId }: Props) {
})} })}
</nav> </nav>
{isLoading && <p>Loading</p>} {isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>}
{/* Grid */} {/* Grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
@@ -55,14 +55,15 @@ export default function FileBrowser({ libraryId }: Props) {
else if (entry.media_item_id) setViewingId(entry.media_item_id); else if (entry.media_item_id) setViewingId(entry.media_item_id);
}} }}
style={{ style={{
cursor: "pointer", cursor: entry.type === "dir" || entry.media_item_id ? "pointer" : "default",
border: "1px solid #ddd", border: "1px solid var(--border)",
borderRadius: 6, borderRadius: 6,
overflow: "hidden", overflow: "hidden",
background: "#fafafa", background: "var(--bg-card)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
opacity: entry.type !== "dir" && !entry.media_item_id ? 0.5 : 1,
}} }}
> >
{entry.type === "dir" ? ( {entry.type === "dir" ? (
@@ -76,7 +77,7 @@ export default function FileBrowser({ libraryId }: Props) {
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/> />
)} )}
<div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
{entry.name} {entry.name}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, BrowseEntry, MediaItem } from "../../api/client"; import { api, type BrowseEntry, type MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel"; import TagPanel from "../TagPanel/TagPanel";
interface Props { interface Props {

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Tag, TagsByCategory, MediaItem } from "../../api/client"; import { api, type Tag, type TagsByCategory, type MediaItem } from "../../api/client";
interface Props { interface Props {
item: MediaItem; item: MediaItem;
@@ -13,7 +13,6 @@ export default function TagPanel({ item }: Props) {
queryFn: api.tags.list, queryFn: api.tags.list,
}); });
const allTags = grouped.flatMap((g) => g.tags);
const assignedIds = new Set(item.tags.map((t) => t.id)); const assignedIds = new Set(item.tags.map((t) => t.id));
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -28,8 +27,7 @@ export default function TagPanel({ item }: Props) {
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()), mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
onSuccess: (tag: Tag) => { onSuccess: (tag: Tag) => {
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["tags"] });
const newIds = [...assignedIds, tag.id]; setTagsMutation.mutate([...assignedIds, tag.id]);
setTagsMutation.mutate(newIds);
setNewName(""); setNewName("");
setNewCategory(""); setNewCategory("");
}, },
@@ -44,11 +42,11 @@ export default function TagPanel({ item }: Props) {
return ( return (
<div style={{ minWidth: 220 }}> <div style={{ minWidth: 220 }}>
<h3 style={{ margin: "0 0 12px" }}>Tags</h3> <h3 style={{ margin: "0 0 12px", color: "var(--text)" }}>Tags</h3>
{grouped.map((group) => ( {grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 12 }}> <div key={group.category} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginBottom: 4 }}> <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginBottom: 4 }}>
{group.category} {group.category}
</div> </div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
@@ -61,9 +59,9 @@ export default function TagPanel({ item }: Props) {
borderRadius: 12, borderRadius: 12,
border: "1px solid", border: "1px solid",
cursor: "pointer", cursor: "pointer",
background: assignedIds.has(tag.id) ? "#3b82f6" : "transparent", background: assignedIds.has(tag.id) ? "var(--accent)" : "transparent",
color: assignedIds.has(tag.id) ? "#fff" : "inherit", color: assignedIds.has(tag.id) ? "#fff" : "var(--text)",
borderColor: assignedIds.has(tag.id) ? "#3b82f6" : "#ccc", borderColor: assignedIds.has(tag.id) ? "var(--accent)" : "var(--border)",
fontSize: 13, fontSize: 13,
}} }}
> >
@@ -75,19 +73,17 @@ export default function TagPanel({ item }: Props) {
))} ))}
<details style={{ marginTop: 16 }}> <details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13, color: "#3b82f6" }}>+ New tag</summary> <summary style={{ cursor: "pointer", fontSize: 13, color: "var(--accent-text)" }}>+ New tag</summary>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
<input <input
placeholder="Tag name" placeholder="Tag name"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
style={{ padding: "4px 8px" }}
/> />
<input <input
placeholder="Category" placeholder="Category"
value={newCategory} value={newCategory}
onChange={(e) => setNewCategory(e.target.value)} onChange={(e) => setNewCategory(e.target.value)}
style={{ padding: "4px 8px" }}
/> />
<button <button
onClick={() => createTagMutation.mutate()} onClick={() => createTagMutation.mutate()}

66
frontend/src/index.css Normal file
View File

@@ -0,0 +1,66 @@
:root {
--bg: #ffffff;
--bg-secondary: #fafafa;
--bg-card: #fafafa;
--border: #e5e7eb;
--border-subtle: #eeeeee;
--text: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-bg: #eff6ff;
--accent-text: #3b82f6;
--tag-bg: #dbeafe;
--danger: #ef4444;
}
[data-theme="dark"] {
--bg: #111827;
--bg-secondary: #1f2937;
--bg-card: #1f2937;
--border: #374151;
--border-subtle: #374151;
--text: #f9fafb;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--accent: #60a5fa;
--accent-bg: #1e3a5f;
--accent-text: #60a5fa;
--tag-bg: #1e3a5f;
--danger: #f87171;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
}
input, textarea, select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 10px;
font: inherit;
outline-color: var(--accent);
width: 100%;
}
button {
font: inherit;
cursor: pointer;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text);
padding: 6px 12px;
}
button:disabled {
opacity: 0.4;
cursor: default;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, Library } from "../api/client"; import { api, type Library } from "../api/client";
import FileBrowser from "../components/FileBrowser/FileBrowser"; import FileBrowser from "../components/FileBrowser/FileBrowser";
export default function BrowserPage() { export default function BrowserPage() {
@@ -14,13 +14,13 @@ export default function BrowserPage() {
const library = libraries.find((l) => l.id === id); const library = libraries.find((l) => l.id === id);
if (!library) return <p style={{ padding: "2rem" }}>Library not found.</p>; if (!library) return <p style={{ padding: "2rem", color: "var(--text)" }}>Library not found.</p>;
return ( return (
<div> <div>
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid #eee" }}> <div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid var(--border-subtle)" }}>
<h2 style={{ margin: 0 }}>{library.name}</h2> <h2 style={{ margin: 0, color: "var(--text)" }}>{library.name}</h2>
<div style={{ fontSize: 12, color: "#888" }}>{library.path}</div> <div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{library.path}</div>
</div> </div>
<FileBrowser libraryId={id} /> <FileBrowser libraryId={id} />
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, MediaItem, TagsByCategory } from "../api/client"; import { api, type MediaItem, type TagsByCategory } from "../api/client";
export default function SearchPage() { export default function SearchPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
@@ -31,24 +31,23 @@ export default function SearchPage() {
return ( return (
<div style={{ padding: "2rem", maxWidth: 900 }}> <div style={{ padding: "2rem", maxWidth: 900 }}>
<h1>Search</h1> <h1 style={{ color: "var(--text)" }}>Search</h1>
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}> <form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
<input <input
placeholder="Search by filename…" placeholder="Search by filename…"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
style={{ flex: 1, minWidth: 200, padding: "6px 10px" }} style={{ flex: 1, minWidth: 200 }}
/> />
<button type="submit">Search</button> <button type="submit" style={{ background: "var(--accent)", color: "#fff", border: "none" }}>Search</button>
</form> </form>
{/* Tag filter */}
{grouped.length > 0 && ( {grouped.length > 0 && (
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, color: "#666", marginBottom: 8 }}>Filter by tag:</div> <div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 8 }}>Filter by tag:</div>
{grouped.map((group) => ( {grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 8 }}> <div key={group.category} style={{ marginBottom: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginRight: 8 }}> <span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginRight: 8 }}>
{group.category} {group.category}
</span> </span>
{group.tags.map((tag) => ( {group.tags.map((tag) => (
@@ -61,9 +60,9 @@ export default function SearchPage() {
borderRadius: 12, borderRadius: 12,
border: "1px solid", border: "1px solid",
cursor: "pointer", cursor: "pointer",
background: selectedTags.includes(tag.id) ? "#3b82f6" : "transparent", background: selectedTags.includes(tag.id) ? "var(--accent)" : "transparent",
color: selectedTags.includes(tag.id) ? "#fff" : "inherit", color: selectedTags.includes(tag.id) ? "#fff" : "var(--text)",
borderColor: selectedTags.includes(tag.id) ? "#3b82f6" : "#ccc", borderColor: selectedTags.includes(tag.id) ? "var(--accent)" : "var(--border)",
fontSize: 13, fontSize: 13,
}} }}
> >
@@ -75,24 +74,24 @@ export default function SearchPage() {
</div> </div>
)} )}
{isFetching && <p>Searching</p>} {isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{results.map((item) => ( {results.map((item) => (
<div key={item.id} style={{ border: "1px solid #ddd", borderRadius: 6, overflow: "hidden" }}> <div key={item.id} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)" }}>
<img <img
src={api.media.thumbnailUrl(item.id)} src={api.media.thumbnailUrl(item.id)}
alt={item.filename} alt={item.filename}
loading="lazy" loading="lazy"
style={{ width: "100%", height: 110, objectFit: "cover" }} style={{ width: "100%", height: 110, objectFit: "cover" }}
/> />
<div style={{ padding: "4px 6px", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <div style={{ padding: "4px 6px", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
{item.filename} {item.filename}
</div> </div>
{item.tags.length > 0 && ( {item.tags.length > 0 && (
<div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}> <div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}>
{item.tags.map((t) => ( {item.tags.map((t) => (
<span key={t.id} style={{ background: "#e0edff", color: "#3b82f6", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}> <span key={t.id} style={{ background: "var(--tag-bg)", color: "var(--accent-text)", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}>
{t.name} {t.name}
</span> </span>
))} ))}
@@ -103,7 +102,7 @@ export default function SearchPage() {
</div> </div>
{submitted && !isFetching && results.length === 0 && ( {submitted && !isFetching && results.length === 0 && (
<p style={{ color: "#888" }}>No results found.</p> <p style={{ color: "var(--text-secondary)" }}>No results found.</p>
)} )}
</div> </div>
); );

View File

@@ -1,6 +1,37 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Library } from "../api/client"; import { api, type Library } from "../api/client";
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const { data } = useQuery({
queryKey: ["scan-status", lib.id],
queryFn: () => api.libraries.scanStatus(lib.id),
refetchInterval: (query) => (query.state.data?.scanning ? 2000 : false),
});
const scanning = data?.scanning ?? false;
return (
<li style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
<div>
<strong style={{ color: "var(--text)" }}>{lib.name}</strong>
{scanning && (
<span style={{ marginLeft: 8, fontSize: 12, color: "var(--accent)" }}>
Scanning
</span>
)}
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>{lib.path}</div>
</div>
<button
onClick={() => onRemove(lib.id)}
disabled={scanning}
style={{ color: scanning ? "var(--text-muted)" : "var(--danger)", background: "transparent", border: "none" }}
>
Remove
</button>
</li>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -31,49 +62,24 @@ export default function SettingsPage() {
return ( return (
<div style={{ padding: "2rem", maxWidth: 600 }}> <div style={{ padding: "2rem", maxWidth: 600 }}>
<h1>Settings</h1> <h1 style={{ color: "var(--text)" }}>Settings</h1>
<h2>Libraries</h2> <h2 style={{ color: "var(--text)" }}>Libraries</h2>
<form <form
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }} onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }} style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
> >
<input <input placeholder="Library name" value={name} onChange={(e) => setName(e.target.value)} required />
placeholder="Library name" <input placeholder="/path/to/directory" value={path} onChange={(e) => setPath(e.target.value)} required />
value={name} {error && <p style={{ color: "var(--danger)", margin: 0 }}>{error}</p>}
onChange={(e) => setName(e.target.value)} <button type="submit" disabled={addMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
required
/>
<input
placeholder="/path/to/directory"
value={path}
onChange={(e) => setPath(e.target.value)}
required
/>
{error && <p style={{ color: "red", margin: 0 }}>{error}</p>}
<button type="submit" disabled={addMutation.isPending}>
{addMutation.isPending ? "Adding…" : "Add Library"} {addMutation.isPending ? "Adding…" : "Add Library"}
</button> </button>
</form> </form>
<ul style={{ listStyle: "none", padding: 0 }}> <ul style={{ listStyle: "none", padding: 0 }}>
{libraries.map((lib) => ( {libraries.map((lib) => (
<li <LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
key={lib.id}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #eee" }}
>
<div>
<strong>{lib.name}</strong>
<div style={{ fontSize: 12, color: "#666" }}>{lib.path}</div>
</div>
<button
onClick={() => deleteMutation.mutate(lib.id)}
disabled={deleteMutation.isPending}
style={{ color: "red" }}
>
Remove
</button>
</li>
))} ))}
</ul> </ul>
</div> </div>