Files
youclonedl/backend/routers/collections.py
inputnoise 1827dd6c4e Initial commit — YT Hub
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>
2026-05-25 20:09:04 +02:00

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