update
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
6
structure.md
Normal 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
|
||||||
Reference in New Issue
Block a user