diff --git a/backend/api/schemas.py b/backend/api/schemas.py index f892b69..a1ddf7d 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -20,18 +20,6 @@ class VideoResponse(BaseModel): model_config = ConfigDict(from_attributes=True) - @classmethod - def fromModel(cls, video): - return cls( - id=video.id, - title=video.title, - youtuber=video.youtuber, - thumbnailUrl=video.thumbnailUrl, - youtubeUrl=video.youtubeUrl, - isDownloaded=video.filePath is not None, - profileIds=[p.id for p in video.profiles], - ) - class CleanupRequest(BaseModel): profileId: int diff --git a/backend/api/video_controller.py b/backend/api/video_controller.py index 511182a..1ebedae 100644 --- a/backend/api/video_controller.py +++ b/backend/api/video_controller.py @@ -1,4 +1,3 @@ -import threading from pathlib import Path from typing import Optional @@ -7,11 +6,11 @@ from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session from api.schemas import CleanupRequest, VideoCreate, VideoResponse -from database.database import SessionLocal, getDb -from download.download_service import downloadVideo +from database.database import getDb +from download.download_service import downloadAsync from model.video import Video from notify.notify_clients import notifyClients -from stream.stream_service import streamVideoLive +from stream.stream_service import streamAndSave router = APIRouter(prefix="/videos", tags=["videos"]) @@ -30,26 +29,21 @@ async def create(videoData: VideoCreate, db: Session = Depends(getDb)): @router.get("", response_model=list[VideoResponse]) -def getAll( - profileId: Optional[int] = Query(None), - db: Session = Depends(getDb), -): - videos = Video.getAll(db, profileId=profileId) - return [VideoResponse.fromModel(v) for v in videos] +def getAll(profileId: Optional[int] = Query(None), db: Session = Depends(getDb)): + return Video.getAll(db, profileId=profileId) @router.get("/downloaded", response_model=list[VideoResponse]) -def getDownloaded( - profileId: Optional[int] = Query(None), - db: Session = Depends(getDb), -): - videos = Video.getDownloaded(db, profileId=profileId) - return [VideoResponse.fromModel(v) for v in videos] +def getDownloaded(profileId: Optional[int] = Query(None), db: Session = Depends(getDb)): + return Video.getDownloaded(db, profileId=profileId) @router.post("/cleanup") def cleanup(request: CleanupRequest, db: Session = Depends(getDb)): - count = Video.deleteNotDownloaded(db, request.profileId, request.excludeIds or None) + profileId = request.profileId + excludeIds = request.excludeIds + + count = Video.deleteNotDownloaded(db, profileId, excludeIds) return {"deleted": count} @@ -57,12 +51,11 @@ def cleanup(request: CleanupRequest, db: Session = Depends(getDb)): def download(videoId: int, db: Session = Depends(getDb)): video = Video.getById(db, videoId) if not video: - raise HTTPException(status_code=404, detail="Video nicht gefunden") + raise HTTPException(404, "Video nicht gefunden") if video.filePath: return {"status": "already_downloaded"} - thread = threading.Thread(target=downloadVideo, args=(video.id, video.youtubeUrl)) - thread.start() + downloadAsync(videoId, video.youtubeUrl) return {"status": "download_started"} @@ -70,52 +63,27 @@ def download(videoId: int, db: Session = Depends(getDb)): def stream(videoId: int, db: Session = Depends(getDb)): video = Video.getById(db, videoId) if not video: - raise HTTPException(status_code=404, detail="Video nicht gefunden") + raise HTTPException(404, "Video nicht gefunden") if not video.filePath: - def streamAndSave(): - outputPath = f"/videos/{videoId}.mp4" - yield from streamVideoLive(videoId, video.youtubeUrl) - if Path(outputPath).exists(): - sdb = SessionLocal() - try: - Video.updateFilePath(sdb, videoId, outputPath) - finally: - sdb.close() - - return StreamingResponse(streamAndSave(), media_type="video/mp4") + return StreamingResponse(streamAndSave(videoId, video.youtubeUrl), media_type="video/mp4") path = Path(video.filePath) if not path.exists(): - raise HTTPException(status_code=404, detail="Videodatei nicht gefunden") - + raise HTTPException(404, "Videodatei nicht gefunden") return FileResponse(path, media_type="video/mp4") @router.get("/{videoId}/file") def getFile(videoId: int, db: Session = Depends(getDb)): - video = Video.getById(db, videoId) - if not video: - raise HTTPException(status_code=404, detail="Video nicht gefunden") - if not video.filePath: - raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen") - - path = Path(video.filePath) - if not path.exists(): - Video.updateFilePath(db, videoId, None) - raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen") - + path, video = Video.getValidPath(db, videoId) + if not path: + raise HTTPException(404, "Video noch nicht heruntergeladen") return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4") @router.delete("/{videoId}/file") def deleteFile(videoId: int, db: Session = Depends(getDb)): - video = Video.getById(db, videoId) - if not video: - raise HTTPException(status_code=404, detail="Video nicht gefunden") - if video.filePath: - path = Path(video.filePath) - if path.exists(): - path.unlink() - Video.updateFilePath(db, videoId, None) + if not Video.deleteServerFile(db, videoId): + raise HTTPException(404, "Video nicht gefunden") return {"status": "deleted"} diff --git a/backend/download/download_service.py b/backend/download/download_service.py index 7e2c946..f6ef4c7 100644 --- a/backend/download/download_service.py +++ b/backend/download/download_service.py @@ -1,4 +1,5 @@ import subprocess +import threading from pathlib import Path from database.database import SessionLocal @@ -8,6 +9,10 @@ VIDEOS_DIR = "/videos" MIN_VALID_SIZE = 1024 * 100 # 100 KB +def downloadAsync(videoId: int, youtubeUrl: str): + threading.Thread(target=downloadVideo, args=(videoId, youtubeUrl)).start() + + def downloadVideo(videoId: int, youtubeUrl: str): outputPath = f"{VIDEOS_DIR}/{videoId}.mp4" diff --git a/backend/model/video.py b/backend/model/video.py index a70d2b9..e00479a 100644 --- a/backend/model/video.py +++ b/backend/model/video.py @@ -1,3 +1,5 @@ +from pathlib import Path + from sqlalchemy import Column, Integer, String from sqlalchemy.orm import Session, relationship @@ -17,6 +19,14 @@ class Video(Base): filePath = Column("file_path", String, nullable=True) profiles = relationship("Profile", secondary=videoProfiles, backref="videos") + @property + def isDownloaded(self) -> bool: + return self.filePath is not None + + @property + def profileIds(self) -> list[int]: + return [p.id for p in self.profiles] + @classmethod def deleteIfExists(cls, db: Session, youtubeUrl: str, profileId: int | None): if not profileId: @@ -80,6 +90,29 @@ class Video(Base): video.filePath = path db.commit() + @classmethod + def getValidPath(cls, db: Session, videoId: int): + video = cls.getById(db, videoId) + if not video or not video.filePath: + return None, None + path = Path(video.filePath) + if not path.exists(): + cls.updateFilePath(db, videoId, None) + return None, None + return path, video + + @classmethod + def deleteServerFile(cls, db: Session, videoId: int) -> bool: + video = cls.getById(db, videoId) + if not video: + return False + if video.filePath: + path = Path(video.filePath) + if path.exists(): + path.unlink() + cls.updateFilePath(db, videoId, None) + return True + @classmethod def deleteNotDownloaded(cls, db: Session, profileId: int, excludeIds: list[int] | None = None) -> int: query = db.query(cls).filter( diff --git a/backend/stream/stream_service.py b/backend/stream/stream_service.py index c2f6b31..f869eeb 100644 --- a/backend/stream/stream_service.py +++ b/backend/stream/stream_service.py @@ -1,10 +1,24 @@ import subprocess from pathlib import Path +from database.database import SessionLocal + VIDEOS_DIR = "/videos" CHUNK_SIZE = 64 * 1024 +def streamAndSave(videoId: int, youtubeUrl: str): + from model.video import Video # Lazy-Import gegen Zirkular + outputPath = f"{VIDEOS_DIR}/{videoId}.mp4" + yield from streamVideoLive(videoId, youtubeUrl) + if Path(outputPath).exists(): + db = SessionLocal() + try: + Video.updateFilePath(db, videoId, outputPath) + finally: + db.close() + + def _getStreamUrls(youtubeUrl: str): result = subprocess.run( [