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

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()