This commit is contained in:
Marek Lenczewski
2026-04-07 16:13:16 +02:00
parent 52c4e5f33d
commit 8f15f51bce
32 changed files with 212 additions and 196 deletions

View File

@@ -10,4 +10,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uvicorn", "base.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from api.schemas import ProfileResponse
from database.database import get_db
from model.profile import Profile
router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("", response_model=list[ProfileResponse])
def get_profiles(db: Session = Depends(get_db)):
return Profile.get_all(db)

View File

@@ -1,18 +1,17 @@
import asyncio
import threading
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from database import get_db
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
from api.schemas import CleanupRequest, VideoCreate, VideoResponse
from database.database import SessionLocal, get_db
from download.download_service import download_video
from model.video import Video
from notify.notify_clients import notify_clients
from stream.stream_service import stream_video_live
router = APIRouter(prefix="/videos", tags=["videos"])
@@ -23,14 +22,14 @@ async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(ge
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)
Video.delete_by_youtube_id(db, video_id_match)
data = video_data.model_dump(exclude={"profile_id"})
video = Video.create_from_dict(db, data, video_data.profile_id)
created_ids.append(video.id)
profile_ids.add(video_data.profile_id or 1)
videos = [video_service.get_video(db, vid) for vid in created_ids]
videos = [Video.get_by_id(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]
@@ -38,25 +37,25 @@ async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(ge
@router.get("", response_model=list[VideoResponse])
def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = video_service.get_all_videos(db, profile_id=profile_id)
videos = Video.get_all(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos]
@router.get("/downloaded", response_model=list[VideoResponse])
def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = video_service.get_downloaded_videos(db, profile_id=profile_id)
videos = Video.get_downloaded(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos]
@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)
count = Video.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None)
return {"deleted": count}
@router.post("/{video_id}/download")
def trigger_download(video_id: int, db: Session = Depends(get_db)):
video = video_service.get_video(db, video_id)
video = Video.get_by_id(db, video_id)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if video.file_path:
@@ -69,7 +68,7 @@ def trigger_download(video_id: int, db: Session = Depends(get_db)):
@router.get("/{video_id}/stream")
def stream_video(video_id: int, db: Session = Depends(get_db)):
video = video_service.get_video(db, video_id)
video = Video.get_by_id(db, video_id)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
@@ -78,9 +77,9 @@ def stream_video(video_id: int, db: Session = Depends(get_db)):
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()
sdb = SessionLocal()
try:
update_file_path(sdb, video_id, output_path)
Video.update_file_path(sdb, video_id, output_path)
finally:
sdb.close()
@@ -95,7 +94,7 @@ def stream_video(video_id: int, db: Session = Depends(get_db)):
@router.get("/{video_id}/file")
def download_file(video_id: int, db: Session = Depends(get_db)):
video = video_service.get_video(db, video_id)
video = Video.get_by_id(db, video_id)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if not video.file_path:
@@ -103,7 +102,7 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
path = Path(video.file_path)
if not path.exists():
video_service.update_file_path(db, video_id, None)
Video.update_file_path(db, video_id, None)
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
@@ -111,20 +110,12 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
@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)
video = Video.get_by_id(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)
Video.update_file_path(db, video_id, None)
return {"status": "deleted"}
profiles_router = APIRouter(prefix="/profiles", tags=["profiles"])
@profiles_router.get("", response_model=list[ProfileResponse])
def get_profiles(db: Session = Depends(get_db)):
return video_service.get_all_profiles(db)

36
backend/base/app.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.profile_controller import router as profiles_router
from api.video_controller import router as videos_router
from database.database import SessionLocal, create_tables
from model.profile import Profile
from notify.notify_clients import register_websocket
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(videos_router)
app.include_router(profiles_router)
register_websocket(app)
@app.on_event("startup")
def startup():
create_tables()
db = SessionLocal()
if db.query(Profile).count() == 0:
db.add(Profile(name="Standard"))
db.commit()
db.close()
@app.get("/")
def root():
return {"status": "running"}

View File

View File

View File

@@ -1,9 +1,11 @@
import subprocess
from pathlib import Path
from database import SessionLocal
from services.video_service import get_video, update_file_path
from database.database import SessionLocal
from model.video import Video
VIDEOS_DIR = "/videos"
MIN_VALID_SIZE = 1024 * 100 # 100 KB
def download_video(video_id: int, youtube_url: str):
@@ -15,13 +17,19 @@ def download_video(video_id: int, youtube_url: str):
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"-o", output_path,
"--merge-output-format", "mp4",
"--force-overwrites",
"--no-continue",
youtube_url,
],
check=True,
)
path = Path(output_path)
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
return
db = SessionLocal()
try:
update_file_path(db, video_id, output_path)
Video.update_file_path(db, video_id, output_path)
finally:
db.close()

View File

@@ -1,59 +0,0 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from database import SessionLocal, create_tables
from models import Profile
from routes.videos import profiles_router, router as videos_router
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
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():
create_tables()
db = SessionLocal()
if db.query(Profile).count() == 0:
db.add(Profile(name="Standard"))
db.commit()
db.close()
@app.get("/")
def root():
return {"status": "running"}

View File

15
backend/model/profile.py Normal file
View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import Session
from database.database import Base
class Profile(Base):
__tablename__ = "profiles"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False, unique=True)
@classmethod
def get_all(cls, db: Session) -> list["Profile"]:
return db.query(cls).all()

View File

@@ -0,0 +1,10 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from database.database import Base
video_profiles = Table(
"video_profiles",
Base.metadata,
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
)

77
backend/model/video.py Normal file
View File

@@ -0,0 +1,77 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import Session, relationship
from database.database import Base
from model.profile import Profile
from model.profile_video import video_profiles
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)
file_path = Column(String, nullable=True)
profiles = relationship("Profile", secondary=video_profiles, backref="videos")
@classmethod
def create_from_dict(cls, db: Session, data: dict, profile_id: int | None) -> "Video":
video = cls(**data)
if not profile_id:
profile_id = 1
profile = db.query(Profile).filter(Profile.id == profile_id).first()
if profile:
video.profiles.append(profile)
db.add(video)
db.commit()
db.refresh(video)
return video
@classmethod
def get_all(cls, db: Session, profile_id: int | None = None) -> list["Video"]:
query = db.query(cls)
if profile_id:
query = query.filter(cls.profiles.any(Profile.id == profile_id))
return query.order_by(cls.id.desc()).all()
@classmethod
def get_downloaded(cls, db: Session, profile_id: int | None = None) -> list["Video"]:
query = db.query(cls).filter(cls.file_path.isnot(None))
if profile_id:
query = query.filter(cls.profiles.any(Profile.id == profile_id))
return query.order_by(cls.id.desc()).all()
@classmethod
def get_by_id(cls, db: Session, video_id: int) -> "Video | None":
return db.query(cls).filter(cls.id == video_id).first()
@classmethod
def delete_by_youtube_id(cls, db: Session, youtube_id: str):
db.query(cls).filter(cls.youtube_url.contains(youtube_id)).delete(synchronize_session=False)
db.commit()
@classmethod
def update_file_path(cls, db: Session, video_id: int, path: str | None):
video = cls.get_by_id(db, video_id)
if video:
video.file_path = path
db.commit()
@classmethod
def delete_not_downloaded(cls, db: Session, profile_id: int, exclude_ids: list[int] | None = None) -> int:
query = db.query(cls).filter(
cls.profiles.any(Profile.id == profile_id),
)
if exclude_ids:
query = query.filter(cls.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(cls).filter(cls.id.in_(video_ids)).delete(synchronize_session=False)
db.commit()
return len(video_ids)

View File

@@ -1,30 +0,0 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Table
from sqlalchemy.orm import relationship
from database import Base
video_profiles = Table(
"video_profiles",
Base.metadata,
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
)
class Profile(Base):
__tablename__ = "profiles"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False, unique=True)
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)
file_path = Column(String, nullable=True)
profiles = relationship("Profile", secondary=video_profiles, backref="videos")

View File

View File

@@ -0,0 +1,24 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
connected_clients: set[WebSocket] = set()
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)
def register_websocket(app: FastAPI):
@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)

View File

@@ -1,69 +0,0 @@
from sqlalchemy.orm import Session
from models import Profile, Video, video_profiles
from schemas import VideoCreate
def create_video(db: Session, video_data: VideoCreate) -> Video:
profile_id = video_data.profile_id
data = video_data.model_dump(exclude={"profile_id"})
video = Video(**data)
if not profile_id:
profile_id = 1
profile = db.query(Profile).filter(Profile.id == profile_id).first()
if profile:
video.profiles.append(profile)
db.add(video)
db.commit()
db.refresh(video)
return video
def get_all_videos(db: Session, profile_id: int | None = None) -> list[Video]:
query = db.query(Video)
if profile_id:
query = query.filter(Video.profiles.any(Profile.id == profile_id))
return query.order_by(Video.id.desc()).all()
def get_downloaded_videos(db: Session, profile_id: int | None = None) -> list[Video]:
query = db.query(Video).filter(Video.file_path.isnot(None))
if profile_id:
query = query.filter(Video.profiles.any(Profile.id == profile_id))
return query.order_by(Video.id.desc()).all()
def get_video(db: Session, video_id: int) -> Video | None:
return db.query(Video).filter(Video.id == video_id).first()
def delete_by_youtube_id(db: Session, youtube_id: str):
db.query(Video).filter(Video.youtube_url.contains(youtube_id)).delete(synchronize_session=False)
db.commit()
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()
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),
)
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 len(video_ids)
def get_all_profiles(db: Session) -> list[Profile]:
return db.query(Profile).all()

View File

View File

@@ -11,14 +11,14 @@
"content_scripts": [
{
"matches": ["*://www.youtube.com/*"],
"js": ["content.js"]
"js": ["tracking/content.js"]
}
],
"background": {
"scripts": ["background.js"]
"scripts": ["api/background.js"]
},
"browser_action": {
"default_popup": "popup.html",
"default_popup": "config/popup.html",
"default_title": "Profil auswählen"
}
}