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

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"]