update
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
uvicorn[standard]
|
||||
yt-dlp
|
||||
sqlalchemy
|
||||
aiosqlite
|
||||
|
||||
Binary file not shown.
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ class VideoResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CleanupRequest(BaseModel):
|
||||
profile_id: int
|
||||
exclude_ids: list[int] = []
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user