tubewatch/playlist-monitor/app/services/video_service.py

313 lines
12 KiB
Python

"""
Video service for managing video records and download operations
"""
import logging
from typing import List, Optional, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from ..models.video import VideoRecord, VideoStatus
from ..models.playlist import PlaylistSubscription
from ..services.metube_client import MeTubeClient
from ..core.config import settings
logger = logging.getLogger(__name__)
class VideoService:
"""Service for managing video records and download operations"""
def __init__(self, db: Session):
self.db = db
self.metube_client: Optional[MeTubeClient] = None
def get_video(self, video_id: str) -> Optional[VideoRecord]:
"""Get a video record by ID"""
return self.db.query(VideoRecord).filter(VideoRecord.id == video_id).first()
def get_video_by_metube_id(self, metube_download_id: str) -> Optional[VideoRecord]:
"""Get a video record by MeTube download ID"""
return self.db.query(VideoRecord).filter(
VideoRecord.metube_download_id == metube_download_id
).first()
def get_videos_by_status(self, status: VideoStatus, limit: int = 100) -> List[VideoRecord]:
"""Get videos by status"""
return self.db.query(VideoRecord).filter(
VideoRecord.status == status
).limit(limit).all()
def get_pending_videos(self, limit: int = 100) -> List[VideoRecord]:
"""Get pending videos ready for download"""
return self.get_videos_by_status(VideoStatus.PENDING, limit)
def get_failed_videos(self, limit: int = 100) -> List[VideoRecord]:
"""Get failed videos that can be retried"""
return self.db.query(VideoRecord).filter(
and_(
VideoRecord.status == VideoStatus.FAILED,
VideoRecord.retry_count < 3
)
).limit(limit).all()
async def download_video(self, video_id: str, metube_client: Optional[MeTubeClient] = None) -> VideoRecord:
"""Trigger download for a video"""
video = self.get_video(video_id)
if not video:
raise ValueError(f"Video not found: {video_id}")
if not video.is_downloadable:
raise ValueError(f"Video is not downloadable (status: {video.status})")
# Get playlist for configuration
playlist = self.db.query(PlaylistSubscription).filter(
PlaylistSubscription.id == video.playlist_id
).first()
if not playlist:
raise ValueError(f"Playlist not found for video: {video_id}")
# Use provided client or create new one
client = metube_client or MeTubeClient(settings.METUBE_URL)
if not client.is_connected:
await client.connect()
try:
# Add download to MeTube
result = await client.add_download(
url=video.video_url,
quality=playlist.quality,
format=playlist.format,
folder=playlist.folder,
auto_start=True
)
# Update video record
metube_download_id = result.get("id")
if not metube_download_id:
raise RuntimeError("MeTube did not return a download ID")
video.mark_as_downloading(metube_download_id)
self.db.commit()
logger.info(f"Triggered download for video {video_id} (MeTube ID: {metube_download_id})")
return video
except Exception as e:
logger.error(f"Error triggering download for video {video_id}: {e}")
video.mark_as_failed(str(e))
self.db.commit()
raise
finally:
# Close client if we created it
if not metube_client and client.is_connected:
await client.disconnect()
def mark_file_as_moved(self, video_id: str, location_note: Optional[str] = None) -> VideoRecord:
"""Mark a video file as moved by the user"""
video = self.get_video(video_id)
if not video:
raise ValueError(f"Video not found: {video_id}")
if not video.is_completed:
raise ValueError("Cannot mark file as moved - video is not completed")
video.file_moved = True
video.file_location_note = location_note
video.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(video)
logger.info(f"Marked video {video_id} file as moved")
return video
def skip_video(self, video_id: str) -> VideoRecord:
"""Mark a video as skipped"""
video = self.get_video(video_id)
if not video:
raise ValueError(f"Video not found: {video_id}")
if video.status not in [VideoStatus.PENDING, VideoStatus.FAILED]:
raise ValueError(f"Cannot skip video with status: {video.status}")
video.mark_as_skipped()
video.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(video)
logger.info(f"Skipped video {video_id}")
return video
def reset_video(self, video_id: str) -> VideoRecord:
"""Reset video to pending status"""
video = self.get_video(video_id)
if not video:
raise ValueError(f"Video not found: {video_id}")
if video.status not in [VideoStatus.COMPLETED, VideoStatus.FAILED, VideoStatus.SKIPPED]:
raise ValueError(f"Cannot reset video with status: {video.status}")
video.reset_to_pending()
video.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(video)
logger.info(f"Reset video {video_id} to pending")
return video
async def sync_with_metube(self, metube_client: Optional[MeTubeClient] = None) -> int:
"""Sync video status with MeTube"""
logger.info("Starting sync with MeTube")
# Get videos that might need syncing
downloading_videos = self.get_videos_by_status(VideoStatus.DOWNLOADING)
if not downloading_videos:
logger.info("No videos to sync with MeTube")
return 0
# Use provided client or create new one
client = metube_client or MeTubeClient(settings.METUBE_URL)
if not client.is_connected:
await client.connect()
synced_count = 0
try:
# Get current download history
history = await client.get_history()
# Create a map of MeTube download IDs to their status
metube_downloads = {}
for download in history.get("completed", []):
download_id = download.get("id")
if download_id:
metube_downloads[download_id] = {
"status": "completed",
"filename": download.get("filename"),
"completed_at": download.get("completed_at")
}
for download in history.get("pending", []):
download_id = download.get("id")
if download_id:
metube_downloads[download_id] = {
"status": "pending",
"filename": download.get("filename")
}
# Sync each downloading video
for video in downloading_videos:
if not video.metube_download_id:
continue
metube_info = metube_downloads.get(video.metube_download_id)
if not metube_info:
# Download might have been cleared from MeTube history
logger.warning(f"Video {video.id} MeTube download not found in history")
continue
# Update video status based on MeTube status
if metube_info["status"] == "completed":
video.mark_as_completed(metube_info.get("filename"))
synced_count += 1
logger.info(f"Synced completed video: {video.id}")
# If still pending, leave as downloading
# If failed in MeTube, it should be handled by error callbacks
if synced_count > 0:
self.db.commit()
logger.info(f"Synced {synced_count} videos with MeTube")
return synced_count
except Exception as e:
logger.error(f"Error syncing with MeTube: {e}")
raise
finally:
# Close client if we created it
if not metube_client and client.is_connected:
await client.disconnect()
async def handle_metube_event(self, event: str, data: Dict[str, Any]) -> None:
"""Handle MeTube WebSocket events"""
try:
if event == "completed":
await self._handle_download_completed(data)
elif event == "updated":
await self._handle_download_updated(data)
elif event == "canceled":
await self._handle_download_canceled(data)
elif event == "error":
await self._handle_download_error(data)
except Exception as e:
logger.error(f"Error handling MeTube event {event}: {e}")
async def _handle_download_completed(self, data: Dict[str, Any]) -> None:
"""Handle download completed event"""
download_id = data.get("id")
filename = data.get("filename")
if not download_id:
return
video = self.get_video_by_metube_id(download_id)
if not video:
logger.debug(f"No video found for completed download {download_id}")
return
video.mark_as_completed(filename)
self.db.commit()
logger.info(f"Video {video.id} completed download (MeTube ID: {download_id})")
async def _handle_download_updated(self, data: Dict[str, Any]) -> None:
"""Handle download updated event"""
# Currently not much to do here, but could track progress
pass
async def _handle_download_canceled(self, data: Dict[str, Any]) -> None:
"""Handle download canceled event"""
download_id = data.get("id")
if not download_id:
return
video = self.get_video_by_metube_id(download_id)
if not video:
return
# Reset to pending so it can be retried
video.reset_to_pending()
self.db.commit()
logger.info(f"Video {video.id} download was canceled (MeTube ID: {download_id})")
async def _handle_download_error(self, data: Dict[str, Any]) -> None:
"""Handle download error event"""
download_id = data.get("id")
error_message = data.get("error", "Unknown error")
if not download_id:
return
video = self.get_video_by_metube_id(download_id)
if not video:
return
video.mark_as_failed(error_message)
self.db.commit()
logger.error(f"Video {video.id} download failed (MeTube ID: {download_id}): {error_message}")