diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fe753e8 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = "sqlite:///videos/youtubeapp.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine) +Base = declarative_base() + + +def create_tables(): + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/main.py b/backend/main.py index c593ad8..9d1d085 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,25 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from database import create_tables +from routes.videos import router as videos_router app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(videos_router) + + +@app.on_event("startup") +def startup(): + create_tables() + @app.get("/") def root(): diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..f647058 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer, String + +from database import Base + + +class Video(Base): + __tablename__ = "videos" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String, nullable=False) + youtuber = Column(String, nullable=False) + thumbnail_url = Column(String, nullable=False) + youtube_url = Column(String, nullable=False, unique=True) + file_path = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/requirements.txt b/backend/requirements.txt index 96172c4..feec010 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,6 @@ fastapi uvicorn yt-dlp +sqlalchemy +aiosqlite +python-multipart diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/__pycache__/__init__.cpython-312.pyc b/backend/routes/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..69808bf Binary files /dev/null and b/backend/routes/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/routes/__pycache__/videos.cpython-312.pyc b/backend/routes/__pycache__/videos.cpython-312.pyc new file mode 100644 index 0000000..80a5230 Binary files /dev/null and b/backend/routes/__pycache__/videos.cpython-312.pyc differ diff --git a/backend/routes/videos.py b/backend/routes/videos.py new file mode 100644 index 0000000..02ca860 --- /dev/null +++ b/backend/routes/videos.py @@ -0,0 +1,81 @@ +import threading +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy.orm import Session + +from database import get_db +from schemas import VideoCreate, VideoResponse +from services import video_service +from services.download_service import download_video + +router = APIRouter(prefix="/videos", tags=["videos"]) + + +@router.post("", response_model=VideoResponse) +def create_video(video_data: VideoCreate, db: Session = Depends(get_db)): + video = video_service.create_video(db, video_data) + return VideoResponse.from_model(video) + + +@router.get("", response_model=list[VideoResponse]) +def get_all_videos(db: Session = Depends(get_db)): + videos = video_service.get_all_videos(db) + return [VideoResponse.from_model(v) for v in videos] + + +@router.get("/downloaded", response_model=list[VideoResponse]) +def get_downloaded_videos(db: Session = Depends(get_db)): + videos = video_service.get_downloaded_videos(db) + return [VideoResponse.from_model(v) for v in videos] + + +@router.post("/{video_id}/download") +def trigger_download(video_id: int, db: Session = Depends(get_db)): + video = video_service.get_video(db, video_id) + if not video: + raise HTTPException(status_code=404, detail="Video nicht gefunden") + if video.file_path: + return {"status": "already_downloaded"} + + thread = threading.Thread(target=download_video, args=(video.id, video.youtube_url)) + thread.start() + return {"status": "download_started"} + + +@router.get("/{video_id}/stream") +def stream_video(video_id: int, db: Session = Depends(get_db)): + video = video_service.get_video(db, video_id) + if not video: + raise HTTPException(status_code=404, detail="Video nicht gefunden") + + if not video.file_path: + download_video(video.id, video.youtube_url) + db.refresh(video) + + path = Path(video.file_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="Videodatei nicht gefunden") + + def iter_file(): + with open(path, "rb") as f: + while chunk := f.read(1024 * 1024): + yield chunk + + return StreamingResponse(iter_file(), media_type="video/mp4") + + +@router.get("/{video_id}/file") +def download_file(video_id: int, db: Session = Depends(get_db)): + video = video_service.get_video(db, video_id) + if not video: + raise HTTPException(status_code=404, detail="Video nicht gefunden") + if not video.file_path: + raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen") + + path = Path(video.file_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="Videodatei nicht gefunden") + + return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4") diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..0467859 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class VideoCreate(BaseModel): + title: str + youtuber: str + thumbnail_url: str + youtube_url: str + + +class VideoResponse(BaseModel): + id: int + title: str + youtuber: str + thumbnail_url: str + youtube_url: str + is_downloaded: bool + created_at: datetime + + class Config: + from_attributes = True + + @classmethod + def from_model(cls, video): + return cls( + id=video.id, + title=video.title, + youtuber=video.youtuber, + thumbnail_url=video.thumbnail_url, + youtube_url=video.youtube_url, + is_downloaded=video.file_path is not None, + created_at=video.created_at, + ) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/__pycache__/__init__.cpython-312.pyc b/backend/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..763eb5b Binary files /dev/null and b/backend/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/services/__pycache__/download_service.cpython-312.pyc b/backend/services/__pycache__/download_service.cpython-312.pyc new file mode 100644 index 0000000..540a64d Binary files /dev/null and b/backend/services/__pycache__/download_service.cpython-312.pyc differ diff --git a/backend/services/__pycache__/video_service.cpython-312.pyc b/backend/services/__pycache__/video_service.cpython-312.pyc new file mode 100644 index 0000000..65b9e0c Binary files /dev/null and b/backend/services/__pycache__/video_service.cpython-312.pyc differ diff --git a/backend/services/download_service.py b/backend/services/download_service.py new file mode 100644 index 0000000..6f2debe --- /dev/null +++ b/backend/services/download_service.py @@ -0,0 +1,27 @@ +import subprocess + +from database import SessionLocal +from services.video_service import get_video, update_file_path + +VIDEOS_DIR = "/videos" + + +def download_video(video_id: int, youtube_url: str): + output_path = f"{VIDEOS_DIR}/{video_id}.mp4" + + subprocess.run( + [ + "yt-dlp", + "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]", + "-o", output_path, + "--merge-output-format", "mp4", + youtube_url, + ], + check=True, + ) + + db = SessionLocal() + try: + update_file_path(db, video_id, output_path) + finally: + db.close() diff --git a/backend/services/video_service.py b/backend/services/video_service.py new file mode 100644 index 0000000..cc421b9 --- /dev/null +++ b/backend/services/video_service.py @@ -0,0 +1,31 @@ +from sqlalchemy.orm import Session + +from models import Video +from schemas import VideoCreate + + +def create_video(db: Session, video_data: VideoCreate) -> Video: + video = Video(**video_data.model_dump()) + db.add(video) + db.commit() + db.refresh(video) + return video + + +def get_all_videos(db: Session) -> list[Video]: + return db.query(Video).order_by(Video.created_at.desc()).all() + + +def get_downloaded_videos(db: Session) -> list[Video]: + return db.query(Video).filter(Video.file_path.isnot(None)).order_by(Video.created_at.desc()).all() + + +def get_video(db: Session, video_id: int) -> Video | None: + return db.query(Video).filter(Video.id == video_id).first() + + +def update_file_path(db: Session, video_id: int, path: str): + video = get_video(db, video_id) + if video: + video.file_path = path + db.commit()