430 lines
16 KiB
Python
430 lines
16 KiB
Python
"""
|
|
Playlist service for managing playlist subscriptions and operations
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
import yt_dlp
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import and_, or_
|
|
|
|
from ..models.playlist import PlaylistSubscription
|
|
from ..models.video import VideoRecord, VideoStatus
|
|
from ..core.config import settings
|
|
from ..core.scheduler import scheduler_manager
|
|
from .metube_client import MeTubeClient
|
|
from .video_service import VideoService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PlaylistService:
|
|
"""Service for managing playlist operations"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.video_service = VideoService(db)
|
|
|
|
def get_playlists(self, skip: int = 0, limit: int = 100, enabled: Optional[bool] = None) -> List[PlaylistSubscription]:
|
|
"""Get playlists with optional filtering"""
|
|
query = self.db.query(PlaylistSubscription)
|
|
|
|
if enabled is not None:
|
|
query = query.filter(PlaylistSubscription.enabled == enabled)
|
|
|
|
return query.offset(skip).limit(limit).all()
|
|
|
|
def get_playlist(self, playlist_id: str) -> Optional[PlaylistSubscription]:
|
|
"""Get a specific playlist by ID"""
|
|
return self.db.query(PlaylistSubscription).filter(PlaylistSubscription.id == playlist_id).first()
|
|
|
|
def get_playlist_by_url(self, url: str) -> Optional[PlaylistSubscription]:
|
|
"""Get a playlist by URL"""
|
|
return self.db.query(PlaylistSubscription).filter(PlaylistSubscription.url == url).first()
|
|
|
|
async def add_playlist(
|
|
self,
|
|
url: str,
|
|
check_interval: int = settings.DEFAULT_CHECK_INTERVAL,
|
|
start_point: Optional[str] = None,
|
|
quality: str = settings.DEFAULT_QUALITY,
|
|
format: str = settings.DEFAULT_FORMAT,
|
|
folder: Optional[str] = None,
|
|
enabled: bool = True
|
|
) -> PlaylistSubscription:
|
|
"""Add a new playlist for monitoring"""
|
|
|
|
# Validate URL
|
|
if not self._is_valid_youtube_playlist_url(url):
|
|
raise ValueError("Invalid YouTube playlist URL")
|
|
|
|
# Check if playlist already exists
|
|
existing = self.get_playlist_by_url(url)
|
|
if existing:
|
|
raise ValueError(f"Playlist already exists with URL: {url}")
|
|
|
|
# Extract playlist info using yt-dlp
|
|
playlist_info = await self._extract_playlist_info(url)
|
|
if not playlist_info:
|
|
raise ValueError("Failed to extract playlist information")
|
|
|
|
# Create playlist subscription
|
|
playlist = PlaylistSubscription(
|
|
url=url,
|
|
title=playlist_info.get("title"),
|
|
check_interval=check_interval,
|
|
start_point=start_point,
|
|
quality=quality,
|
|
format=format,
|
|
folder=folder,
|
|
enabled=enabled
|
|
)
|
|
|
|
self.db.add(playlist)
|
|
self.db.commit()
|
|
self.db.refresh(playlist)
|
|
|
|
logger.info(f"Created playlist subscription: {playlist.title} ({playlist.id})")
|
|
|
|
# Fetch and create video records
|
|
await self._initialize_playlist_videos(playlist, playlist_info)
|
|
|
|
# Schedule periodic checks if enabled
|
|
if enabled:
|
|
scheduler_manager.add_playlist_check_job(playlist.id, check_interval)
|
|
|
|
return playlist
|
|
|
|
def update_playlist(self, playlist_id: str, **kwargs) -> PlaylistSubscription:
|
|
"""Update playlist settings"""
|
|
playlist = self.get_playlist(playlist_id)
|
|
if not playlist:
|
|
raise ValueError(f"Playlist not found: {playlist_id}")
|
|
|
|
# Update fields
|
|
for key, value in kwargs.items():
|
|
if hasattr(playlist, key) and value is not None:
|
|
setattr(playlist, key, value)
|
|
|
|
playlist.updated_at = datetime.utcnow()
|
|
self.db.commit()
|
|
self.db.refresh(playlist)
|
|
|
|
# Update scheduler if check_interval changed
|
|
if "check_interval" in kwargs and playlist.enabled:
|
|
scheduler_manager.add_playlist_check_job(playlist.id, playlist.check_interval)
|
|
|
|
logger.info(f"Updated playlist: {playlist.title} ({playlist.id})")
|
|
return playlist
|
|
|
|
def delete_playlist(self, playlist_id: str, delete_videos: bool = False) -> None:
|
|
"""Delete a playlist"""
|
|
playlist = self.get_playlist(playlist_id)
|
|
if not playlist:
|
|
raise ValueError(f"Playlist not found: {playlist_id}")
|
|
|
|
# Remove scheduler job
|
|
scheduler_manager.remove_playlist_check_job(playlist_id)
|
|
|
|
# Delete playlist (videos will be cascade deleted if delete_videos is True)
|
|
self.db.delete(playlist)
|
|
self.db.commit()
|
|
|
|
logger.info(f"Deleted playlist: {playlist.title} ({playlist.id})")
|
|
|
|
async def check_playlist(self, playlist_id: str, force: bool = False) -> int:
|
|
"""Check playlist for new videos"""
|
|
playlist = self.get_playlist(playlist_id)
|
|
if not playlist:
|
|
raise ValueError(f"Playlist not found: {playlist_id}")
|
|
|
|
if not playlist.enabled and not force:
|
|
logger.info(f"Playlist {playlist_id} is disabled, skipping check")
|
|
return 0
|
|
|
|
if not playlist.should_check() and not force:
|
|
logger.info(f"Playlist {playlist_id} was recently checked, skipping")
|
|
return 0
|
|
|
|
logger.info(f"Checking playlist: {playlist.title} ({playlist_id})")
|
|
|
|
# Extract current playlist info
|
|
playlist_info = await self._extract_playlist_info(playlist.url)
|
|
if not playlist_info:
|
|
logger.error(f"Failed to extract playlist info for {playlist_id}")
|
|
return 0
|
|
|
|
# Get existing video IDs
|
|
existing_videos = self.db.query(VideoRecord).filter(
|
|
VideoRecord.playlist_id == playlist_id
|
|
).all()
|
|
existing_video_ids = {v.video_id for v in existing_videos}
|
|
|
|
# Process new videos
|
|
new_videos_count = 0
|
|
videos_info = playlist_info.get("entries", [])
|
|
|
|
for video_info in videos_info:
|
|
video_id = video_info.get("id")
|
|
if not video_id:
|
|
continue
|
|
|
|
if video_id not in existing_video_ids:
|
|
# Create new video record
|
|
video = self._create_video_record(playlist, video_info)
|
|
self.db.add(video)
|
|
new_videos_count += 1
|
|
|
|
logger.debug(f"Found new video: {video.title} ({video_id})")
|
|
|
|
# Update last checked timestamp
|
|
playlist.last_checked = datetime.utcnow()
|
|
self.db.commit()
|
|
|
|
# Trigger downloads for pending videos
|
|
if new_videos_count > 0:
|
|
await self._trigger_pending_downloads(playlist)
|
|
|
|
logger.info(f"Playlist check completed: {playlist.title} - Found {new_videos_count} new videos")
|
|
return new_videos_count
|
|
|
|
def get_playlist_stats(self, playlist_id: str) -> Dict[str, int]:
|
|
"""Get playlist statistics"""
|
|
stats = {
|
|
"total": 0,
|
|
"pending": 0,
|
|
"downloading": 0,
|
|
"completed": 0,
|
|
"failed": 0,
|
|
"skipped": 0
|
|
}
|
|
|
|
# Get video counts by status
|
|
video_counts = self.db.query(VideoRecord.status, func.count(VideoRecord.id)).filter(
|
|
VideoRecord.playlist_id == playlist_id
|
|
).group_by(VideoRecord.status).all()
|
|
|
|
for status, count in video_counts:
|
|
stats["total"] += count
|
|
if status == VideoStatus.PENDING:
|
|
stats["pending"] = count
|
|
elif status == VideoStatus.DOWNLOADING:
|
|
stats["downloading"] = count
|
|
elif status == VideoStatus.COMPLETED:
|
|
stats["completed"] = count
|
|
elif status == VideoStatus.FAILED:
|
|
stats["failed"] = count
|
|
elif status == VideoStatus.SKIPPED:
|
|
stats["skipped"] = count
|
|
|
|
return stats
|
|
|
|
def get_playlist_videos(
|
|
self,
|
|
playlist_id: str,
|
|
status: Optional[str] = None,
|
|
limit: int = 50,
|
|
skip: int = 0
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get videos for a playlist"""
|
|
query = self.db.query(VideoRecord).filter(VideoRecord.playlist_id == playlist_id)
|
|
|
|
if status:
|
|
query = query.filter(VideoRecord.status == status)
|
|
|
|
videos = query.order_by(VideoRecord.playlist_index).offset(skip).limit(limit).all()
|
|
|
|
# Convert to dict for JSON serialization
|
|
return [
|
|
{
|
|
"id": v.id,
|
|
"video_id": v.video_id,
|
|
"title": v.title,
|
|
"status": v.status,
|
|
"playlist_index": v.playlist_index,
|
|
"upload_date": v.upload_date.isoformat() if v.upload_date else None,
|
|
"download_requested_at": v.download_requested_at.isoformat() if v.download_requested_at else None,
|
|
"download_completed_at": v.download_completed_at.isoformat() if v.download_completed_at else None,
|
|
"error_message": v.error_message,
|
|
"retry_count": v.retry_count,
|
|
"file_moved": v.file_moved,
|
|
"file_location_note": v.file_location_note,
|
|
}
|
|
for v in videos
|
|
]
|
|
|
|
def update_start_point(self, playlist_id: str, start_video_id: str) -> int:
|
|
"""Update start point and mark videos before it as skipped"""
|
|
playlist = self.get_playlist(playlist_id)
|
|
if not playlist:
|
|
raise ValueError(f"Playlist not found: {playlist_id}")
|
|
|
|
# Find the start video
|
|
start_video = self.db.query(VideoRecord).filter(
|
|
and_(
|
|
VideoRecord.playlist_id == playlist_id,
|
|
VideoRecord.video_id == start_video_id
|
|
)
|
|
).first()
|
|
|
|
if not start_video:
|
|
raise ValueError(f"Video not found in playlist: {start_video_id}")
|
|
|
|
# Update playlist start point
|
|
playlist.start_point = start_video_id
|
|
playlist.updated_at = datetime.utcnow()
|
|
|
|
# Mark videos before start point as skipped
|
|
updated_count = 0
|
|
videos_to_skip = self.db.query(VideoRecord).filter(
|
|
and_(
|
|
VideoRecord.playlist_id == playlist_id,
|
|
VideoRecord.playlist_index < start_video.playlist_index,
|
|
VideoRecord.status == VideoStatus.PENDING
|
|
)
|
|
).all()
|
|
|
|
for video in videos_to_skip:
|
|
video.mark_as_skipped()
|
|
updated_count += 1
|
|
|
|
self.db.commit()
|
|
|
|
logger.info(f"Updated start point for playlist {playlist_id}: {updated_count} videos marked as skipped")
|
|
return updated_count
|
|
|
|
def _is_valid_youtube_playlist_url(self, url: str) -> bool:
|
|
"""Validate YouTube playlist URL"""
|
|
try:
|
|
parsed = urlparse(url)
|
|
|
|
# Check if it's a YouTube domain
|
|
if parsed.netloc not in ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"]:
|
|
return False
|
|
|
|
# Check for playlist parameter
|
|
if "playlist" in parsed.path.lower():
|
|
return True
|
|
|
|
query_params = parse_qs(parsed.query)
|
|
if "list" in query_params:
|
|
return True
|
|
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
async def _extract_playlist_info(self, url: str) -> Optional[Dict[str, Any]]:
|
|
"""Extract playlist information using yt-dlp"""
|
|
try:
|
|
ydl_opts = {
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extract_flat": True, # Only extract metadata, not actual videos
|
|
"skip_download": True,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(url, download=False)
|
|
return info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting playlist info: {e}")
|
|
return None
|
|
|
|
def _create_video_record(self, playlist: PlaylistSubscription, video_info: Dict[str, Any]) -> VideoRecord:
|
|
"""Create a video record from video info"""
|
|
video_id = video_info.get("id")
|
|
title = video_info.get("title")
|
|
playlist_index = video_info.get("playlist_index")
|
|
upload_date_str = video_info.get("upload_date")
|
|
|
|
# Parse upload date
|
|
upload_date = None
|
|
if upload_date_str:
|
|
try:
|
|
upload_date = datetime.strptime(upload_date_str, "%Y%m%d")
|
|
except ValueError:
|
|
pass
|
|
|
|
# Determine initial status based on start point
|
|
status = VideoStatus.PENDING
|
|
if playlist.start_point:
|
|
# If start_point is set, check if this video should be skipped
|
|
if self._should_skip_video(playlist, video_id, playlist_index):
|
|
status = VideoStatus.SKIPPED
|
|
|
|
video = VideoRecord(
|
|
playlist_id=playlist.id,
|
|
video_url=f"https://www.youtube.com/watch?v={video_id}",
|
|
video_id=video_id,
|
|
title=title,
|
|
playlist_index=playlist_index,
|
|
upload_date=upload_date,
|
|
status=status
|
|
)
|
|
|
|
return video
|
|
|
|
def _should_skip_video(self, playlist: PlaylistSubscription, video_id: str, playlist_index: Optional[int]) -> bool:
|
|
"""Determine if a video should be skipped based on start point"""
|
|
if not playlist.start_point:
|
|
return False
|
|
|
|
# If start_point is a video ID
|
|
if playlist.start_point == video_id:
|
|
return False
|
|
|
|
# If start_point is a playlist index
|
|
try:
|
|
start_index = int(playlist.start_point)
|
|
if playlist_index is not None and playlist_index < start_index:
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
# Check if we've already processed videos after the start point
|
|
existing_after_start = self.db.query(VideoRecord).filter(
|
|
and_(
|
|
VideoRecord.playlist_id == playlist.id,
|
|
VideoRecord.playlist_index > playlist_index if playlist_index else True,
|
|
VideoRecord.status != VideoStatus.SKIPPED
|
|
)
|
|
).count()
|
|
|
|
return existing_after_start > 0
|
|
|
|
async def _initialize_playlist_videos(self, playlist: PlaylistSubscription, playlist_info: Dict[str, Any]) -> None:
|
|
"""Initialize video records for a new playlist"""
|
|
videos_info = playlist_info.get("entries", [])
|
|
|
|
for video_info in videos_info:
|
|
video = self._create_video_record(playlist, video_info)
|
|
self.db.add(video)
|
|
|
|
self.db.commit()
|
|
logger.info(f"Initialized {len(videos_info)} video records for playlist {playlist.id}")
|
|
|
|
async def _trigger_pending_downloads(self, playlist: PlaylistSubscription) -> None:
|
|
"""Trigger downloads for pending videos in a playlist"""
|
|
pending_videos = self.db.query(VideoRecord).filter(
|
|
and_(
|
|
VideoRecord.playlist_id == playlist.id,
|
|
VideoRecord.status == VideoStatus.PENDING
|
|
)
|
|
).order_by(VideoRecord.playlist_index).limit(settings.MAX_CONCURRENT_DOWNLOADS).all()
|
|
|
|
if not pending_videos:
|
|
return
|
|
|
|
logger.info(f"Triggering downloads for {len(pending_videos)} pending videos in playlist {playlist.id}")
|
|
|
|
for video in pending_videos:
|
|
try:
|
|
await self.video_service.download_video(video.id)
|
|
except Exception as e:
|
|
logger.error(f"Error triggering download for video {video.id}: {e}") |