This commit is contained in:
Marek
2026-04-05 23:26:55 +02:00
parent b3b4410ee0
commit b6635a107d
21 changed files with 229 additions and 75 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from database import SessionLocal, create_tables
@@ -17,6 +17,32 @@ app.add_middleware(
app.include_router(videos_router)
app.include_router(profiles_router)
# --- WebSocket ---
connected_clients: set[WebSocket] = set()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
connected_clients.discard(websocket)
async def notify_clients(profile_ids: list[int]):
message = ",".join(str(pid) for pid in profile_ids)
for client in list(connected_clients):
try:
await client.send_text(message)
except Exception:
connected_clients.discard(client)
# --- Startup ---
@app.on_event("startup")
def startup():

View File

@@ -1,5 +1,5 @@
fastapi
uvicorn
uvicorn[standard]
yt-dlp
sqlalchemy
aiosqlite

View File

@@ -1,29 +1,39 @@
import asyncio
import threading
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from database import get_db
from schemas import ProfileResponse, VideoCreate, VideoResponse
from schemas import CleanupRequest, ProfileResponse, VideoCreate, VideoResponse
from services import video_service
from services.download_service import download_video
from services.stream_service import stream_video_live
from services.video_service import update_file_path
router = APIRouter(prefix="/videos", tags=["videos"])
@router.post("", response_model=list[VideoResponse])
def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)):
async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)):
created_ids = []
profile_ids = set()
for video_data in reversed(videos_data):
video_id_match = video_data.youtube_url.split("v=")[-1].split("&")[0]
video_service.delete_by_youtube_id(db, video_id_match)
video = video_service.create_video(db, video_data)
created_ids.append(video.id)
if video_data.profile_id:
profile_ids.add(video_data.profile_id)
videos = [video_service.get_video(db, vid) for vid in created_ids]
if profile_ids:
from main import notify_clients
await notify_clients(list(profile_ids))
return [VideoResponse.from_model(v) for v in videos if v]
@@ -39,9 +49,9 @@ def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session =
return [VideoResponse.from_model(v) for v in videos]
@router.delete("")
def delete_not_downloaded(profile_id: int = Query(...), db: Session = Depends(get_db)):
count = video_service.delete_not_downloaded(db, profile_id)
@router.post("/cleanup")
def cleanup_videos(request: CleanupRequest, db: Session = Depends(get_db)):
count = video_service.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None)
return {"deleted": count}
@@ -65,21 +75,23 @@ def stream_video(video_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if not video.file_path:
return StreamingResponse(
stream_video_live(video.youtube_url),
media_type="video/mp4",
)
def stream_and_save():
output_path = f"/videos/{video_id}.mp4"
yield from stream_video_live(video_id, video.youtube_url)
if Path(output_path).exists():
sdb = __import__("database").SessionLocal()
try:
update_file_path(sdb, video_id, output_path)
finally:
sdb.close()
return StreamingResponse(stream_and_save(), media_type="video/mp4")
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")
return FileResponse(path, media_type="video/mp4")
@router.get("/{video_id}/file")
@@ -97,6 +109,19 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
@router.delete("/{video_id}/file")
def delete_server_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 video.file_path:
path = Path(video.file_path)
if path.exists():
path.unlink()
video_service.update_file_path(db, video_id, None)
return {"status": "deleted"}
profiles_router = APIRouter(prefix="/profiles", tags=["profiles"])

View File

@@ -34,6 +34,11 @@ class VideoResponse(BaseModel):
)
class CleanupRequest(BaseModel):
profile_id: int
exclude_ids: list[int] = []
class ProfileResponse(BaseModel):
id: int
name: str

View File

@@ -12,7 +12,7 @@ def download_video(video_id: int, youtube_url: str):
subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"-o", output_path,
"--merge-output-format", "mp4",
youtube_url,

View File

@@ -1,32 +1,53 @@
import subprocess
import time
from pathlib import Path
VIDEOS_DIR = "/videos"
def stream_video_live(youtube_url: str):
result = subprocess.run(
def stream_video_live(video_id: int, youtube_url: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
path = Path(output_path)
process = subprocess.Popen(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"-g", youtube_url,
"-f", "best[ext=mp4][vcodec^=avc]/best[ext=mp4]",
"-o", output_path,
youtube_url,
],
capture_output=True, text=True, check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
urls = result.stdout.strip().split("\n")
cmd = ["ffmpeg"]
for url in urls:
cmd.extend(["-i", url])
cmd.extend(["-c", "copy", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"])
# Warte bis Datei existiert und mindestens 1MB hat
while process.poll() is None:
if path.exists() and path.stat().st_size >= 1024 * 1024:
break
time.sleep(0.5)
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
while True:
chunk = process.stdout.read(1024 * 1024)
if not chunk:
break
yield chunk
if not path.exists():
process.wait()
except GeneratorExit:
process.kill()
finally:
if process.poll() is None:
process.kill()
return
# Streame aus der wachsenden Datei
pos = 0
stall_count = 0
with open(output_path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if chunk:
pos += len(chunk)
stall_count = 0
yield chunk
else:
if process.poll() is not None:
# Download fertig — restliche Bytes lesen
remaining = f.read()
if remaining:
yield remaining
break
stall_count += 1
if stall_count > 60: # 30 Sekunden ohne neue Daten
break
time.sleep(0.5)

View File

@@ -48,16 +48,20 @@ def update_file_path(db: Session, video_id: int, path: str):
db.commit()
def delete_not_downloaded(db: Session, profile_id: int) -> int:
videos = db.query(Video).filter(
Video.file_path.is_(None),
def delete_not_downloaded(db: Session, profile_id: int, exclude_ids: list[int] | None = None) -> int:
query = db.query(Video).filter(
Video.profiles.any(Profile.id == profile_id),
).all()
count = len(videos)
for video in videos:
db.delete(video)
)
if exclude_ids:
query = query.filter(Video.id.notin_(exclude_ids))
videos = query.all()
video_ids = [v.id for v in videos]
if not video_ids:
return 0
db.execute(video_profiles.delete().where(video_profiles.c.video_id.in_(video_ids)))
db.query(Video).filter(Video.id.in_(video_ids)).delete(synchronize_session=False)
db.commit()
return count
return len(video_ids)
def get_all_profiles(db: Session) -> list[Profile]: