This commit is contained in:
Marek Lenczewski
2026-04-07 17:55:30 +02:00
parent 8f15f51bce
commit ca988345e9
19 changed files with 201 additions and 203 deletions

View File

@@ -4,10 +4,10 @@ data class Video(
val id: Int,
val title: String,
val youtuber: String,
val thumbnail_url: String,
val youtube_url: String,
val is_downloaded: Boolean,
val profile_ids: List<Int> = emptyList()
val thumbnailUrl: String,
val youtubeUrl: String,
val isDownloaded: Boolean,
val profileIds: List<Int> = emptyList()
)
data class Profile(

View File

@@ -7,10 +7,10 @@ import retrofit2.http.Query
interface VideoApi {
@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")
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")
suspend fun triggerDownload(@Path("id") id: Int): Map<String, String>

View File

@@ -19,7 +19,7 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
}
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)
response["deleted"] ?: 0
}

View File

@@ -40,7 +40,7 @@ fun VideoCard(video: Video, onClick: () -> Unit) {
) {
Column {
AsyncImage(
model = video.thumbnail_url,
model = video.thumbnailUrl,
contentDescription = video.title,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@@ -83,7 +83,7 @@ fun VideoDetailScreen(
Column(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = video.thumbnail_url,
model = video.thumbnailUrl,
contentDescription = video.title,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -121,7 +121,7 @@ private fun VideoInfo(
Text(
text = when {
isLocal -> "Lokal gespeichert"
video.is_downloaded -> "Auf Server heruntergeladen"
video.isDownloaded -> "Auf Server heruntergeladen"
else -> "Noch nicht heruntergeladen"
},
style = MaterialTheme.typography.bodySmall,

View File

@@ -140,7 +140,7 @@ class VideoViewModel : ViewModel() {
delay(2000)
val videosResult = repository.getAllVideos()
val video = videosResult.getOrNull()?.find { it.id == videoId }
if (video?.is_downloaded == true) break
if (video?.isDownloaded == true) break
attempts++
}
if (attempts >= 150) throw Exception("Download fehlgeschlagen")

View File

@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from api.schemas import ProfileResponse
from database.database import get_db
from database.database import getDb
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)
def getAll(db: Session = Depends(getDb)):
return Profile.getAll(db)

View File

@@ -1,47 +1,45 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class VideoCreate(BaseModel):
title: str
youtuber: str
thumbnail_url: str
youtube_url: str
profile_id: int | None = None
thumbnailUrl: str
youtubeUrl: str
profileId: int | None = None
class VideoResponse(BaseModel):
id: int
title: str
youtuber: str
thumbnail_url: str
youtube_url: str
is_downloaded: bool
profile_ids: list[int]
thumbnailUrl: str
youtubeUrl: str
isDownloaded: bool
profileIds: list[int]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_model(cls, video):
def fromModel(cls, video):
return cls(
id=video.id,
title=video.title,
youtuber=video.youtuber,
thumbnail_url=video.thumbnail_url,
youtube_url=video.youtube_url,
is_downloaded=video.file_path is not None,
profile_ids=[p.id for p in video.profiles],
thumbnailUrl=video.thumbnailUrl,
youtubeUrl=video.youtubeUrl,
isDownloaded=video.filePath is not None,
profileIds=[p.id for p in video.profiles],
)
class CleanupRequest(BaseModel):
profile_id: int
exclude_ids: list[int] = []
profileId: int
excludeIds: list[int] = []
class ProfileResponse(BaseModel):
id: int
name: str
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -7,115 +7,115 @@ from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from api.schemas import CleanupRequest, VideoCreate, VideoResponse
from database.database import SessionLocal, get_db
from download.download_service import download_video
from database.database import SessionLocal, getDb
from download.download_service import downloadVideo
from model.video import Video
from notify.notify_clients import notify_clients
from stream.stream_service import stream_video_live
from notify.notify_clients import notifyClients
from stream.stream_service import streamVideoLive
router = APIRouter(prefix="/videos", tags=["videos"])
@router.post("", response_model=list[VideoResponse])
async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)):
created_ids = []
profile_ids = set()
for video_data in reversed(videos_data):
video_id_match = video_data.youtube_url.split("v=")[-1].split("&")[0]
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.get_by_id(db, vid) for vid in created_ids]
@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
if profile_ids:
await notify_clients(list(profile_ids))
return [VideoResponse.from_model(v) for v in videos if v]
Video.deleteIfExists(db, youtubeUrl, profileId)
Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
await notifyClients(profileId)
@router.get("", response_model=list[VideoResponse])
def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = Video.get_all(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos]
def getAll(
profileId: Optional[int] = Query(None),
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])
def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = Video.get_downloaded(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos]
def getDownloaded(
profileId: Optional[int] = Query(None),
db: Session = Depends(getDb),
):
videos = Video.getDownloaded(db, profileId=profileId)
return [VideoResponse.fromModel(v) for v in videos]
@router.post("/cleanup")
def cleanup_videos(request: CleanupRequest, db: Session = Depends(get_db)):
count = Video.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None)
def cleanup(request: CleanupRequest, db: Session = Depends(getDb)):
count = Video.deleteNotDownloaded(db, request.profileId, request.excludeIds or None)
return {"deleted": count}
@router.post("/{video_id}/download")
def trigger_download(video_id: int, db: Session = Depends(get_db)):
video = Video.get_by_id(db, video_id)
@router.post("/{videoId}/download")
def download(videoId: int, db: Session = Depends(getDb)):
video = Video.getById(db, videoId)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if video.file_path:
if video.filePath:
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()
return {"status": "download_started"}
@router.get("/{video_id}/stream")
def stream_video(video_id: int, db: Session = Depends(get_db)):
video = Video.get_by_id(db, video_id)
@router.get("/{videoId}/stream")
def stream(videoId: int, db: Session = Depends(getDb)):
video = Video.getById(db, videoId)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if not video.file_path:
def stream_and_save():
output_path = f"/videos/{video_id}.mp4"
yield from stream_video_live(video_id, video.youtube_url)
if Path(output_path).exists():
if not video.filePath:
def streamAndSave():
outputPath = f"/videos/{videoId}.mp4"
yield from streamVideoLive(videoId, video.youtubeUrl)
if Path(outputPath).exists():
sdb = SessionLocal()
try:
Video.update_file_path(sdb, video_id, output_path)
Video.updateFilePath(sdb, videoId, outputPath)
finally:
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():
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
return FileResponse(path, media_type="video/mp4")
@router.get("/{video_id}/file")
def download_file(video_id: int, db: Session = Depends(get_db)):
video = Video.get_by_id(db, video_id)
@router.get("/{videoId}/file")
def getFile(videoId: int, db: Session = Depends(getDb)):
video = Video.getById(db, videoId)
if not video:
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")
path = Path(video.file_path)
path = Path(video.filePath)
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")
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
@router.delete("/{video_id}/file")
def delete_server_file(video_id: int, db: Session = Depends(get_db)):
video = Video.get_by_id(db, video_id)
@router.delete("/{videoId}/file")
def deleteFile(videoId: int, db: Session = Depends(getDb)):
video = Video.getById(db, videoId)
if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden")
if video.file_path:
path = Path(video.file_path)
if video.filePath:
path = Path(video.filePath)
if path.exists():
path.unlink()
Video.update_file_path(db, video_id, None)
Video.updateFilePath(db, videoId, None)
return {"status": "deleted"}

View File

@@ -1,11 +1,11 @@
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 api.profile_controller import router as profilesRouter
from api.video_controller import router as videosRouter
from database.database import SessionLocal, createTables
from model.profile import Profile
from notify.notify_clients import register_websocket
from notify.notify_clients import registerWebsocket
app = FastAPI()
@@ -16,14 +16,14 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(videos_router)
app.include_router(profiles_router)
register_websocket(app)
app.include_router(videosRouter)
app.include_router(profilesRouter)
registerWebsocket(app)
@app.on_event("startup")
def startup():
create_tables()
createTables()
db = SessionLocal()
if db.query(Profile).count() == 0:
db.add(Profile(name="Standard"))

View File

@@ -8,11 +8,11 @@ SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
def create_tables():
def createTables():
Base.metadata.create_all(bind=engine)
def get_db():
def getDb():
db = SessionLocal()
try:
yield db

View File

@@ -8,28 +8,28 @@ VIDEOS_DIR = "/videos"
MIN_VALID_SIZE = 1024 * 100 # 100 KB
def download_video(video_id: int, youtube_url: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
def downloadVideo(videoId: int, youtubeUrl: str):
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"-o", output_path,
"-o", outputPath,
"--merge-output-format", "mp4",
"--force-overwrites",
"--no-continue",
youtube_url,
youtubeUrl,
],
check=True,
)
path = Path(output_path)
path = Path(outputPath)
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
return
db = SessionLocal()
try:
Video.update_file_path(db, video_id, output_path)
Video.updateFilePath(db, videoId, outputPath)
finally:
db.close()

View File

@@ -11,5 +11,5 @@ class Profile(Base):
name = Column(String, nullable=False, unique=True)
@classmethod
def get_all(cls, db: Session) -> list["Profile"]:
def getAll(cls, db: Session) -> list["Profile"]:
return db.query(cls).all()

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Column, ForeignKey, Integer, Table
from database.database import Base
video_profiles = Table(
videoProfiles = Table(
"video_profiles",
Base.metadata,
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),

View File

@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session, relationship
from database.database import Base
from model.profile import Profile
from model.profile_video import video_profiles
from model.profile_video import videoProfiles
class Video(Base):
@@ -12,17 +12,42 @@ class Video(Base):
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")
thumbnailUrl = Column("thumbnail_url", String, nullable=False)
youtubeUrl = Column("youtube_url", String, nullable=False)
filePath = Column("file_path", String, nullable=True)
profiles = relationship("Profile", secondary=videoProfiles, 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()
def deleteIfExists(cls, db: Session, youtubeUrl: str, profileId: int | None):
if not profileId:
profileId = 1
videos = db.query(cls).filter(
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:
video.profiles.append(profile)
db.add(video)
@@ -31,47 +56,42 @@ class Video(Base):
return video
@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)
if profile_id:
query = query.filter(cls.profiles.any(Profile.id == profile_id))
if profileId:
query = query.filter(cls.profiles.any(Profile.id == profileId))
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))
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
def get_by_id(cls, db: Session, video_id: int) -> "Video | None":
return db.query(cls).filter(cls.id == video_id).first()
def getById(cls, db: Session, videoId: int) -> "Video | None":
return db.query(cls).filter(cls.id == videoId).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)
def updateFilePath(cls, db: Session, videoId: int, path: str | None):
video = cls.getById(db, videoId)
if video:
video.file_path = path
video.filePath = path
db.commit()
@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(
cls.profiles.any(Profile.id == profile_id),
cls.profiles.any(Profile.id == profileId),
)
if exclude_ids:
query = query.filter(cls.id.notin_(exclude_ids))
if excludeIds:
query = query.filter(cls.id.notin_(excludeIds))
videos = query.all()
video_ids = [v.id for v in videos]
if not video_ids:
videoIds = [v.id for v in videos]
if not videoIds:
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.execute(videoProfiles.delete().where(videoProfiles.c.video_id.in_(videoIds)))
db.query(cls).filter(cls.id.in_(videoIds)).delete(synchronize_session=False)
db.commit()
return len(video_ids)
return len(videoIds)

View File

@@ -1,24 +1,26 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
connected_clients: set[WebSocket] = set()
connectedClients: 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):
async def notifyClients(profileId: int | None):
if not profileId:
profileId = 1
message = str(profileId)
for client in list(connectedClients):
try:
await client.send_text(message)
except Exception:
connected_clients.discard(client)
connectedClients.discard(client)
def register_websocket(app: FastAPI):
def registerWebsocket(app: FastAPI):
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
async def websocketEndpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.add(websocket)
connectedClients.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
connected_clients.discard(websocket)
connectedClients.discard(websocket)

View File

@@ -5,13 +5,13 @@ VIDEOS_DIR = "/videos"
CHUNK_SIZE = 64 * 1024
def _get_stream_urls(youtube_url: str):
def _getStreamUrls(youtubeUrl: str):
result = subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"--print", "urls",
youtube_url,
youtubeUrl,
],
capture_output=True, text=True, timeout=30,
)
@@ -26,24 +26,24 @@ def _get_stream_urls(youtube_url: str):
return None, None
def stream_video_live(video_id: int, youtube_url: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
def streamVideoLive(videoId: int, youtubeUrl: str):
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
video_url, audio_url = _get_stream_urls(youtube_url)
if not video_url:
videoUrl, audioUrl = _getStreamUrls(youtubeUrl)
if not videoUrl:
return
if audio_url:
if audioUrl:
cmd = [
"ffmpeg",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", video_url,
"-i", videoUrl,
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", audio_url,
"-i", audioUrl,
"-c:v", "copy",
"-c:a", "aac",
"-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_streamed", "1",
"-reconnect_delay_max", "5",
"-i", video_url,
"-i", videoUrl,
"-c", "copy",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4",
@@ -70,7 +70,7 @@ def stream_video_live(video_id: int, youtube_url: str):
)
try:
with open(output_path, "wb") as f:
with open(outputPath, "wb") as f:
while True:
chunk = process.stdout.read(CHUNK_SIZE)
if not chunk:
@@ -86,6 +86,6 @@ def stream_video_live(video_id: int, youtube_url: str):
if process.stdout:
process.stdout.close()
path = Path(output_path)
path = Path(outputPath)
if process.returncode != 0 and path.exists():
path.unlink()

View File

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

View File

@@ -18,51 +18,29 @@ function extractVideoFromCard(element) {
return {
title,
youtuber,
thumbnail_url: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
youtube_url: `https://www.youtube.com/watch?v=${match[1]}`,
thumbnailUrl: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
youtubeUrl: `https://www.youtube.com/watch?v=${match[1]}`,
};
}
function collectVideos(elements) {
const videos = [];
for (const el of elements) {
const video = extractVideoFromCard(el);
if (!video) continue;
if (sentUrls.has(video.youtube_url)) continue;
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);
}
async function sendVideo(video) {
const stored = await browser.storage.local.get("profileId");
const profileId = stored.profileId || null;
const payload = { ...video, profileId };
console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, payload.title);
browser.runtime.sendMessage(payload);
}
// --- IntersectionObserver: nur sichtbare Cards erfassen ---
const visibilityObserver = new IntersectionObserver((entries) => {
const cards = entries.filter((e) => e.isIntersecting).map((e) => e.target);
if (cards.length > 0) {
queueVideos(collectVideos(cards));
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const video = extractVideoFromCard(entry.target);
if (!video) continue;
if (sentUrls.has(video.youtubeUrl)) continue;
sentUrls.add(video.youtubeUrl);
sendVideo(video);
}
}, { threshold: 0.5 });