initial commit
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
15
backend/app/config.py
Normal file
15
backend/app/config.py
Normal 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
15
backend/app/database.py
Normal 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
37
backend/app/main.py
Normal 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
56
backend/app/models.py
Normal 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")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
102
backend/app/routers/libraries.py
Normal file
102
backend/app/routers/libraries.py
Normal 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)
|
||||
85
backend/app/routers/media.py
Normal file
85
backend/app/routers/media.py
Normal 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
|
||||
44
backend/app/routers/search.py
Normal file
44
backend/app/routers/search.py
Normal 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()
|
||||
45
backend/app/routers/tags.py
Normal file
45
backend/app/routers/tags.py
Normal 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
88
backend/app/schemas.py
Normal 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]
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
91
backend/app/services/scanner.py
Normal file
91
backend/app/services/scanner.py
Normal 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()
|
||||
53
backend/app/services/thumbnails.py
Normal file
53
backend/app/services/thumbnails.py
Normal 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
|
||||
140
backend/app/services/watcher.py
Normal file
140
backend/app/services/watcher.py
Normal 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)
|
||||
Reference in New Issue
Block a user