initial commit

This commit is contained in:
2026-05-09 12:34:45 -04:00
commit 97fabc2c17
49 changed files with 4856 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Set this to the top-level directory on your host that contains your media.
# Library paths you configure in the app must be subdirectories of this path.
# Inside the container, this maps to /media.
MEDIA_ROOT=/mnt/nas

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.pyc
.venv/

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /backend
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

37
backend/alembic.ini Normal file
View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite+aiosqlite:////data/medialore.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

45
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,45 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import models so Alembic can detect them
from app.database import Base # noqa: F401
import app.models # noqa: F401
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
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():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,56 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-05-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"libraries",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("path", sa.String(), nullable=False, unique=True),
)
op.create_table(
"media_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("library_id", sa.Integer(), sa.ForeignKey("libraries.id", ondelete="CASCADE"), nullable=False),
sa.Column("rel_path", sa.String(), nullable=False),
sa.Column("filename", sa.String(), nullable=False),
sa.Column("file_hash", sa.String()),
sa.Column("media_type", sa.String(), nullable=False),
sa.Column("size_bytes", sa.Integer()),
sa.Column("missing", sa.Boolean(), default=False),
sa.Column("created_at", sa.DateTime(), default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), default=sa.func.now()),
sa.UniqueConstraint("library_id", "rel_path"),
)
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("category", sa.String(), nullable=False),
sa.UniqueConstraint("name", "category"),
)
op.create_table(
"media_item_tags",
sa.Column("media_item_id", sa.Integer(), sa.ForeignKey("media_items.id", ondelete="CASCADE"), primary_key=True),
sa.Column("tag_id", sa.Integer(), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
def downgrade() -> None:
op.drop_table("media_item_tags")
op.drop_table("tags")
op.drop_table("media_items")
op.drop_table("libraries")

0
backend/app/__init__.py Normal file
View File

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:////data/medialore.db"
media_root: str = "/media"
thumbnail_dir: str = "/data/thumbnails"
model_config = {"env_file": ".env"}
settings = Settings()
THUMBNAIL_DIR = Path(settings.thumbnail_dir)
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)

15
backend/app/database.py Normal file
View File

@@ -0,0 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with SessionLocal() as session:
yield session

37
backend/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from alembic.config import Config
from alembic import command
from app.database import engine
from app.routers import libraries, media, tags, search
from app.services import watcher as watcher_service
def run_migrations():
alembic_cfg = Config("/backend/alembic.ini")
command.upgrade(alembic_cfg, "head")
@asynccontextmanager
async def lifespan(app: FastAPI):
run_migrations()
await watcher_service.start_all()
yield
await watcher_service.stop_all()
app = FastAPI(title="MediaLore", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(libraries.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
app.include_router(search.router, prefix="/api")

56
backend/app/models.py Normal file
View File

@@ -0,0 +1,56 @@
from datetime import datetime
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Integer, String, Table, Column, UniqueConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
media_item_tags = Table(
"media_item_tags",
Base.metadata,
Column("media_item_id", Integer, ForeignKey("media_items.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
class Library(Base):
__tablename__ = "libraries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
path: Mapped[str] = mapped_column(String, nullable=False, unique=True)
items: Mapped[list["MediaItem"]] = relationship(
"MediaItem", back_populates="library", cascade="all, delete-orphan"
)
class MediaItem(Base):
__tablename__ = "media_items"
__table_args__ = (UniqueConstraint("library_id", "rel_path"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
library_id: Mapped[int] = mapped_column(Integer, ForeignKey("libraries.id", ondelete="CASCADE"), nullable=False)
rel_path: Mapped[str] = mapped_column(String, nullable=False)
filename: Mapped[str] = mapped_column(String, nullable=False)
file_hash: Mapped[str | None] = mapped_column(String)
media_type: Mapped[str] = mapped_column(String, nullable=False) # 'image' | 'video'
size_bytes: Mapped[int | None] = mapped_column(Integer)
missing: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
library: Mapped["Library"] = relationship("Library", back_populates="items")
tags: Mapped[list["Tag"]] = relationship("Tag", secondary=media_item_tags, back_populates="items")
class Tag(Base):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("name", "category"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
category: Mapped[str] = mapped_column(String, nullable=False)
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags")

View File

View File

@@ -0,0 +1,102 @@
import os
from pathlib import Path
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 Library, MediaItem
from app.schemas import LibraryCreate, LibraryOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service
router = APIRouter(prefix="/libraries", tags=["libraries"])
@router.get("", response_model=list[LibraryOut])
async def list_libraries(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Library))
return result.scalars().all()
@router.post("", response_model=LibraryOut, status_code=201)
async def create_library(body: LibraryCreate, db: AsyncSession = Depends(get_db)):
path = Path(body.path)
if not path.is_dir():
raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}")
existing = await db.execute(select(Library).where(Library.path == str(path)))
if existing.scalars().first():
raise HTTPException(409, "A library with this path already exists")
lib = Library(name=body.name, path=str(path))
db.add(lib)
await db.flush()
await db.refresh(lib)
lib_id = lib.id
lib_path = lib.path
await db.commit()
await scanner.scan_library(lib_id, lib_path, db)
watcher_service.start_watcher(lib_id, lib_path)
async with db.begin():
pass
result = await db.execute(select(Library).where(Library.id == lib_id))
return result.scalars().first()
@router.delete("/{library_id}", status_code=204)
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:
raise HTTPException(404, "Library not found")
watcher_service.stop_watcher(library_id)
await db.delete(lib)
await db.commit()
@router.get("/{library_id}/browse", response_model=BrowseResult)
async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:
raise HTTPException(404, "Library not found")
root = Path(lib.path)
target = (root / path).resolve()
# Path traversal guard
if not str(target).startswith(str(root)):
raise HTTPException(400, "Invalid path")
if not target.is_dir():
raise HTTPException(404, "Directory not found")
# Load all media items in this directory (non-recursive)
rel_prefix = path.strip("/")
items_result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == library_id,
MediaItem.missing == False, # noqa: E712
)
)
db_items: dict[str, MediaItem] = {}
for item in items_result.scalars().all():
db_items[item.rel_path] = item
entries: list[BrowseEntry] = []
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
rel_entry = str(entry.relative_to(root))
if entry.is_dir():
entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry))
elif entry.is_file() and rel_entry in db_items:
item = db_items[rel_entry]
entries.append(BrowseEntry(
name=entry.name,
type=item.media_type,
rel_path=rel_entry,
media_item_id=item.id,
))
return BrowseResult(path=path, entries=entries)

View File

@@ -0,0 +1,85 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
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.schemas import MediaItemOut, TagIdList
from app.services.thumbnails import get_or_create_thumbnail
router = APIRouter(prefix="/media", tags=["media"])
async def _get_item_and_lib(media_id: int, db: AsyncSession) -> tuple[MediaItem, Library]:
result = await db.execute(
select(MediaItem).where(MediaItem.id == media_id)
)
item = result.scalars().first()
if not item:
raise HTTPException(404, "Media item not found")
lib_result = await db.execute(select(Library).where(Library.id == item.library_id))
lib = lib_result.scalars().first()
return item, lib
def _resolve_safe(lib: Library, item: MediaItem) -> Path:
root = Path(lib.path)
abs_path = (root / item.rel_path).resolve()
if not str(abs_path).startswith(str(root.resolve())):
raise HTTPException(400, "Invalid path")
return abs_path
@router.get("/{media_id}", response_model=MediaItemOut)
async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(MediaItem).where(MediaItem.id == media_id)
)
item = result.scalars().first()
if not item:
raise HTTPException(404, "Media item not found")
# Eagerly load tags
await db.refresh(item, ["tags"])
return item
@router.get("/{media_id}/file")
async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
item, lib = await _get_item_and_lib(media_id, db)
if item.missing:
raise HTTPException(404, "File is missing from disk")
abs_path = _resolve_safe(lib, item)
if not abs_path.exists():
raise HTTPException(404, "File not found on disk")
return FileResponse(str(abs_path))
@router.get("/{media_id}/thumbnail")
async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
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)
if not thumb:
raise HTTPException(404, "Thumbnail could not be generated")
return FileResponse(str(thumb), media_type="image/jpeg")
@router.put("/{media_id}/tags", response_model=MediaItemOut)
async def set_tags(media_id: int, body: TagIdList, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
item = result.scalars().first()
if not item:
raise HTTPException(404, "Media item not found")
tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
tags = tags_result.scalars().all()
if len(tags) != len(body.tag_ids):
raise HTTPException(400, "One or more tag IDs not found")
await db.refresh(item, ["tags"])
item.tags = list(tags)
await db.commit()
await db.refresh(item, ["tags"])
return item

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
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.schemas import MediaItemOut
router = APIRouter(prefix="/search", tags=["search"])
@router.get("", response_model=list[MediaItemOut])
async def search(
q: str = Query(default=""),
tags: str = Query(default=""),
library_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(MediaItem)
.options(selectinload(MediaItem.tags))
.where(MediaItem.missing == False) # noqa: E712
)
if q:
stmt = stmt.where(MediaItem.filename.ilike(f"%{q}%"))
if library_id is not None:
stmt = stmt.where(MediaItem.library_id == library_id)
if tags:
tag_ids = [int(t.strip()) for t in tags.split(",") if t.strip().isdigit()]
for tag_id in tag_ids:
stmt = stmt.where(
MediaItem.id.in_(
select(media_item_tags.c.media_item_id).where(
media_item_tags.c.tag_id == tag_id
)
)
)
result = await db.execute(stmt.order_by(MediaItem.filename).limit(200))
return result.scalars().all()

View File

@@ -0,0 +1,45 @@
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 Tag
from app.schemas import TagCreate, TagOut, TagsByCategory
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("", response_model=list[TagsByCategory])
async def list_tags(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
tags = result.scalars().all()
grouped: dict[str, list[TagOut]] = {}
for tag in tags:
grouped.setdefault(tag.category, []).append(TagOut.model_validate(tag))
return [TagsByCategory(category=cat, tags=items) for cat, items in grouped.items()]
@router.post("", response_model=TagOut, status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
existing = await db.execute(
select(Tag).where(Tag.name == body.name, Tag.category == body.category)
)
if existing.scalars().first():
raise HTTPException(409, "Tag already exists")
tag = Tag(name=body.name, category=body.category)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalars().first()
if not tag:
raise HTTPException(404, "Tag not found")
await db.delete(tag)
await db.commit()

88
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,88 @@
from datetime import datetime
from pydantic import BaseModel
# --- Tag ---
class TagBase(BaseModel):
name: str
category: str
class TagCreate(TagBase):
pass
class TagOut(TagBase):
id: int
model_config = {"from_attributes": True}
class TagsByCategory(BaseModel):
category: str
tags: list[TagOut]
# --- Library ---
class LibraryCreate(BaseModel):
name: str
path: str
class LibraryOut(BaseModel):
id: int
name: str
path: str
model_config = {"from_attributes": True}
# --- MediaItem ---
class MediaItemOut(BaseModel):
id: int
library_id: int
rel_path: str
filename: str
media_type: str
size_bytes: int | None
missing: bool
tags: list[TagOut]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MediaItemSummary(BaseModel):
id: int
filename: str
rel_path: str
media_type: str
missing: bool
model_config = {"from_attributes": True}
# --- Browse ---
class BrowseEntry(BaseModel):
name: str
type: str # 'dir' | 'image' | 'video'
rel_path: str
media_item_id: int | None = None
class BrowseResult(BaseModel):
path: str
entries: list[BrowseEntry]
# --- Search ---
class SearchResult(BaseModel):
items: list[MediaItemOut]
# --- Tag assignment ---
class TagIdList(BaseModel):
tag_ids: list[int]

View File

View File

@@ -0,0 +1,91 @@
import hashlib
from pathlib import Path
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import MediaItem
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif", ".heic"}
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".flv", ".wmv", ".ts"}
def classify(path: Path) -> str | None:
ext = path.suffix.lower()
if ext in IMAGE_EXTENSIONS:
return "image"
if ext in VIDEO_EXTENSIONS:
return "video"
return None
def hash_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
async def scan_library(library_id: int, library_path: str, db: AsyncSession) -> None:
root = Path(library_path)
existing = await db.execute(
select(MediaItem).where(MediaItem.library_id == library_id)
)
db_items = {item.rel_path: item for item in existing.scalars().all()}
seen_paths: set[str] = set()
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
media_type = classify(file_path)
if not media_type:
continue
rel = str(file_path.relative_to(root))
seen_paths.add(rel)
if rel in db_items:
item = db_items[rel]
if item.missing:
item.missing = False
item.updated_at = datetime.utcnow()
else:
file_hash = 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)
if moved:
moved.rel_path = rel
moved.filename = file_path.name
moved.missing = False
moved.updated_at = datetime.utcnow()
else:
item = MediaItem(
library_id=library_id,
rel_path=rel,
filename=file_path.name,
file_hash=file_hash,
media_type=media_type,
size_bytes=file_path.stat().st_size,
missing=False,
)
db.add(item)
# Mark items no longer on disk as missing
for rel_path, item in db_items.items():
if rel_path not in seen_paths and not item.missing:
item.missing = True
item.updated_at = datetime.utcnow()
await db.commit()
async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> MediaItem | None:
result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == library_id,
MediaItem.file_hash == file_hash,
MediaItem.missing == True, # noqa: E712
)
)
return result.scalars().first()

View File

@@ -0,0 +1,53 @@
import subprocess
from pathlib import Path
from PIL import Image
from app.config import THUMBNAIL_DIR
THUMB_SIZE = (400, 400)
def thumbnail_path(media_id: int) -> Path:
return THUMBNAIL_DIR / f"{media_id}.jpg"
def generate_image_thumbnail(src: Path, dest: Path) -> None:
with Image.open(src) as img:
img.thumbnail(THUMB_SIZE)
img = img.convert("RGB")
img.save(dest, "JPEG", quality=85)
def generate_video_thumbnail(src: Path, dest: Path) -> None:
# Extract frame at 10% of duration
subprocess.run(
[
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(src),
],
capture_output=True, text=True, check=False,
)
subprocess.run(
[
"ffmpeg", "-y", "-ss", "00:00:05", "-i", str(src),
"-vframes", "1", "-vf", f"scale={THUMB_SIZE[0]}:-1",
str(dest),
],
capture_output=True, check=False,
)
def get_or_create_thumbnail(media_id: int, abs_path: str, media_type: str) -> Path | None:
dest = thumbnail_path(media_id)
if dest.exists():
return dest
src = Path(abs_path)
if not src.exists():
return None
try:
if media_type == "image":
generate_image_thumbnail(src, dest)
else:
generate_video_thumbnail(src, dest)
return dest if dest.exists() else None
except Exception:
return None

View File

@@ -0,0 +1,140 @@
import asyncio
import logging
from pathlib import Path
from datetime import datetime
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileMovedEvent, FileCreatedEvent, FileDeletedEvent, DirMovedEvent
from sqlalchemy import select
from app.database import SessionLocal
from app.models import Library, MediaItem
from app.services.scanner import classify, hash_file
log = logging.getLogger(__name__)
_observers: dict[int, Observer] = {}
class LibraryEventHandler(FileSystemEventHandler):
def __init__(self, library_id: int, library_path: str):
self.library_id = library_id
self.root = Path(library_path)
def _rel(self, abs_path: str) -> str:
return str(Path(abs_path).relative_to(self.root))
def on_moved(self, event):
asyncio.run(self._handle_move(event))
def on_created(self, event):
if not event.is_directory:
asyncio.run(self._handle_create(event.src_path))
def on_deleted(self, event):
if not event.is_directory:
asyncio.run(self._handle_delete(event.src_path))
async def _handle_move(self, event):
async with SessionLocal() as db:
if isinstance(event, DirMovedEvent):
old_prefix = self._rel(event.src_path)
new_prefix = self._rel(event.dest_path)
result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == self.library_id,
MediaItem.rel_path.startswith(old_prefix + "/"),
)
)
for item in result.scalars().all():
item.rel_path = new_prefix + item.rel_path[len(old_prefix):]
item.updated_at = datetime.utcnow()
else:
src_rel = self._rel(event.src_path)
dest_rel = self._rel(event.dest_path)
result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == self.library_id,
MediaItem.rel_path == src_rel,
)
)
item = result.scalars().first()
if item:
item.rel_path = dest_rel
item.filename = Path(event.dest_path).name
item.missing = False
item.updated_at = datetime.utcnow()
await db.commit()
async def _handle_create(self, abs_path: str):
path = Path(abs_path)
media_type = classify(path)
if not media_type:
return
rel = self._rel(abs_path)
async with SessionLocal() as db:
existing = await db.execute(
select(MediaItem).where(
MediaItem.library_id == self.library_id,
MediaItem.rel_path == rel,
)
)
if existing.scalars().first():
return
file_hash = hash_file(path)
item = MediaItem(
library_id=self.library_id,
rel_path=rel,
filename=path.name,
file_hash=file_hash,
media_type=media_type,
size_bytes=path.stat().st_size,
)
db.add(item)
await db.commit()
async def _handle_delete(self, abs_path: str):
rel = self._rel(abs_path)
async with SessionLocal() as db:
result = await db.execute(
select(MediaItem).where(
MediaItem.library_id == self.library_id,
MediaItem.rel_path == rel,
)
)
item = result.scalars().first()
if item:
item.missing = True
item.updated_at = datetime.utcnow()
await db.commit()
def start_watcher(library_id: int, library_path: str):
if library_id in _observers:
return
handler = LibraryEventHandler(library_id, library_path)
observer = Observer()
observer.schedule(handler, library_path, recursive=True)
observer.start()
_observers[library_id] = observer
log.info("Started watcher for library %d at %s", library_id, library_path)
def stop_watcher(library_id: int):
observer = _observers.pop(library_id, None)
if observer:
observer.stop()
observer.join()
log.info("Stopped watcher for library %d", library_id)
async def start_all():
async with SessionLocal() as db:
result = await db.execute(select(Library))
libraries = result.scalars().all()
for lib in libraries:
start_watcher(lib.id, lib.path)
async def stop_all():
for library_id in list(_observers.keys()):
stop_watcher(library_id)

22
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "medialore"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy[asyncio]>=2.0",
"aiosqlite>=0.20",
"alembic>=1.13",
"pydantic-settings>=2.0",
"watchdog>=4.0",
"Pillow>=10.0",
"python-multipart>=0.0.9",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
backend:
build: ./backend
restart: unless-stopped
volumes:
- ./data:/data
- ${MEDIA_ROOT:-/media}:/media:ro
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- MEDIA_ROOT=/media
- THUMBNAIL_DIR=/data/thumbnails
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/Dockerfile Normal file
View File

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

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Allow large file uploads
client_max_body_size 0;
# Disable buffering for video streaming
proxy_buffering off;
}
location / {
try_files $uri $uri/ /index.html;
}
}

2857
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.100.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

76
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, Library } from "./api/client";
import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage";
const queryClient = new QueryClient();
function Sidebar() {
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
});
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
display: "block",
padding: "6px 12px",
textDecoration: "none",
borderRadius: 4,
color: isActive ? "#3b82f6" : "inherit",
background: isActive ? "#eff6ff" : "transparent",
fontWeight: isActive ? 600 : 400,
});
return (
<nav style={{ width: 220, borderRight: "1px solid #e5e7eb", padding: "1rem", display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12 }}>MediaLore</div>
<NavLink to="/search" style={linkStyle}>Search</NavLink>
{libraries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#9ca3af", margin: "12px 0 4px", padding: "0 12px" }}>
Libraries
</div>
{libraries.map((lib) => (
<NavLink key={lib.id} to={`/library/${lib.id}`} style={linkStyle}>
{lib.name}
</NavLink>
))}
</>
)}
<div style={{ marginTop: "auto" }}>
<NavLink to="/settings" style={linkStyle}>Settings</NavLink>
</div>
</nav>
);
}
function AppShell() {
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
<Sidebar />
<main style={{ flex: 1, overflow: "auto" }}>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/library/:libraryId" element={<BrowserPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppShell />
</BrowserRouter>
</QueryClientProvider>
);
}

101
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,101 @@
const BASE = "/api";
export interface Library {
id: number;
name: string;
path: string;
}
export interface Tag {
id: number;
name: string;
category: string;
}
export interface TagsByCategory {
category: string;
tags: Tag[];
}
export interface MediaItem {
id: number;
library_id: number;
rel_path: string;
filename: string;
media_type: "image" | "video";
size_bytes: number | null;
missing: boolean;
tags: Tag[];
created_at: string;
updated_at: string;
}
export interface BrowseEntry {
name: string;
type: "dir" | "image" | "video";
rel_path: string;
media_item_id: number | null;
}
export interface BrowseResult {
path: string;
entries: BrowseEntry[];
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
libraries: {
list: () => request<Library[]>("/libraries"),
create: (name: string, path: string) =>
request<Library>("/libraries", {
method: "POST",
body: JSON.stringify({ name, path }),
}),
delete: (id: number) =>
request<void>(`/libraries/${id}`, { method: "DELETE" }),
browse: (id: number, path = "") =>
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
},
media: {
get: (id: number) => request<MediaItem>(`/media/${id}`),
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
setTags: (id: number, tagIds: number[]) =>
request<MediaItem>(`/media/${id}/tags`, {
method: "PUT",
body: JSON.stringify({ tag_ids: tagIds }),
}),
},
tags: {
list: () => request<TagsByCategory[]>("/tags"),
create: (name: string, category: string) =>
request<Tag>("/tags", {
method: "POST",
body: JSON.stringify({ name, category }),
}),
delete: (id: number) =>
request<void>(`/tags/${id}`, { method: "DELETE" }),
},
search: (params: { q?: string; tags?: number[]; library_id?: number }) => {
const p = new URLSearchParams();
if (params.q) p.set("q", params.q);
if (params.tags?.length) p.set("tags", params.tags.join(","));
if (params.library_id != null) p.set("library_id", String(params.library_id));
return request<MediaItem[]>(`/search?${p}`);
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseResult } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer";
interface Props {
libraryId: number;
}
export default function FileBrowser({ libraryId }: Props) {
const [currentPath, setCurrentPath] = useState("");
const [viewingId, setViewingId] = useState<number | null>(null);
const { data, isLoading } = useQuery<BrowseResult>({
queryKey: ["browse", libraryId, currentPath],
queryFn: () => api.libraries.browse(libraryId, currentPath),
});
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
function navigate(relPath: string) {
setCurrentPath(relPath);
setViewingId(null);
}
return (
<div style={{ padding: "1rem" }}>
{/* Breadcrumb */}
<nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600 }}>
Root
</button>
{pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/");
return (
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span style={{ color: "#888" }}>/</span>
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer" }}>
{part}
</button>
</span>
);
})}
</nav>
{isLoading && <p>Loading</p>}
{/* Grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{data?.entries.map((entry) => (
<div
key={entry.rel_path}
onClick={() => {
if (entry.type === "dir") navigate(entry.rel_path);
else if (entry.media_item_id) setViewingId(entry.media_item_id);
}}
style={{
cursor: "pointer",
border: "1px solid #ddd",
borderRadius: 6,
overflow: "hidden",
background: "#fafafa",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{entry.type === "dir" ? (
<div style={{ fontSize: 40, padding: "20px 0" }}>📁</div>
) : (
<img
src={entry.media_item_id ? api.media.thumbnailUrl(entry.media_item_id) : ""}
alt={entry.name}
loading="lazy"
style={{ width: "100%", height: 110, objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{entry.name}
</div>
</div>
))}
</div>
{viewingId && data && (
<MediaViewer
mediaId={viewingId}
siblings={data.entries}
onClose={() => setViewingId(null)}
onNavigate={setViewingId}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseEntry, MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel";
interface Props {
mediaId: number;
siblings: BrowseEntry[];
onClose: () => void;
onNavigate: (id: number) => void;
}
export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) {
const { data: item } = useQuery<MediaItem>({
queryKey: ["media", mediaId],
queryFn: () => api.media.get(mediaId),
});
const mediaSiblings = siblings.filter((e) => e.media_item_id != null);
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;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft" && prevId) onNavigate(prevId);
if (e.key === "ArrowRight" && nextId) onNavigate(nextId);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [prevId, nextId, onClose, onNavigate]);
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
display: "flex", gap: 16, background: "#1a1a1a", borderRadius: 8,
padding: 16, maxWidth: "95vw", maxHeight: "95vh", overflow: "auto",
}}
>
{/* Prev */}
<button
onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
>
</button>
{/* Media */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
{item?.filename && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
)}
{item?.media_type === "image" && (
<img
src={api.media.fileUrl(mediaId)}
alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "80vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
src={api.media.fileUrl(mediaId)}
controls
style={{ maxWidth: "70vw", maxHeight: "80vh" }}
/>
)}
</div>
{/* Next */}
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Tag panel */}
{item && (
<div style={{ color: "#fff", borderLeft: "1px solid #333", paddingLeft: 16, minWidth: 200 }}>
<TagPanel item={item} />
</div>
)}
{/* Close */}
<button
onClick={onClose}
style={{ position: "absolute", top: 16, right: 16, background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Tag, TagsByCategory, MediaItem } from "../../api/client";
interface Props {
item: MediaItem;
}
export default function TagPanel({ item }: Props) {
const qc = useQueryClient();
const { data: grouped = [] } = useQuery<TagsByCategory[]>({
queryKey: ["tags"],
queryFn: api.tags.list,
});
const allTags = grouped.flatMap((g) => g.tags);
const assignedIds = new Set(item.tags.map((t) => t.id));
const [newName, setNewName] = useState("");
const [newCategory, setNewCategory] = useState("");
const setTagsMutation = useMutation({
mutationFn: (ids: number[]) => api.media.setTags(item.id, ids),
onSuccess: () => qc.invalidateQueries({ queryKey: ["media", item.id] }),
});
const createTagMutation = useMutation({
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
onSuccess: (tag: Tag) => {
qc.invalidateQueries({ queryKey: ["tags"] });
const newIds = [...assignedIds, tag.id];
setTagsMutation.mutate(newIds);
setNewName("");
setNewCategory("");
},
});
function toggle(tagId: number) {
const next = assignedIds.has(tagId)
? [...assignedIds].filter((id) => id !== tagId)
: [...assignedIds, tagId];
setTagsMutation.mutate(next);
}
return (
<div style={{ minWidth: 220 }}>
<h3 style={{ margin: "0 0 12px" }}>Tags</h3>
{grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginBottom: 4 }}>
{group.category}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{group.tags.map((tag) => (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
style={{
padding: "3px 10px",
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
background: assignedIds.has(tag.id) ? "#3b82f6" : "transparent",
color: assignedIds.has(tag.id) ? "#fff" : "inherit",
borderColor: assignedIds.has(tag.id) ? "#3b82f6" : "#ccc",
fontSize: 13,
}}
>
{tag.name}
</button>
))}
</div>
</div>
))}
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13, color: "#3b82f6" }}>+ New tag</summary>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
<input
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<input
placeholder="Category"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<button
onClick={() => createTagMutation.mutate()}
disabled={!newName.trim() || !newCategory.trim() || createTagMutation.isPending}
>
Create & assign
</button>
</div>
</details>
</div>
);
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,28 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api, Library } from "../api/client";
import FileBrowser from "../components/FileBrowser/FileBrowser";
export default function BrowserPage() {
const { libraryId } = useParams<{ libraryId: string }>();
const id = Number(libraryId);
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
});
const library = libraries.find((l) => l.id === id);
if (!library) return <p style={{ padding: "2rem" }}>Library not found.</p>;
return (
<div>
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid #eee" }}>
<h2 style={{ margin: 0 }}>{library.name}</h2>
<div style={{ fontSize: 12, color: "#888" }}>{library.path}</div>
</div>
<FileBrowser libraryId={id} />
</div>
);
}

View File

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

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Library } from "../api/client";
export default function SettingsPage() {
const qc = useQueryClient();
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
});
const [name, setName] = useState("");
const [path, setPath] = useState("");
const [error, setError] = useState("");
const addMutation = useMutation({
mutationFn: () => api.libraries.create(name, path),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["libraries"] });
setName("");
setPath("");
setError("");
},
onError: (e: Error) => setError(e.message),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.libraries.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["libraries"] }),
});
return (
<div style={{ padding: "2rem", maxWidth: 600 }}>
<h1>Settings</h1>
<h2>Libraries</h2>
<form
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
>
<input
placeholder="Library name"
value={name}
onChange={(e) => setName(e.target.value)}
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"}
</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{libraries.map((lib) => (
<li
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>
</div>
);
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

12
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
})