This commit is contained in:
Marek Lenczewski
2026-04-08 08:51:59 +02:00
parent 375a9cd386
commit a0c8ecaf27
12 changed files with 75 additions and 92 deletions

View File

@@ -3,20 +3,19 @@ package com.youtubeapp.data
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
interface VideoApi { interface VideoApi {
@GET("videos") @GET("profiles/{profileId}/videos")
suspend fun getAllVideos(@Query("profileId") profileId: Int? = null): List<Video> suspend fun getAllVideos(@Path("profileId") profileId: Int): List<Video>
@GET("videos/downloaded")
suspend fun getDownloadedVideos(@Query("profileId") profileId: Int? = null): List<Video>
@POST("videos/{id}/download") @POST("videos/{id}/download")
suspend fun triggerDownload(@Path("id") id: Int): Map<String, String> suspend fun triggerDownload(@Path("id") id: Int): Map<String, String>
@POST("videos/cleanup") @POST("profiles/{profileId}/videos/cleanup")
suspend fun cleanupVideos(@retrofit2.http.Body body: Map<String, @JvmSuppressWildcards Any>): Map<String, Int> suspend fun cleanupVideos(
@Path("profileId") profileId: Int,
@retrofit2.http.Body body: Map<String, @JvmSuppressWildcards Any>,
): Map<String, Int>
@retrofit2.http.DELETE("videos/{id}/file") @retrofit2.http.DELETE("videos/{id}/file")
suspend fun deleteServerFile(@Path("id") id: Int): Map<String, String> suspend fun deleteServerFile(@Path("id") id: Int): Map<String, String>

View File

@@ -2,12 +2,9 @@ package com.youtubeapp.data
class VideoRepository(private val api: VideoApi = ApiClient.api) { class VideoRepository(private val api: VideoApi = ApiClient.api) {
suspend fun getAllVideos(profileId: Int? = null): Result<List<Video>> = suspend fun getAllVideos(profileId: Int): Result<List<Video>> =
runCatching { api.getAllVideos(profileId) } runCatching { api.getAllVideos(profileId) }
suspend fun getDownloadedVideos(profileId: Int? = null): Result<List<Video>> =
runCatching { api.getDownloadedVideos(profileId) }
suspend fun triggerDownload(videoId: Int): Result<String> = runCatching { suspend fun triggerDownload(videoId: Int): Result<String> = runCatching {
val response = api.triggerDownload(videoId) val response = api.triggerDownload(videoId)
response["status"] ?: "unknown" response["status"] ?: "unknown"
@@ -19,8 +16,8 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
} }
suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching { suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching {
val body = mapOf("profileId" to profileId, "excludeIds" to excludeIds) val body = mapOf("excludeIds" to excludeIds)
val response = api.cleanupVideos(body) val response = api.cleanupVideos(profileId, body)
response["deleted"] ?: 0 response["deleted"] ?: 0
} }

View File

@@ -103,9 +103,10 @@ class VideoViewModel : ViewModel() {
} }
fun loadAllVideos() { fun loadAllVideos() {
val profileId = _state.value.selectedProfileId ?: return
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
repository.getAllVideos(profileId = _state.value.selectedProfileId) repository.getAllVideos(profileId)
.onSuccess { videos -> .onSuccess { videos ->
_state.value = _state.value.copy(allVideos = videos, isLoading = false) _state.value = _state.value.copy(allVideos = videos, isLoading = false)
} }
@@ -135,10 +136,11 @@ class VideoViewModel : ViewModel() {
val status = result.getOrNull() val status = result.getOrNull()
if (status == "download_started") { if (status == "download_started") {
val profileId = _state.value.selectedProfileId ?: 1
var attempts = 0 var attempts = 0
while (attempts < 150) { while (attempts < 150) {
delay(2000) delay(2000)
val videosResult = repository.getAllVideos() val videosResult = repository.getAllVideos(profileId)
val video = videosResult.getOrNull()?.find { it.id == videoId } val video = videosResult.getOrNull()?.find { it.id == videoId }
if (video?.isDownloaded == true) break if (video?.isDownloaded == true) break
attempts++ attempts++

View File

@@ -1,13 +1,38 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter
from sqlalchemy.orm import Session
from api.schemas import ProfileResponse from api.schemas import CleanupRequest, VideoCreate, VideoResponse
from database.database import getDb from database.database import DbSession
from model.profile import Profile from model.profile import Profile
from model.video import Video
from notify.notify_clients import notifyClients
router = APIRouter(prefix="/profiles", tags=["profiles"]) router = APIRouter()
@router.get("", response_model=list[ProfileResponse]) @router.get("/profiles")
def getAll(db: Session = Depends(getDb)): def getAll(db: DbSession):
return Profile.getAll(db) return Profile.getAll(db)
@router.post("/profiles/{profileId}/videos", status_code=204)
async def createVideo(profileId: int, videoData: VideoCreate, db: DbSession):
title = videoData.title
youtuber = videoData.youtuber
thumbnailUrl = videoData.thumbnailUrl
youtubeUrl = videoData.youtubeUrl
Video.deleteIfExists(db, youtubeUrl, profileId)
Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
await notifyClients(profileId)
@router.get("/profiles/{profileId}/videos", response_model=list[VideoResponse])
def getVideos(profileId: int, db: DbSession):
return Video.getAll(db, profileId=profileId)
@router.post("/profiles/{profileId}/videos/cleanup")
def cleanupVideos(profileId: int, request: CleanupRequest, db: DbSession):
excludeIds = request.excludeIds
count = Video.deleteNotDownloaded(db, profileId, excludeIds)
return {"deleted": count}

View File

@@ -6,7 +6,6 @@ class VideoCreate(BaseModel):
youtuber: str youtuber: str
thumbnailUrl: str thumbnailUrl: str
youtubeUrl: str youtubeUrl: str
profileId: int | None = None
class VideoResponse(BaseModel): class VideoResponse(BaseModel):
@@ -22,12 +21,4 @@ class VideoResponse(BaseModel):
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
profileId: int
excludeIds: list[int] = [] excludeIds: list[int] = []
class ProfileResponse(BaseModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,54 +1,18 @@
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from api.schemas import CleanupRequest, VideoCreate, VideoResponse from database.database import DbSession
from database.database import getDb
from download.download_service import downloadAsync from download.download_service import downloadAsync
from model.video import Video from model.video import Video
from notify.notify_clients import notifyClients
from stream.stream_service import streamAndSave from stream.stream_service import streamAndSave
router = APIRouter(prefix="/videos", tags=["videos"]) router = APIRouter(prefix="/videos")
@router.post("", status_code=204)
async def create(videoData: VideoCreate, db: Session = Depends(getDb)):
title = videoData.title
youtuber = videoData.youtuber
thumbnailUrl = videoData.thumbnailUrl
youtubeUrl = videoData.youtubeUrl
profileId = videoData.profileId
Video.deleteIfExists(db, youtubeUrl, profileId)
Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
await notifyClients(profileId)
@router.get("", response_model=list[VideoResponse])
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)):
return Video.getDownloaded(db, profileId=profileId)
@router.post("/cleanup")
def cleanup(request: CleanupRequest, db: Session = Depends(getDb)):
profileId = request.profileId
excludeIds = request.excludeIds
count = Video.deleteNotDownloaded(db, profileId, excludeIds)
return {"deleted": count}
@router.post("/{videoId}/download") @router.post("/{videoId}/download")
def download(videoId: int, db: Session = Depends(getDb)): def download(videoId: int, db: DbSession):
video = Video.getById(db, videoId) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(404, "Video nicht gefunden") raise HTTPException(404, "Video nicht gefunden")
@@ -60,7 +24,7 @@ def download(videoId: int, db: Session = Depends(getDb)):
@router.get("/{videoId}/stream") @router.get("/{videoId}/stream")
def stream(videoId: int, db: Session = Depends(getDb)): def stream(videoId: int, db: DbSession):
video = Video.getById(db, videoId) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(404, "Video nicht gefunden") raise HTTPException(404, "Video nicht gefunden")
@@ -75,7 +39,7 @@ def stream(videoId: int, db: Session = Depends(getDb)):
@router.get("/{videoId}/file") @router.get("/{videoId}/file")
def getFile(videoId: int, db: Session = Depends(getDb)): def getFile(videoId: int, db: DbSession):
path, video = Video.getValidPath(db, videoId) path, video = Video.getValidPath(db, videoId)
if not path: if not path:
raise HTTPException(404, "Video noch nicht heruntergeladen") raise HTTPException(404, "Video noch nicht heruntergeladen")
@@ -83,7 +47,7 @@ def getFile(videoId: int, db: Session = Depends(getDb)):
@router.delete("/{videoId}/file") @router.delete("/{videoId}/file")
def deleteFile(videoId: int, db: Session = Depends(getDb)): def deleteFile(videoId: int, db: DbSession):
if not Video.deleteServerFile(db, videoId): if not Video.deleteServerFile(db, videoId):
raise HTTPException(404, "Video nicht gefunden") raise HTTPException(404, "Video nicht gefunden")
return {"status": "deleted"} return {"status": "deleted"}

View File

@@ -1,5 +1,8 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import Session, sessionmaker, declarative_base
DATABASE_URL = "sqlite:///videos/youtubeapp.db" DATABASE_URL = "sqlite:///videos/youtubeapp.db"
@@ -18,3 +21,6 @@ def getDb():
yield db yield db
finally: finally:
db.close() db.close()
DbSession = Annotated[Session, Depends(getDb)]

View File

@@ -11,5 +11,6 @@ class Profile(Base):
name = Column(String, nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
@classmethod @classmethod
def getAll(cls, db: Session) -> list["Profile"]: def getAll(cls, db: Session) -> list[dict]:
return db.query(cls).all() profiles = db.query(cls).all()
return [{"id": p.id, "name": p.name} for p in profiles]

View File

@@ -72,13 +72,6 @@ class Video(Base):
query = query.filter(cls.profiles.any(Profile.id == profileId)) query = query.filter(cls.profiles.any(Profile.id == profileId))
return query.order_by(cls.id.desc()).all() return query.order_by(cls.id.desc()).all()
@classmethod
def getDownloaded(cls, db: Session, profileId: int | None = None) -> list["Video"]:
query = db.query(cls).filter(cls.filePath.isnot(None))
if profileId:
query = query.filter(cls.profiles.any(Profile.id == profileId))
return query.order_by(cls.id.desc()).all()
@classmethod @classmethod
def getById(cls, db: Session, videoId: int) -> "Video | None": def getById(cls, db: Session, videoId: int) -> "Video | None":
return db.query(cls).filter(cls.id == videoId).first() return db.query(cls).filter(cls.id == videoId).first()

View File

@@ -1,7 +1,7 @@
const SERVER_URL = "http://marha.local:8000/videos"; const SERVER_BASE = "http://marha.local:8000";
browser.runtime.onMessage.addListener((video) => { browser.runtime.onMessage.addListener(({ profileId, video }) => {
fetch(SERVER_URL, { fetch(`${SERVER_BASE}/profiles/${profileId}/videos`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(video), body: JSON.stringify(video),

View File

@@ -25,10 +25,9 @@ function extractVideoFromCard(element) {
async function sendVideo(video) { async function sendVideo(video) {
const stored = await browser.storage.local.get("profileId"); const stored = await browser.storage.local.get("profileId");
const profileId = stored.profileId || null; const profileId = stored.profileId || 1;
const payload = { ...video, profileId }; console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, video.title);
console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, payload.title); browser.runtime.sendMessage({ profileId, video });
browser.runtime.sendMessage(payload);
} }
// --- IntersectionObserver: nur sichtbare Cards erfassen --- // --- IntersectionObserver: nur sichtbare Cards erfassen ---

6
structure.md Normal file
View File

@@ -0,0 +1,6 @@
# Backend
Profile(id, name) - Profiledata
Video(id, title, youtuber, imgUrl, videoUrl, profile) - Videodata for profiles
# App
Profile(id, name) - Profiledata, sync with backend
Video(id, title, youtuber, imgPath, videoPath, profile) - Videodata for downloaded videos