update
This commit is contained in:
@@ -4,10 +4,10 @@ data class Video(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val youtuber: String,
|
val youtuber: String,
|
||||||
val thumbnail_url: String,
|
val thumbnailUrl: String,
|
||||||
val youtube_url: String,
|
val youtubeUrl: String,
|
||||||
val is_downloaded: Boolean,
|
val isDownloaded: Boolean,
|
||||||
val profile_ids: List<Int> = emptyList()
|
val profileIds: List<Int> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Profile(
|
data class Profile(
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import retrofit2.http.Query
|
|||||||
|
|
||||||
interface VideoApi {
|
interface VideoApi {
|
||||||
@GET("videos")
|
@GET("videos")
|
||||||
suspend fun getAllVideos(@Query("profile_id") profileId: Int? = null): List<Video>
|
suspend fun getAllVideos(@Query("profileId") profileId: Int? = null): List<Video>
|
||||||
|
|
||||||
@GET("videos/downloaded")
|
@GET("videos/downloaded")
|
||||||
suspend fun getDownloadedVideos(@Query("profile_id") profileId: Int? = null): List<Video>
|
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>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ 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("profile_id" to profileId, "exclude_ids" to excludeIds)
|
val body = mapOf("profileId" to profileId, "excludeIds" to excludeIds)
|
||||||
val response = api.cleanupVideos(body)
|
val response = api.cleanupVideos(body)
|
||||||
response["deleted"] ?: 0
|
response["deleted"] ?: 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ fun VideoCard(video: Video, onClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = video.thumbnail_url,
|
model = video.thumbnailUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ fun VideoDetailScreen(
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = video.thumbnail_url,
|
model = video.thumbnailUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -121,7 +121,7 @@ private fun VideoInfo(
|
|||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
isLocal -> "Lokal gespeichert"
|
isLocal -> "Lokal gespeichert"
|
||||||
video.is_downloaded -> "Auf Server heruntergeladen"
|
video.isDownloaded -> "Auf Server heruntergeladen"
|
||||||
else -> "Noch nicht heruntergeladen"
|
else -> "Noch nicht heruntergeladen"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class VideoViewModel : ViewModel() {
|
|||||||
delay(2000)
|
delay(2000)
|
||||||
val videosResult = repository.getAllVideos()
|
val videosResult = repository.getAllVideos()
|
||||||
val video = videosResult.getOrNull()?.find { it.id == videoId }
|
val video = videosResult.getOrNull()?.find { it.id == videoId }
|
||||||
if (video?.is_downloaded == true) break
|
if (video?.isDownloaded == true) break
|
||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
if (attempts >= 150) throw Exception("Download fehlgeschlagen")
|
if (attempts >= 150) throw Exception("Download fehlgeschlagen")
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from api.schemas import ProfileResponse
|
from api.schemas import ProfileResponse
|
||||||
from database.database import get_db
|
from database.database import getDb
|
||||||
from model.profile import Profile
|
from model.profile import Profile
|
||||||
|
|
||||||
router = APIRouter(prefix="/profiles", tags=["profiles"])
|
router = APIRouter(prefix="/profiles", tags=["profiles"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProfileResponse])
|
@router.get("", response_model=list[ProfileResponse])
|
||||||
def get_profiles(db: Session = Depends(get_db)):
|
def getAll(db: Session = Depends(getDb)):
|
||||||
return Profile.get_all(db)
|
return Profile.getAll(db)
|
||||||
|
|||||||
@@ -1,47 +1,45 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
class VideoCreate(BaseModel):
|
class VideoCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
youtuber: str
|
youtuber: str
|
||||||
thumbnail_url: str
|
thumbnailUrl: str
|
||||||
youtube_url: str
|
youtubeUrl: str
|
||||||
profile_id: int | None = None
|
profileId: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class VideoResponse(BaseModel):
|
class VideoResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
youtuber: str
|
youtuber: str
|
||||||
thumbnail_url: str
|
thumbnailUrl: str
|
||||||
youtube_url: str
|
youtubeUrl: str
|
||||||
is_downloaded: bool
|
isDownloaded: bool
|
||||||
profile_ids: list[int]
|
profileIds: list[int]
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_model(cls, video):
|
def fromModel(cls, video):
|
||||||
return cls(
|
return cls(
|
||||||
id=video.id,
|
id=video.id,
|
||||||
title=video.title,
|
title=video.title,
|
||||||
youtuber=video.youtuber,
|
youtuber=video.youtuber,
|
||||||
thumbnail_url=video.thumbnail_url,
|
thumbnailUrl=video.thumbnailUrl,
|
||||||
youtube_url=video.youtube_url,
|
youtubeUrl=video.youtubeUrl,
|
||||||
is_downloaded=video.file_path is not None,
|
isDownloaded=video.filePath is not None,
|
||||||
profile_ids=[p.id for p in video.profiles],
|
profileIds=[p.id for p in video.profiles],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CleanupRequest(BaseModel):
|
class CleanupRequest(BaseModel):
|
||||||
profile_id: int
|
profileId: int
|
||||||
exclude_ids: list[int] = []
|
excludeIds: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class ProfileResponse(BaseModel):
|
class ProfileResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|||||||
@@ -7,115 +7,115 @@ from fastapi.responses import FileResponse, StreamingResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from api.schemas import CleanupRequest, VideoCreate, VideoResponse
|
from api.schemas import CleanupRequest, VideoCreate, VideoResponse
|
||||||
from database.database import SessionLocal, get_db
|
from database.database import SessionLocal, getDb
|
||||||
from download.download_service import download_video
|
from download.download_service import downloadVideo
|
||||||
from model.video import Video
|
from model.video import Video
|
||||||
from notify.notify_clients import notify_clients
|
from notify.notify_clients import notifyClients
|
||||||
from stream.stream_service import stream_video_live
|
from stream.stream_service import streamVideoLive
|
||||||
|
|
||||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=list[VideoResponse])
|
@router.post("", status_code=204)
|
||||||
async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)):
|
async def create(videoData: VideoCreate, db: Session = Depends(getDb)):
|
||||||
created_ids = []
|
title = videoData.title
|
||||||
profile_ids = set()
|
youtuber = videoData.youtuber
|
||||||
for video_data in reversed(videos_data):
|
thumbnailUrl = videoData.thumbnailUrl
|
||||||
video_id_match = video_data.youtube_url.split("v=")[-1].split("&")[0]
|
youtubeUrl = videoData.youtubeUrl
|
||||||
Video.delete_by_youtube_id(db, video_id_match)
|
profileId = videoData.profileId
|
||||||
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.get_by_id(db, vid) for vid in created_ids]
|
|
||||||
|
|
||||||
if profile_ids:
|
Video.deleteIfExists(db, youtubeUrl, profileId)
|
||||||
await notify_clients(list(profile_ids))
|
Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
|
||||||
|
await notifyClients(profileId)
|
||||||
return [VideoResponse.from_model(v) for v in videos if v]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[VideoResponse])
|
@router.get("", response_model=list[VideoResponse])
|
||||||
def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
|
def getAll(
|
||||||
videos = Video.get_all(db, profile_id=profile_id)
|
profileId: Optional[int] = Query(None),
|
||||||
return [VideoResponse.from_model(v) for v in videos]
|
db: Session = Depends(getDb),
|
||||||
|
):
|
||||||
|
videos = Video.getAll(db, profileId=profileId)
|
||||||
|
return [VideoResponse.fromModel(v) for v in videos]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/downloaded", response_model=list[VideoResponse])
|
@router.get("/downloaded", response_model=list[VideoResponse])
|
||||||
def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
|
def getDownloaded(
|
||||||
videos = Video.get_downloaded(db, profile_id=profile_id)
|
profileId: Optional[int] = Query(None),
|
||||||
return [VideoResponse.from_model(v) for v in videos]
|
db: Session = Depends(getDb),
|
||||||
|
):
|
||||||
|
videos = Video.getDownloaded(db, profileId=profileId)
|
||||||
|
return [VideoResponse.fromModel(v) for v in videos]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup")
|
@router.post("/cleanup")
|
||||||
def cleanup_videos(request: CleanupRequest, db: Session = Depends(get_db)):
|
def cleanup(request: CleanupRequest, db: Session = Depends(getDb)):
|
||||||
count = Video.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None)
|
count = Video.deleteNotDownloaded(db, request.profileId, request.excludeIds or None)
|
||||||
return {"deleted": count}
|
return {"deleted": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_id}/download")
|
@router.post("/{videoId}/download")
|
||||||
def trigger_download(video_id: int, db: Session = Depends(get_db)):
|
def download(videoId: int, db: Session = Depends(getDb)):
|
||||||
video = Video.get_by_id(db, video_id)
|
video = Video.getById(db, videoId)
|
||||||
if not video:
|
if not video:
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
||||||
if video.file_path:
|
if video.filePath:
|
||||||
return {"status": "already_downloaded"}
|
return {"status": "already_downloaded"}
|
||||||
|
|
||||||
thread = threading.Thread(target=download_video, args=(video.id, video.youtube_url))
|
thread = threading.Thread(target=downloadVideo, args=(video.id, video.youtubeUrl))
|
||||||
thread.start()
|
thread.start()
|
||||||
return {"status": "download_started"}
|
return {"status": "download_started"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{video_id}/stream")
|
@router.get("/{videoId}/stream")
|
||||||
def stream_video(video_id: int, db: Session = Depends(get_db)):
|
def stream(videoId: int, db: Session = Depends(getDb)):
|
||||||
video = Video.get_by_id(db, video_id)
|
video = Video.getById(db, videoId)
|
||||||
if not video:
|
if not video:
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
||||||
|
|
||||||
if not video.file_path:
|
if not video.filePath:
|
||||||
def stream_and_save():
|
def streamAndSave():
|
||||||
output_path = f"/videos/{video_id}.mp4"
|
outputPath = f"/videos/{videoId}.mp4"
|
||||||
yield from stream_video_live(video_id, video.youtube_url)
|
yield from streamVideoLive(videoId, video.youtubeUrl)
|
||||||
if Path(output_path).exists():
|
if Path(outputPath).exists():
|
||||||
sdb = SessionLocal()
|
sdb = SessionLocal()
|
||||||
try:
|
try:
|
||||||
Video.update_file_path(sdb, video_id, output_path)
|
Video.updateFilePath(sdb, videoId, outputPath)
|
||||||
finally:
|
finally:
|
||||||
sdb.close()
|
sdb.close()
|
||||||
|
|
||||||
return StreamingResponse(stream_and_save(), media_type="video/mp4")
|
return StreamingResponse(streamAndSave(), media_type="video/mp4")
|
||||||
|
|
||||||
path = Path(video.file_path)
|
path = Path(video.filePath)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
|
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
|
||||||
|
|
||||||
return FileResponse(path, media_type="video/mp4")
|
return FileResponse(path, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{video_id}/file")
|
@router.get("/{videoId}/file")
|
||||||
def download_file(video_id: int, db: Session = Depends(get_db)):
|
def getFile(videoId: int, db: Session = Depends(getDb)):
|
||||||
video = Video.get_by_id(db, video_id)
|
video = Video.getById(db, videoId)
|
||||||
if not video:
|
if not video:
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
||||||
if not video.file_path:
|
if not video.filePath:
|
||||||
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
||||||
|
|
||||||
path = Path(video.file_path)
|
path = Path(video.filePath)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
Video.update_file_path(db, video_id, None)
|
Video.updateFilePath(db, videoId, None)
|
||||||
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
||||||
|
|
||||||
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
|
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{video_id}/file")
|
@router.delete("/{videoId}/file")
|
||||||
def delete_server_file(video_id: int, db: Session = Depends(get_db)):
|
def deleteFile(videoId: int, db: Session = Depends(getDb)):
|
||||||
video = Video.get_by_id(db, video_id)
|
video = Video.getById(db, videoId)
|
||||||
if not video:
|
if not video:
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
||||||
if video.file_path:
|
if video.filePath:
|
||||||
path = Path(video.file_path)
|
path = Path(video.filePath)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
Video.update_file_path(db, video_id, None)
|
Video.updateFilePath(db, videoId, None)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from api.profile_controller import router as profiles_router
|
from api.profile_controller import router as profilesRouter
|
||||||
from api.video_controller import router as videos_router
|
from api.video_controller import router as videosRouter
|
||||||
from database.database import SessionLocal, create_tables
|
from database.database import SessionLocal, createTables
|
||||||
from model.profile import Profile
|
from model.profile import Profile
|
||||||
from notify.notify_clients import register_websocket
|
from notify.notify_clients import registerWebsocket
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(videos_router)
|
app.include_router(videosRouter)
|
||||||
app.include_router(profiles_router)
|
app.include_router(profilesRouter)
|
||||||
register_websocket(app)
|
registerWebsocket(app)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
create_tables()
|
createTables()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
if db.query(Profile).count() == 0:
|
if db.query(Profile).count() == 0:
|
||||||
db.add(Profile(name="Standard"))
|
db.add(Profile(name="Standard"))
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ SessionLocal = sessionmaker(bind=engine)
|
|||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def create_tables():
|
def createTables():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def getDb():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
|
|||||||
@@ -8,28 +8,28 @@ VIDEOS_DIR = "/videos"
|
|||||||
MIN_VALID_SIZE = 1024 * 100 # 100 KB
|
MIN_VALID_SIZE = 1024 * 100 # 100 KB
|
||||||
|
|
||||||
|
|
||||||
def download_video(video_id: int, youtube_url: str):
|
def downloadVideo(videoId: int, youtubeUrl: str):
|
||||||
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
|
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
||||||
"-o", output_path,
|
"-o", outputPath,
|
||||||
"--merge-output-format", "mp4",
|
"--merge-output-format", "mp4",
|
||||||
"--force-overwrites",
|
"--force-overwrites",
|
||||||
"--no-continue",
|
"--no-continue",
|
||||||
youtube_url,
|
youtubeUrl,
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
path = Path(output_path)
|
path = Path(outputPath)
|
||||||
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
|
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
|
||||||
return
|
return
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
Video.update_file_path(db, video_id, output_path)
|
Video.updateFilePath(db, videoId, outputPath)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ class Profile(Base):
|
|||||||
name = Column(String, nullable=False, unique=True)
|
name = Column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls, db: Session) -> list["Profile"]:
|
def getAll(cls, db: Session) -> list["Profile"]:
|
||||||
return db.query(cls).all()
|
return db.query(cls).all()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from sqlalchemy import Column, ForeignKey, Integer, Table
|
|||||||
|
|
||||||
from database.database import Base
|
from database.database import Base
|
||||||
|
|
||||||
video_profiles = Table(
|
videoProfiles = Table(
|
||||||
"video_profiles",
|
"video_profiles",
|
||||||
Base.metadata,
|
Base.metadata,
|
||||||
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
|
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session, relationship
|
|||||||
|
|
||||||
from database.database import Base
|
from database.database import Base
|
||||||
from model.profile import Profile
|
from model.profile import Profile
|
||||||
from model.profile_video import video_profiles
|
from model.profile_video import videoProfiles
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base):
|
||||||
@@ -12,17 +12,42 @@ class Video(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
title = Column(String, nullable=False)
|
title = Column(String, nullable=False)
|
||||||
youtuber = Column(String, nullable=False)
|
youtuber = Column(String, nullable=False)
|
||||||
thumbnail_url = Column(String, nullable=False)
|
thumbnailUrl = Column("thumbnail_url", String, nullable=False)
|
||||||
youtube_url = Column(String, nullable=False)
|
youtubeUrl = Column("youtube_url", String, nullable=False)
|
||||||
file_path = Column(String, nullable=True)
|
filePath = Column("file_path", String, nullable=True)
|
||||||
profiles = relationship("Profile", secondary=video_profiles, backref="videos")
|
profiles = relationship("Profile", secondary=videoProfiles, backref="videos")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_dict(cls, db: Session, data: dict, profile_id: int | None) -> "Video":
|
def deleteIfExists(cls, db: Session, youtubeUrl: str, profileId: int | None):
|
||||||
video = cls(**data)
|
if not profileId:
|
||||||
if not profile_id:
|
profileId = 1
|
||||||
profile_id = 1
|
videos = db.query(cls).filter(
|
||||||
profile = db.query(Profile).filter(Profile.id == profile_id).first()
|
cls.youtubeUrl == youtubeUrl,
|
||||||
|
cls.profiles.any(Profile.id == profileId),
|
||||||
|
).all()
|
||||||
|
for video in videos:
|
||||||
|
db.delete(video)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
title: str,
|
||||||
|
youtuber: str,
|
||||||
|
thumbnailUrl: str,
|
||||||
|
youtubeUrl: str,
|
||||||
|
profileId: int | None,
|
||||||
|
) -> "Video":
|
||||||
|
if not profileId:
|
||||||
|
profileId = 1
|
||||||
|
video = cls(
|
||||||
|
title=title,
|
||||||
|
youtuber=youtuber,
|
||||||
|
thumbnailUrl=thumbnailUrl,
|
||||||
|
youtubeUrl=youtubeUrl,
|
||||||
|
)
|
||||||
|
profile = db.query(Profile).filter(Profile.id == profileId).first()
|
||||||
if profile:
|
if profile:
|
||||||
video.profiles.append(profile)
|
video.profiles.append(profile)
|
||||||
db.add(video)
|
db.add(video)
|
||||||
@@ -31,47 +56,42 @@ class Video(Base):
|
|||||||
return video
|
return video
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls, db: Session, profile_id: int | None = None) -> list["Video"]:
|
def getAll(cls, db: Session, profileId: int | None = None) -> list["Video"]:
|
||||||
query = db.query(cls)
|
query = db.query(cls)
|
||||||
if profile_id:
|
if profileId:
|
||||||
query = query.filter(cls.profiles.any(Profile.id == profile_id))
|
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
|
@classmethod
|
||||||
def get_downloaded(cls, db: Session, profile_id: int | None = None) -> list["Video"]:
|
def getDownloaded(cls, db: Session, profileId: int | None = None) -> list["Video"]:
|
||||||
query = db.query(cls).filter(cls.file_path.isnot(None))
|
query = db.query(cls).filter(cls.filePath.isnot(None))
|
||||||
if profile_id:
|
if profileId:
|
||||||
query = query.filter(cls.profiles.any(Profile.id == profile_id))
|
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
|
@classmethod
|
||||||
def get_by_id(cls, db: Session, video_id: int) -> "Video | None":
|
def getById(cls, db: Session, videoId: int) -> "Video | None":
|
||||||
return db.query(cls).filter(cls.id == video_id).first()
|
return db.query(cls).filter(cls.id == videoId).first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def delete_by_youtube_id(cls, db: Session, youtube_id: str):
|
def updateFilePath(cls, db: Session, videoId: int, path: str | None):
|
||||||
db.query(cls).filter(cls.youtube_url.contains(youtube_id)).delete(synchronize_session=False)
|
video = cls.getById(db, videoId)
|
||||||
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:
|
if video:
|
||||||
video.file_path = path
|
video.filePath = path
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def delete_not_downloaded(cls, db: Session, profile_id: int, exclude_ids: list[int] | None = None) -> int:
|
def deleteNotDownloaded(cls, db: Session, profileId: int, excludeIds: list[int] | None = None) -> int:
|
||||||
query = db.query(cls).filter(
|
query = db.query(cls).filter(
|
||||||
cls.profiles.any(Profile.id == profile_id),
|
cls.profiles.any(Profile.id == profileId),
|
||||||
)
|
)
|
||||||
if exclude_ids:
|
if excludeIds:
|
||||||
query = query.filter(cls.id.notin_(exclude_ids))
|
query = query.filter(cls.id.notin_(excludeIds))
|
||||||
videos = query.all()
|
videos = query.all()
|
||||||
video_ids = [v.id for v in videos]
|
videoIds = [v.id for v in videos]
|
||||||
if not video_ids:
|
if not videoIds:
|
||||||
return 0
|
return 0
|
||||||
db.execute(video_profiles.delete().where(video_profiles.c.video_id.in_(video_ids)))
|
db.execute(videoProfiles.delete().where(videoProfiles.c.video_id.in_(videoIds)))
|
||||||
db.query(cls).filter(cls.id.in_(video_ids)).delete(synchronize_session=False)
|
db.query(cls).filter(cls.id.in_(videoIds)).delete(synchronize_session=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(video_ids)
|
return len(videoIds)
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
connected_clients: set[WebSocket] = set()
|
connectedClients: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
async def notify_clients(profile_ids: list[int]):
|
async def notifyClients(profileId: int | None):
|
||||||
message = ",".join(str(pid) for pid in profile_ids)
|
if not profileId:
|
||||||
for client in list(connected_clients):
|
profileId = 1
|
||||||
|
message = str(profileId)
|
||||||
|
for client in list(connectedClients):
|
||||||
try:
|
try:
|
||||||
await client.send_text(message)
|
await client.send_text(message)
|
||||||
except Exception:
|
except Exception:
|
||||||
connected_clients.discard(client)
|
connectedClients.discard(client)
|
||||||
|
|
||||||
|
|
||||||
def register_websocket(app: FastAPI):
|
def registerWebsocket(app: FastAPI):
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocketEndpoint(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
connected_clients.add(websocket)
|
connectedClients.add(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
connected_clients.discard(websocket)
|
connectedClients.discard(websocket)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ VIDEOS_DIR = "/videos"
|
|||||||
CHUNK_SIZE = 64 * 1024
|
CHUNK_SIZE = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _get_stream_urls(youtube_url: str):
|
def _getStreamUrls(youtubeUrl: str):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
||||||
"--print", "urls",
|
"--print", "urls",
|
||||||
youtube_url,
|
youtubeUrl,
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
)
|
)
|
||||||
@@ -26,24 +26,24 @@ def _get_stream_urls(youtube_url: str):
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def stream_video_live(video_id: int, youtube_url: str):
|
def streamVideoLive(videoId: int, youtubeUrl: str):
|
||||||
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
|
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
|
||||||
|
|
||||||
video_url, audio_url = _get_stream_urls(youtube_url)
|
videoUrl, audioUrl = _getStreamUrls(youtubeUrl)
|
||||||
if not video_url:
|
if not videoUrl:
|
||||||
return
|
return
|
||||||
|
|
||||||
if audio_url:
|
if audioUrl:
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", video_url,
|
"-i", videoUrl,
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", audio_url,
|
"-i", audioUrl,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
@@ -56,7 +56,7 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", video_url,
|
"-i", videoUrl,
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-f", "mp4",
|
"-f", "mp4",
|
||||||
@@ -70,7 +70,7 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(output_path, "wb") as f:
|
with open(outputPath, "wb") as f:
|
||||||
while True:
|
while True:
|
||||||
chunk = process.stdout.read(CHUNK_SIZE)
|
chunk = process.stdout.read(CHUNK_SIZE)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -86,6 +86,6 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
if process.stdout:
|
if process.stdout:
|
||||||
process.stdout.close()
|
process.stdout.close()
|
||||||
|
|
||||||
path = Path(output_path)
|
path = Path(outputPath)
|
||||||
if process.returncode != 0 and path.exists():
|
if process.returncode != 0 and path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const SERVER_URL = "http://marha.local:8000/videos";
|
const SERVER_URL = "http://marha.local:8000/videos";
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((videos) => {
|
browser.runtime.onMessage.addListener((video) => {
|
||||||
fetch(SERVER_URL, {
|
fetch(SERVER_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(videos),
|
body: JSON.stringify(video),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,51 +18,29 @@ function extractVideoFromCard(element) {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
youtuber,
|
youtuber,
|
||||||
thumbnail_url: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
|
thumbnailUrl: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
|
||||||
youtube_url: `https://www.youtube.com/watch?v=${match[1]}`,
|
youtubeUrl: `https://www.youtube.com/watch?v=${match[1]}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectVideos(elements) {
|
async function sendVideo(video) {
|
||||||
const videos = [];
|
const stored = await browser.storage.local.get("profileId");
|
||||||
for (const el of elements) {
|
const profileId = stored.profileId || null;
|
||||||
const video = extractVideoFromCard(el);
|
const payload = { ...video, profileId };
|
||||||
if (!video) continue;
|
console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, payload.title);
|
||||||
if (sentUrls.has(video.youtube_url)) continue;
|
browser.runtime.sendMessage(payload);
|
||||||
sentUrls.add(video.youtube_url);
|
|
||||||
videos.push(video);
|
|
||||||
}
|
|
||||||
return videos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Debounced Batch-Versand ---
|
|
||||||
|
|
||||||
let pendingVideos = [];
|
|
||||||
let sendTimer = null;
|
|
||||||
|
|
||||||
async function queueVideos(videos) {
|
|
||||||
pendingVideos.push(...videos);
|
|
||||||
if (!sendTimer) {
|
|
||||||
sendTimer = setTimeout(async () => {
|
|
||||||
if (pendingVideos.length > 0) {
|
|
||||||
const stored = await browser.storage.local.get("profileId");
|
|
||||||
const profileId = stored.profileId || null;
|
|
||||||
const batch = pendingVideos.map((v) => ({ ...v, profile_id: profileId }));
|
|
||||||
console.log(`[YT-Erfasser] ${batch.length} Videos senden (Profil: ${profileId})`);
|
|
||||||
browser.runtime.sendMessage(batch);
|
|
||||||
}
|
|
||||||
pendingVideos = [];
|
|
||||||
sendTimer = null;
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IntersectionObserver: nur sichtbare Cards erfassen ---
|
// --- IntersectionObserver: nur sichtbare Cards erfassen ---
|
||||||
|
|
||||||
const visibilityObserver = new IntersectionObserver((entries) => {
|
const visibilityObserver = new IntersectionObserver((entries) => {
|
||||||
const cards = entries.filter((e) => e.isIntersecting).map((e) => e.target);
|
for (const entry of entries) {
|
||||||
if (cards.length > 0) {
|
if (!entry.isIntersecting) continue;
|
||||||
queueVideos(collectVideos(cards));
|
const video = extractVideoFromCard(entry.target);
|
||||||
|
if (!video) continue;
|
||||||
|
if (sentUrls.has(video.youtubeUrl)) continue;
|
||||||
|
sentUrls.add(video.youtubeUrl);
|
||||||
|
sendVideo(video);
|
||||||
}
|
}
|
||||||
}, { threshold: 0.5 });
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user