Self-hosted personal YouTube management app. FastAPI + SQLite backend, React + Vite + Tailwind frontend. Dockerfiles and compose included for Portainer deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
5.9 KiB
Python
179 lines
5.9 KiB
Python
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
|
|
from ..auth_utils import get_current_user
|
|
from ..database import get_db
|
|
from ..models import Collection, CollectionItem, User, Video
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class CollectionOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
created_at: datetime
|
|
video_count: int = 0
|
|
thumbnails: list[str] = []
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class CollectionCreate(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.get("", response_model=list[CollectionOut])
|
|
def list_collections(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
rows = db.execute(
|
|
text("""
|
|
SELECT c.id, c.name, c.created_at,
|
|
COUNT(ci.id) AS video_count
|
|
FROM collections c
|
|
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE c.user_id = :uid
|
|
GROUP BY c.id
|
|
ORDER BY c.created_at DESC
|
|
"""),
|
|
{"uid": current_user.id},
|
|
).mappings().all()
|
|
|
|
result = []
|
|
for row in rows:
|
|
# Grab up to 4 thumbnails for mosaic preview
|
|
thumbs = db.execute(
|
|
text("""
|
|
SELECT v.thumbnail_url FROM collection_items ci
|
|
JOIN videos v ON ci.video_id = v.id
|
|
WHERE ci.collection_id = :cid AND v.thumbnail_url IS NOT NULL
|
|
ORDER BY ci.added_at DESC LIMIT 4
|
|
"""),
|
|
{"cid": row["id"]},
|
|
).scalars().all()
|
|
result.append(CollectionOut(
|
|
id=row["id"],
|
|
name=row["name"],
|
|
created_at=row["created_at"],
|
|
video_count=row["video_count"],
|
|
thumbnails=list(thumbs),
|
|
))
|
|
return result
|
|
|
|
|
|
@router.post("", response_model=CollectionOut, status_code=201)
|
|
def create_collection(
|
|
body: CollectionCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Name required")
|
|
col = Collection(user_id=current_user.id, name=name)
|
|
db.add(col)
|
|
db.commit()
|
|
db.refresh(col)
|
|
return CollectionOut(id=col.id, name=col.name, created_at=col.created_at, video_count=0, thumbnails=[])
|
|
|
|
|
|
@router.patch("/{collection_id}", response_model=CollectionOut)
|
|
def rename_collection(
|
|
collection_id: int,
|
|
body: CollectionCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
|
if not col:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
col.name = (body.name or "").strip() or col.name
|
|
db.commit()
|
|
count = db.query(CollectionItem).filter_by(collection_id=col.id).count()
|
|
return CollectionOut(id=col.id, name=col.name, created_at=col.created_at, video_count=count)
|
|
|
|
|
|
@router.delete("/{collection_id}", status_code=204)
|
|
def delete_collection(
|
|
collection_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
|
if col:
|
|
db.delete(col)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/{collection_id}/videos")
|
|
def get_collection_videos(
|
|
collection_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
|
if not col:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
rows = db.execute(
|
|
text("""
|
|
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
|
v.duration_seconds, v.published_at, v.description,
|
|
c.id AS channel_id, c.name AS channel_name,
|
|
COALESCE(uv.watched, 0) AS is_watched,
|
|
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
|
COALESCE(uv.liked, 0) AS liked,
|
|
COALESCE(uv.queued, 0) AS queued,
|
|
ci.added_at
|
|
FROM collection_items ci
|
|
JOIN videos v ON ci.video_id = v.id
|
|
LEFT JOIN channels c ON v.channel_id = c.id
|
|
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid
|
|
WHERE ci.collection_id = :cid
|
|
ORDER BY ci.added_at DESC
|
|
"""),
|
|
{"uid": current_user.id, "cid": collection_id},
|
|
).mappings().all()
|
|
return {"collection": {"id": col.id, "name": col.name}, "videos": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/{collection_id}/videos", status_code=201)
|
|
def add_to_collection(
|
|
collection_id: int,
|
|
body: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
|
if not col:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
video_id = body.get("video_id")
|
|
if not video_id:
|
|
raise HTTPException(status_code=400, detail="video_id required")
|
|
existing = db.query(CollectionItem).filter_by(collection_id=collection_id, video_id=video_id).first()
|
|
if not existing:
|
|
db.add(CollectionItem(collection_id=collection_id, video_id=video_id))
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{collection_id}/videos/{video_id}", status_code=204)
|
|
def remove_from_collection(
|
|
collection_id: int,
|
|
video_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
|
if not col:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
item = db.query(CollectionItem).filter_by(collection_id=collection_id, video_id=video_id).first()
|
|
if item:
|
|
db.delete(item)
|
|
db.commit()
|