import logging from datetime import datetime from typing import Optional, Any import pytz from beanie import ( Document, after_event, Insert, Delete, before_event, Update, Replace, ) from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator from pymongo import IndexModel, ASCENDING, DESCENDING, TEXT from db.enums import TorrentType, NudityStatus from db.redis_database import REDIS_ASYNC_CLIENT class EpisodeFile(BaseModel): season_number: int episode_number: int size: int | None = None filename: str | None = None file_index: int | None = None title: str | None = None released: datetime | None = None thumbnail: str | None = None overview: str | None = None class CatalogStats(BaseModel): catalog: str total_streams: int = 0 last_stream_added: datetime | None = None class MediaFusionMetaData(Document): id: str title: str is_custom: bool = Field(default=False) aka_titles: Optional[list[str]] = Field(default_factory=list) year: Optional[int] = None poster: Optional[str] = None is_poster_working: Optional[bool] = True is_add_title_to_poster: Optional[bool] = False background: Optional[str] = None type: str description: Optional[str] = None runtime: Optional[str] = None website: Optional[str] = None genres: Optional[list[str]] = Field(default_factory=list) last_updated_at: datetime = Field(default_factory=datetime.now) catalog_stats: list[CatalogStats] = Field(default_factory=list) last_stream_added: datetime | None = Field(default_factory=datetime.now) total_streams: int | None = 0 class Settings: is_root = True indexes = [ # Partial index for custom IDs (mf prefix) to enforce uniqueness IndexModel( [("title", ASCENDING), ("year", ASCENDING), ("type", ASCENDING)], unique=True, partialFilterExpression={"is_custom": True}, name="unique_title_year_type_for_mf_id", ), # Regular index for all documents IndexModel( [("title", ASCENDING), ("year", ASCENDING), ("type", ASCENDING)], unique=False, ), IndexModel([("title", TEXT)]), IndexModel([("year", ASCENDING), ("end_year", ASCENDING)]), IndexModel([("_class_id", ASCENDING)]), IndexModel([("type", ASCENDING), ("genres", ASCENDING)]), IndexModel( [ ("type", ASCENDING), ("catalog_stats.catalog", ASCENDING), ("catalog_stats.total_streams", ASCENDING), ] ), ] @field_validator("runtime", mode="before") def validate_runtime(cls, v): if v and isinstance(v, int): return f"{v} min" return v class KnownFile(BaseModel): size: int filename: str class TorrentStreams(Document): model_config = ConfigDict(extra="allow") id: str meta_id: str torrent_name: str size: int episode_files: list[EpisodeFile] | None = Field(default_factory=list) filename: Optional[str] = None file_index: Optional[int] = None announce_list: list[str] languages: list[str] source: str uploader: Optional[str] = None catalog: list[str] created_at: datetime = Field(default_factory=datetime.now) updated_at: Optional[datetime] = None uploaded_at: Optional[datetime] = None resolution: Optional[str] = None codec: Optional[str] = None quality: Optional[str] = None audio: list[str] | str | None = None hdr: list[str] | None = None seeders: Optional[int] = None torrent_type: Optional[TorrentType] = TorrentType.PUBLIC is_blocked: Optional[bool] = False torrent_file: bytes | None = None known_file_details: Optional[list[KnownFile]] = None @after_event(Insert) async def update_metadata_on_create(self): """Update metadata when a new stream is created""" # Initialize update operations for total stream count update_ops = { "$inc": {"total_streams": 1}, "$set": {"last_stream_added": self.created_at}, } # Update catalog_stats array for cat in self.catalog: # Try to update existing catalog stats first result = await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id, "catalog_stats.catalog": cat}, { "$inc": {"catalog_stats.$.total_streams": 1}, "$set": { "catalog_stats.$.last_stream_added": self.created_at, **update_ops["$set"], }, }, ) # If no existing catalog stats found, add new one if result.modified_count == 0: # Initialize $push.catalog_stats.$each if it doesn't exist yet if "$push" not in update_ops: update_ops["$push"] = {} if "catalog_stats" not in update_ops["$push"]: update_ops["$push"]["catalog_stats"] = {"$each": []} # Add this catalog to the $each array update_ops["$push"]["catalog_stats"]["$each"].append( { "catalog": cat, "total_streams": 1, "last_stream_added": self.created_at, } ) # Handle episode metadata updates for series if self.episode_files: series_data = await MediaFusionSeriesMetaData.get(self.meta_id) if series_data: existing_episodes = { (ep.season_number, ep.episode_number): ep for ep in series_data.episodes } new_episodes = [] for ep in self.episode_files: key = (ep.season_number, ep.episode_number) if key not in existing_episodes: new_episodes.append( SeriesEpisode( season_number=ep.season_number, episode_number=ep.episode_number, title=ep.title or f"Episode {ep.episode_number}", released=ep.released or self.created_at, overview=ep.overview, thumbnail=ep.thumbnail, ) ) if new_episodes: update_ops["$push"] = update_ops.get("$push", {}) update_ops["$push"]["episodes"] = { "$each": [ep.model_dump() for ep in new_episodes] } cache_keys = await REDIS_ASYNC_CLIENT.keys( f"series_{self.meta_id}_meta*" ) cache_keys.append(f"series_data:{self.meta_id}") await REDIS_ASYNC_CLIENT.delete(*cache_keys) await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, update_ops ) logging.info(f"Added stream {self.id} to metadata {self.meta_id}") @after_event(Delete) async def update_metadata_on_delete(self): """Update metadata when a stream is deleted""" # Get all remaining streams for this meta_id grouped by catalog pipeline = [ { "$match": { "meta_id": self.meta_id, "_id": {"$ne": self.id}, "is_blocked": {"$ne": True}, } }, {"$unwind": "$catalog"}, { "$group": { "_id": "$catalog", "total_streams": {"$sum": 1}, "last_stream_added": {"$max": "$created_at"}, } }, ] remaining_streams = ( await TorrentStreams.get_motor_collection() .aggregate(pipeline) .to_list(None) ) remaining_catalogs = {stream["_id"] for stream in remaining_streams} # Build update operations update_ops = { "$inc": {"total_streams": -1}, "$pull": { "catalog_stats": { "catalog": { "$in": [ cat for cat in self.catalog if cat not in remaining_catalogs ] } } }, } # Update remaining catalog stats for stream in remaining_streams: await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id, "catalog_stats.catalog": stream["_id"]}, { "$set": { "catalog_stats.$.total_streams": stream["total_streams"], "catalog_stats.$.last_stream_added": stream[ "last_stream_added" ], } }, ) # Update last_stream_added if remaining_streams: update_ops["$set"] = { "last_stream_added": max( stream["last_stream_added"] for stream in remaining_streams ) } else: update_ops["$set"] = {"last_stream_added": None} await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, update_ops ) logging.info(f"Removed stream {self.id} from metadata {self.meta_id}") @before_event(Update) async def update_metadata_on_block(self): """Update metadata when a stream is blocked""" if hasattr(self, "is_blocked") and self.is_blocked: logging.info(f"Stream {self.id} is blocked") await self.update_metadata_on_delete() @before_event(Update) async def update_metadata_on_change(self): """Update metadata when stream episodes change""" if not hasattr(self, "episode_files") or not self.episode_files: return # Only proceed if this is an update with episode changes old_stream = await TorrentStreams.get(self.id) if not old_stream or old_stream.episode_files == self.episode_files: return series_data = await MediaFusionSeriesMetaData.get(self.meta_id) if not series_data: return existing_episodes = { (ep.season_number, ep.episode_number): ep for ep in series_data.episodes } new_episodes = [] for ep in self.episode_files: key = (ep.season_number, ep.episode_number) if key not in existing_episodes: new_episodes.append( SeriesEpisode( season_number=ep.season_number, episode_number=ep.episode_number, title=ep.title or f"Episode {ep.episode_number}", released=ep.released or self.created_at, ) ) if new_episodes: await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, { "$push": { "episodes": {"$each": [ep.model_dump() for ep in new_episodes]} } }, ) cache_keys = await REDIS_ASYNC_CLIENT.keys(f"series_{self.meta_id}_meta*") cache_keys.append(f"series_data:{self.meta_id}") await REDIS_ASYNC_CLIENT.delete(*cache_keys) logging.info(f"Updated episodes for series {self.meta_id}") def __eq__(self, other): if not isinstance(other, TorrentStreams): return False return self.id == other.id def __hash__(self): return hash(self.id) @field_validator("id", mode="after") def validate_id(cls, v): return v.lower() @field_validator("created_at", mode="after") def validate_created_at(cls, v): # convert to UTC return v.astimezone(pytz.utc) class Settings: indexes = [ # Optimized compound indexes IndexModel( [ ("meta_id", ASCENDING), ("is_blocked", ASCENDING), ("catalog", ASCENDING), ("created_at", DESCENDING), ] ), IndexModel([("_id", ASCENDING), ("is_blocked", ASCENDING)]), IndexModel([("_class_id", ASCENDING)]), IndexModel([("source", ASCENDING)]), IndexModel([("uploader", ASCENDING)]), ] def get_episodes( self, season_number: int, episode_number: int ) -> list[EpisodeFile]: """ Returns a list of Episode objects for the given season and episode number, sorted by size in descending order if size is available. """ episodes = [ ep for ep in self.episode_files or [] if ep.season_number == season_number and ep.episode_number == episode_number ] return sorted(episodes, key=lambda ep: ep.size or 0, reverse=True) class TVStreams(Document): meta_id: str name: str source: str url: str | None = None ytId: str | None = None externalUrl: str | None = None behaviorHints: dict[str, Any] | None = None created_at: datetime = Field(default_factory=datetime.now) country: str | None = None is_working: Optional[bool] = True test_failure_count: int = 0 namespaces: list[str] = Field(default_factory=lambda: ["mediafusion"]) drm_key_id: str | None = None drm_key: str | None = None @after_event([Insert, Replace]) async def update_metadata_on_create(self): """Update metadata when a new stream is created""" if self.is_working: update_data = { "$inc": {"total_streams": 1}, "$set": {"last_stream_added": self.created_at}, } await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, update_data ) logging.info(f"Added stream {self.id} to metadata {self.meta_id}") @after_event(Delete) async def update_metadata_on_delete(self): """Update metadata when a stream is deleted""" # First, check if this was the last stream for any catalog await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, {"$inc": {"total_streams": -1}} ) logging.info(f"Removed stream {self.id} from metadata {self.meta_id}") @after_event(Update) async def update_metadata_on_not_working(self): """Update metadata when a stream is not working""" inc_value = 1 if self.is_working else -1 update_data = {"$inc": {"total_streams": inc_value}} if self.is_working: update_data["$set"] = {"last_stream_added": datetime.now()} await MediaFusionMetaData.get_motor_collection().update_one( {"_id": self.meta_id}, update_data ) def __eq__(self, other): if not isinstance(other, TVStreams): return False return ( self.url == other.url and self.ytId == other.ytId and self.externalUrl == other.externalUrl and self.drm_key_id == other.drm_key_id and self.drm_key == other.drm_key ) def __hash__(self): return hash( (self.url, self.ytId, self.externalUrl, self.drm_key_id, self.drm_key) ) class Settings: indexes = [ IndexModel( [ ("meta_id", ASCENDING), ("created_at", DESCENDING), ("namespaces", ASCENDING), ("is_working", ASCENDING), ] ), IndexModel( [("url", ASCENDING), ("ytId", ASCENDING), ("externalUrl", ASCENDING)], unique=True, sparse=True, ), IndexModel([("_class_id", ASCENDING)]), ] class MediaFusionMovieMetaData(MediaFusionMetaData): type: str = "movie" imdb_rating: Optional[float] = None tmdb_rating: Optional[float] = None parent_guide_nudity_status: Optional[NudityStatus] = NudityStatus.UNKNOWN parent_guide_certificates: Optional[list[str]] = Field(default_factory=list) stars: Optional[list[str]] = Field(default_factory=list) class SeriesEpisode(BaseModel): """Series episode metadata from IMDb""" season_number: int episode_number: int title: str | None = None overview: Optional[str] = None released: datetime | None = None imdb_rating: Optional[float] = None tmdb_rating: Optional[float] = None thumbnail: Optional[str] = None @model_validator(mode="after") def validate_title(self): if not self.title: self.title = f"Episode {self.episode_number}" return self class MediaFusionSeriesMetaData(MediaFusionMetaData): type: str = "series" end_year: Optional[int] = None imdb_rating: Optional[float] = None tmdb_rating: Optional[float] = None parent_guide_nudity_status: Optional[str] = "None" parent_guide_certificates: Optional[list[str]] = Field(default_factory=list) stars: Optional[list[str]] = Field(default_factory=list) episodes: list[SeriesEpisode] = Field(default_factory=list) class MediaFusionTVMetaData(MediaFusionMetaData): type: str = "tv" country: str | None = None tv_language: str | None = None logo: Optional[str] = None class MediaFusionEventsMetaData(MediaFusionMetaData): type: str = "events" event_start_timestamp: Optional[int] = None logo: Optional[str] = None streams: list[TVStreams]