""" 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}")