313 lines
12 KiB
Python
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}") |