diff --git a/backend/Dockerfile b/backend/Dockerfile index d013b81..92c94c9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/routes/__init__.py b/backend/api/__init__.py similarity index 100% rename from backend/routes/__init__.py rename to backend/api/__init__.py diff --git a/backend/api/profile_controller.py b/backend/api/profile_controller.py new file mode 100644 index 0000000..3043391 --- /dev/null +++ b/backend/api/profile_controller.py @@ -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) diff --git a/backend/schemas.py b/backend/api/schemas.py similarity index 100% rename from backend/schemas.py rename to backend/api/schemas.py diff --git a/backend/routes/videos.py b/backend/api/video_controller.py similarity index 68% rename from backend/routes/videos.py rename to backend/api/video_controller.py index 6b483f2..cf2c393 100644 --- a/backend/routes/videos.py +++ b/backend/api/video_controller.py @@ -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) diff --git a/backend/services/__init__.py b/backend/base/__init__.py similarity index 100% rename from backend/services/__init__.py rename to backend/base/__init__.py diff --git a/backend/base/app.py b/backend/base/app.py new file mode 100644 index 0000000..9049966 --- /dev/null +++ b/backend/base/app.py @@ -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"} diff --git a/backend/database/__init__.py b/backend/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database/database.py similarity index 100% rename from backend/database.py rename to backend/database/database.py diff --git a/backend/download/__init__.py b/backend/download/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/download_service.py b/backend/download/download_service.py similarity index 57% rename from backend/services/download_service.py rename to backend/download/download_service.py index b0dfa15..f8a049e 100644 --- a/backend/services/download_service.py +++ b/backend/download/download_service.py @@ -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() diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 6d21d98..0000000 --- a/backend/main.py +++ /dev/null @@ -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"} diff --git a/backend/model/__init__.py b/backend/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model/profile.py b/backend/model/profile.py new file mode 100644 index 0000000..cd1c56d --- /dev/null +++ b/backend/model/profile.py @@ -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() diff --git a/backend/model/profile_video.py b/backend/model/profile_video.py new file mode 100644 index 0000000..bb357ea --- /dev/null +++ b/backend/model/profile_video.py @@ -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")), +) diff --git a/backend/model/video.py b/backend/model/video.py new file mode 100644 index 0000000..a78da47 --- /dev/null +++ b/backend/model/video.py @@ -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) diff --git a/backend/models.py b/backend/models.py deleted file mode 100644 index e61527a..0000000 --- a/backend/models.py +++ /dev/null @@ -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") diff --git a/backend/notify/__init__.py b/backend/notify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notify/notify_clients.py b/backend/notify/notify_clients.py new file mode 100644 index 0000000..b426e4f --- /dev/null +++ b/backend/notify/notify_clients.py @@ -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) diff --git a/backend/routes/__pycache__/__init__.cpython-312.pyc b/backend/routes/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a265420..0000000 Binary files a/backend/routes/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/routes/__pycache__/videos.cpython-312.pyc b/backend/routes/__pycache__/videos.cpython-312.pyc deleted file mode 100644 index 550176a..0000000 Binary files a/backend/routes/__pycache__/videos.cpython-312.pyc and /dev/null differ diff --git a/backend/services/__pycache__/__init__.cpython-312.pyc b/backend/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e6c80ea..0000000 Binary files a/backend/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/services/__pycache__/download_service.cpython-312.pyc b/backend/services/__pycache__/download_service.cpython-312.pyc deleted file mode 100644 index 5d04e6b..0000000 Binary files a/backend/services/__pycache__/download_service.cpython-312.pyc and /dev/null differ diff --git a/backend/services/__pycache__/video_service.cpython-312.pyc b/backend/services/__pycache__/video_service.cpython-312.pyc deleted file mode 100644 index b792b3f..0000000 Binary files a/backend/services/__pycache__/video_service.cpython-312.pyc and /dev/null differ diff --git a/backend/services/video_service.py b/backend/services/video_service.py deleted file mode 100644 index 3734fb8..0000000 --- a/backend/services/video_service.py +++ /dev/null @@ -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() diff --git a/backend/stream/__init__.py b/backend/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/stream_service.py b/backend/stream/stream_service.py similarity index 100% rename from backend/services/stream_service.py rename to backend/stream/stream_service.py diff --git a/browser_extension/background.js b/browser_extension/api/background.js similarity index 100% rename from browser_extension/background.js rename to browser_extension/api/background.js diff --git a/browser_extension/popup.html b/browser_extension/config/popup.html similarity index 100% rename from browser_extension/popup.html rename to browser_extension/config/popup.html diff --git a/browser_extension/popup.js b/browser_extension/config/popup.js similarity index 100% rename from browser_extension/popup.js rename to browser_extension/config/popup.js diff --git a/browser_extension/manifest.json b/browser_extension/manifest.json index 8c7995c..96ff6c5 100644 --- a/browser_extension/manifest.json +++ b/browser_extension/manifest.json @@ -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" } } diff --git a/browser_extension/content.js b/browser_extension/tracking/content.js similarity index 100% rename from browser_extension/content.js rename to browser_extension/tracking/content.js