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>
This commit is contained in:
inputnoise
2026-05-25 20:09:04 +02:00
commit 1827dd6c4e
63 changed files with 14480 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
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()