mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
1199 lines
41 KiB
Python
1199 lines
41 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
|
|
import humanize
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from beanie import BulkWriter
|
|
from beanie.exceptions import RevisionIdWasChanged
|
|
from beanie.operators import Set
|
|
from fastapi import BackgroundTasks
|
|
from pymongo.errors import DuplicateKeyError
|
|
|
|
from db import schemas
|
|
from db.config import settings
|
|
from db.models import (
|
|
Episode,
|
|
MediaFusionEventsMetaData,
|
|
MediaFusionMetaData,
|
|
MediaFusionMovieMetaData,
|
|
MediaFusionSeriesMetaData,
|
|
MediaFusionTVMetaData,
|
|
Season,
|
|
TorrentStreams,
|
|
TVStreams,
|
|
)
|
|
from db.schemas import Stream, TorrentStreamsList
|
|
from scrapers.utils import run_scrapers
|
|
from scrapers.imdb_data import get_imdb_movie_data, search_imdb
|
|
from utils import crypto
|
|
from utils.parser import (
|
|
fetch_downloaded_info_hashes,
|
|
parse_stream_data,
|
|
parse_tv_stream_data,
|
|
)
|
|
from utils.runtime_const import REDIS_ASYNC_CLIENT
|
|
from utils.validation_helper import (
|
|
validate_parent_guide_nudity,
|
|
get_filter_certification_values,
|
|
)
|
|
|
|
|
|
async def get_meta_list(
|
|
user_data: schemas.UserData,
|
|
catalog_type: str,
|
|
catalog: str,
|
|
is_watchlist_catalog: bool,
|
|
skip: int = 0,
|
|
limit: int = 25,
|
|
user_ip: str | None = None,
|
|
genre: Optional[str] = None,
|
|
) -> list[schemas.Meta]:
|
|
poster_path = f"{settings.poster_host_url}/poster/{catalog_type}/"
|
|
meta_class = MediaFusionMetaData
|
|
|
|
# Define query filters for TorrentStreams based on catalog
|
|
if is_watchlist_catalog:
|
|
downloaded_info_hashes = await fetch_downloaded_info_hashes(user_data, user_ip)
|
|
if not downloaded_info_hashes:
|
|
return []
|
|
query_filters = {"_id": {"$in": downloaded_info_hashes}}
|
|
else:
|
|
query_filters = {"catalog": {"$in": [catalog]}}
|
|
|
|
query_filters["is_blocked"] = {"$ne": True}
|
|
match_filter = {
|
|
"type": catalog_type,
|
|
}
|
|
|
|
if genre:
|
|
match_filter["genres"] = {"$in": [genre]}
|
|
|
|
if "Disable" not in user_data.nudity_filter:
|
|
match_filter["parent_guide_nudity_status"] = {"$nin": user_data.nudity_filter}
|
|
|
|
if "Disable" not in user_data.certification_filter:
|
|
match_filter["parent_guide_certificates"] = {
|
|
"$nin": get_filter_certification_values(user_data)
|
|
}
|
|
|
|
# Define the pipeline for aggregation
|
|
pipeline = [
|
|
{"$match": query_filters},
|
|
{"$group": {"_id": "$meta_id", "created_at": {"$max": "$created_at"}}},
|
|
{"$sort": {"created_at": -1}},
|
|
{
|
|
"$lookup": { # Optimized lookup with pagination and filtering
|
|
"from": meta_class.get_collection_name(),
|
|
"localField": "_id",
|
|
"foreignField": "_id",
|
|
"pipeline": [
|
|
{"$match": match_filter},
|
|
],
|
|
"as": "meta_data",
|
|
}
|
|
},
|
|
{"$unwind": "$meta_data"}, # Flatten the results of the lookup
|
|
{"$replaceRoot": {"newRoot": "$meta_data"}}, # Flatten the structure
|
|
{"$skip": skip}, # Pagination with skip and limit
|
|
{"$limit": limit},
|
|
{"$set": {"poster": {"$concat": [poster_path, "$_id", ".jpg"]}}},
|
|
]
|
|
|
|
# Execute the aggregation pipeline
|
|
meta_list = await TorrentStreams.aggregate(
|
|
pipeline, projection_model=schemas.Meta
|
|
).to_list()
|
|
|
|
return meta_list
|
|
|
|
|
|
async def get_tv_meta_list(
|
|
namespace: str, genre: Optional[str] = None, skip: int = 0, limit: int = 25
|
|
) -> list[schemas.Meta]:
|
|
# Define query filters for TVStreams
|
|
query_filters = {
|
|
"is_working": True,
|
|
"namespaces": {"$in": [namespace, "mediafusion", None]},
|
|
}
|
|
|
|
poster_path = f"{settings.poster_host_url}/poster/tv/"
|
|
|
|
# Define the pipeline for aggregation
|
|
pipeline = [
|
|
{"$match": query_filters},
|
|
{"$group": {"_id": "$meta_id", "created_at": {"$max": "$created_at"}}},
|
|
{"$sort": {"created_at": -1}},
|
|
{
|
|
"$lookup": {
|
|
"from": MediaFusionTVMetaData.get_collection_name(),
|
|
"localField": "_id",
|
|
"foreignField": "_id",
|
|
"pipeline": [
|
|
{"$match": {**({"genres": {"$in": [genre]}} if genre else {})}},
|
|
],
|
|
"as": "meta_data",
|
|
}
|
|
},
|
|
{"$unwind": "$meta_data"}, # Flatten the results of the lookup
|
|
{"$replaceRoot": {"newRoot": "$meta_data"}}, # Flatten the structure
|
|
{"$skip": skip}, # Pagination with skip and limit
|
|
{"$limit": limit},
|
|
{"$set": {"poster": {"$concat": [poster_path, "$_id", ".jpg"]}}},
|
|
]
|
|
|
|
# Execute the aggregation pipeline
|
|
tv_meta_list = await TVStreams.aggregate(
|
|
pipeline, projection_model=schemas.Meta
|
|
).to_list()
|
|
|
|
return tv_meta_list
|
|
|
|
|
|
async def get_movie_data_by_id(movie_id: str) -> Optional[MediaFusionMovieMetaData]:
|
|
# Check if the movie data is already in the cache
|
|
cached_data = await REDIS_ASYNC_CLIENT.get(f"movie_data:{movie_id}")
|
|
if cached_data:
|
|
return MediaFusionMovieMetaData.model_validate_json(cached_data)
|
|
|
|
movie_data = await MediaFusionMovieMetaData.get(movie_id)
|
|
# store it in the db for feature reference.
|
|
if not movie_data and movie_id.startswith("tt"):
|
|
movie = await get_imdb_movie_data(movie_id, "movie")
|
|
if not movie:
|
|
return None
|
|
|
|
movie_data = MediaFusionMovieMetaData(
|
|
id=movie_id,
|
|
title=movie.title,
|
|
year=movie.year,
|
|
poster=movie.primary_image,
|
|
background=movie.primary_image,
|
|
streams=[],
|
|
description=movie.plot.get("en-US"),
|
|
genres=movie.genres,
|
|
imdb_rating=movie.rating,
|
|
parent_guide_nudity_status=movie.advisories.nudity.status,
|
|
parent_guide_certificates=list(
|
|
set(cert.certificate for cert in movie.certification.certificates)
|
|
),
|
|
aka_titles=list(set(aka.title for aka in movie.akas)),
|
|
stars=list(set(star.name for star in movie.cast))[10:],
|
|
)
|
|
try:
|
|
await movie_data.create()
|
|
logging.info("Added metadata for movie %s", movie_data.title)
|
|
except (RevisionIdWasChanged, DuplicateKeyError):
|
|
# Wait for a moment before re-fetching to mitigate rapid retry issues
|
|
await asyncio.sleep(1)
|
|
movie_data = await MediaFusionMovieMetaData.get(movie_id)
|
|
|
|
# Serialize the data and store it in the Redis cache for 1 day
|
|
if movie_data:
|
|
await REDIS_ASYNC_CLIENT.set(
|
|
f"movie_data:{movie_id}",
|
|
movie_data.model_dump_json(exclude_none=True),
|
|
ex=86400,
|
|
)
|
|
return movie_data
|
|
|
|
|
|
async def get_series_data_by_id(
|
|
series_id: str, fetch_links: bool = False
|
|
) -> Optional[MediaFusionSeriesMetaData]:
|
|
series_data = await MediaFusionSeriesMetaData.get(
|
|
series_id, fetch_links=fetch_links
|
|
)
|
|
|
|
if not series_data and series_id.startswith("tt"):
|
|
series = await get_imdb_movie_data(series_id, "series")
|
|
if not series:
|
|
return None
|
|
|
|
series_data = MediaFusionSeriesMetaData(
|
|
id=series_id,
|
|
title=series.title,
|
|
year=series.year,
|
|
end_year=series.end_year if hasattr(series, "end_year") else None,
|
|
poster=series.primary_image,
|
|
background=series.primary_image,
|
|
streams=[],
|
|
description=series.plot.get("en-US"),
|
|
genres=series.genres,
|
|
imdb_rating=series.rating,
|
|
parent_guide_nudity_status=series.advisories.nudity.status,
|
|
parent_guide_certificates=list(
|
|
set(cert.certificate for cert in series.certification.certificates)
|
|
),
|
|
aka_titles=list(set(aka.title for aka in series.akas))[10:],
|
|
stars=list(set(star.name for star in series.cast))[10:],
|
|
)
|
|
try:
|
|
await series_data.create()
|
|
logging.info("Added metadata for series %s", series_data.title)
|
|
except (RevisionIdWasChanged, DuplicateKeyError):
|
|
# Wait for a moment before re-fetching to mitigate rapid retry issues
|
|
await asyncio.sleep(1)
|
|
series_data = await MediaFusionMovieMetaData.get(series_id)
|
|
|
|
return series_data
|
|
|
|
|
|
async def get_tv_data_by_id(
|
|
tv_id: str, fetch_links: bool = False
|
|
) -> Optional[MediaFusionTVMetaData]:
|
|
tv_data = await MediaFusionTVMetaData.get(tv_id, fetch_links=fetch_links)
|
|
return tv_data
|
|
|
|
|
|
async def get_cached_torrent_streams(
|
|
cache_key: str,
|
|
video_id: str,
|
|
season: Optional[int] = None,
|
|
episode: Optional[int] = None,
|
|
) -> list[TorrentStreams]:
|
|
# Create a unique key for Redis
|
|
# Try to get the data from the Redis cache
|
|
cached_data = await REDIS_ASYNC_CLIENT.get(cache_key)
|
|
|
|
if cached_data is not None:
|
|
# If the data is in the cache, deserialize it and return it
|
|
streams = TorrentStreamsList.model_validate_json(cached_data).streams
|
|
else:
|
|
# If the data is not in the cache, query it from the database
|
|
if season is not None and episode is not None:
|
|
streams = (
|
|
await TorrentStreams.find(
|
|
{"meta_id": video_id, "is_blocked": {"$ne": True}}
|
|
)
|
|
.find(
|
|
{
|
|
"season.season_number": season,
|
|
"season.episodes.episode_number": episode,
|
|
}
|
|
)
|
|
.sort("-updated_at")
|
|
.to_list()
|
|
)
|
|
else:
|
|
streams = (
|
|
await TorrentStreams.find(
|
|
{"meta_id": video_id, "is_blocked": {"$ne": True}}
|
|
)
|
|
.sort("-updated_at")
|
|
.to_list()
|
|
)
|
|
|
|
torrent_streams = TorrentStreamsList(streams=streams)
|
|
|
|
# Serialize the data and store it in the Redis cache for 30 minutes
|
|
await REDIS_ASYNC_CLIENT.set(
|
|
cache_key, torrent_streams.model_dump_json(exclude_none=True), ex=1800
|
|
)
|
|
|
|
return streams
|
|
|
|
|
|
async def get_movie_streams(
|
|
user_data,
|
|
secret_str: str,
|
|
video_id: str,
|
|
user_ip: str | None,
|
|
background_tasks: BackgroundTasks,
|
|
) -> list[Stream]:
|
|
if video_id.startswith("dl"):
|
|
if not video_id.endswith(user_data.streaming_provider.service):
|
|
return []
|
|
return [
|
|
schemas.Stream(
|
|
name=f"MediaFusion {user_data.streaming_provider.service.title()} 🗑️💩",
|
|
description="🚨💀⚠️\nDelete all files in streaming provider",
|
|
url=f"{settings.host_url}/streaming_provider/{secret_str}/delete_all",
|
|
)
|
|
]
|
|
movie_metadata = await get_movie_data_by_id(video_id)
|
|
if not (movie_metadata and validate_parent_guide_nudity(movie_metadata, user_data)):
|
|
return []
|
|
|
|
cache_key = f"torrent_streams:{video_id}"
|
|
cached_streams = await get_cached_torrent_streams(cache_key, video_id)
|
|
|
|
if video_id.startswith("tt"):
|
|
new_streams = await run_scrapers(
|
|
movie_metadata,
|
|
"movie",
|
|
user_data,
|
|
)
|
|
all_streams = set(cached_streams).union(new_streams)
|
|
if new_streams:
|
|
# reset the cache
|
|
await REDIS_ASYNC_CLIENT.delete(cache_key)
|
|
background_tasks.add_task(store_new_torrent_streams, new_streams)
|
|
else:
|
|
all_streams = cached_streams
|
|
|
|
return await parse_stream_data(all_streams, user_data, secret_str, user_ip=user_ip)
|
|
|
|
|
|
async def get_series_streams(
|
|
user_data,
|
|
secret_str: str,
|
|
video_id: str,
|
|
season: int,
|
|
episode: int,
|
|
user_ip: str | None,
|
|
background_tasks: BackgroundTasks,
|
|
) -> list[Stream]:
|
|
series_metadata = await get_series_data_by_id(video_id, False)
|
|
if not (
|
|
series_metadata and validate_parent_guide_nudity(series_metadata, user_data)
|
|
):
|
|
return []
|
|
|
|
cache_key = f"torrent_streams:{video_id}:{season}:{episode}"
|
|
cached_streams = await get_cached_torrent_streams(
|
|
cache_key, video_id, season, episode
|
|
)
|
|
|
|
if video_id.startswith("tt"):
|
|
new_streams = await run_scrapers(
|
|
series_metadata,
|
|
"series",
|
|
user_data,
|
|
season,
|
|
episode,
|
|
)
|
|
all_streams = set(cached_streams).union(new_streams)
|
|
if new_streams:
|
|
# reset the cache
|
|
await REDIS_ASYNC_CLIENT.delete(cache_key)
|
|
background_tasks.add_task(store_new_torrent_streams, new_streams)
|
|
else:
|
|
all_streams = cached_streams
|
|
|
|
return await parse_stream_data(
|
|
all_streams,
|
|
user_data,
|
|
secret_str,
|
|
season,
|
|
episode,
|
|
user_ip=user_ip,
|
|
is_series=True,
|
|
)
|
|
|
|
|
|
async def store_new_torrent_streams(
|
|
streams: list[TorrentStreams] | set[TorrentStreams],
|
|
):
|
|
if not streams:
|
|
return
|
|
bulk_writer = BulkWriter()
|
|
|
|
for stream in streams:
|
|
try:
|
|
existing_stream = await TorrentStreams.get(stream.id)
|
|
if existing_stream:
|
|
update_data = {"seeders": stream.seeders, "updated_at": datetime.now()}
|
|
if stream.season and stream.season.episodes:
|
|
existing_episodes = {
|
|
ep.episode_number for ep in existing_stream.season.episodes
|
|
}
|
|
new_episodes = [
|
|
ep
|
|
for ep in stream.season.episodes
|
|
if ep.episode_number not in existing_episodes
|
|
]
|
|
if new_episodes:
|
|
logging.info(
|
|
"Adding new %s episodes to stream %s",
|
|
len(new_episodes),
|
|
stream.id,
|
|
)
|
|
update_data["season.episodes"] = (
|
|
existing_stream.season.episodes + new_episodes
|
|
)
|
|
await existing_stream.update(Set(update_data), bulk_writer=bulk_writer)
|
|
logging.info("Updated stream %s for %s", stream.id, stream.meta_id)
|
|
else:
|
|
await TorrentStreams.insert_one(stream, bulk_writer=bulk_writer)
|
|
logging.info("Added new stream %s for %s", stream.id, stream.meta_id)
|
|
except DuplicateKeyError:
|
|
logging.warning(
|
|
"Duplicate stream found: %s for %s", stream.id, stream.meta_id
|
|
)
|
|
|
|
await bulk_writer.commit()
|
|
|
|
|
|
async def get_tv_streams(video_id: str, namespace: str, user_data) -> list[Stream]:
|
|
tv_streams = await TVStreams.find(
|
|
{
|
|
"meta_id": video_id,
|
|
"is_working": True,
|
|
"namespaces": {"$in": [namespace, "mediafusion", None]},
|
|
},
|
|
).to_list()
|
|
|
|
return await parse_tv_stream_data(tv_streams, user_data)
|
|
|
|
|
|
async def get_movie_meta(meta_id: str, user_data: schemas.UserData):
|
|
movie_data = await get_movie_data_by_id(meta_id)
|
|
|
|
if not (movie_data and validate_parent_guide_nudity(movie_data, user_data)):
|
|
return {}
|
|
|
|
return {
|
|
"meta": {
|
|
"_id": meta_id,
|
|
"type": "movie",
|
|
"title": movie_data.title,
|
|
"poster": f"{settings.poster_host_url}/poster/movie/{meta_id}.jpg",
|
|
"background": movie_data.poster,
|
|
"description": movie_data.description,
|
|
"runtime": movie_data.runtime,
|
|
"website": movie_data.website,
|
|
}
|
|
}
|
|
|
|
|
|
async def get_series_meta(meta_id: str, user_data: schemas.UserData):
|
|
# Initialize the match filter
|
|
match_filter = {
|
|
"_id": meta_id,
|
|
}
|
|
|
|
if "Disable" not in user_data.nudity_filter:
|
|
match_filter["parent_guide_nudity_status"] = {"$nin": user_data.nudity_filter}
|
|
|
|
if "Disable" not in user_data.certification_filter:
|
|
match_filter["parent_guide_certificates"] = {
|
|
"$nin": get_filter_certification_values(user_data)
|
|
}
|
|
|
|
poster_path = f"{settings.poster_host_url}/poster/series/{meta_id}.jpg"
|
|
|
|
# Define the aggregation pipeline
|
|
pipeline = [
|
|
{"$match": match_filter},
|
|
{
|
|
"$lookup": {
|
|
"from": "TorrentStreams",
|
|
"localField": "_id",
|
|
"foreignField": "meta_id",
|
|
"as": "streams",
|
|
}
|
|
},
|
|
{"$unwind": "$streams"},
|
|
{
|
|
"$group": {
|
|
"_id": "$_id",
|
|
"series_title": {"$first": "$title"}, # Store series title separately
|
|
"poster": {"$first": "$poster"},
|
|
"background": {"$first": "$background"},
|
|
"streams": {"$push": "$streams"},
|
|
}
|
|
},
|
|
{
|
|
"$addFields": {
|
|
"videos": {
|
|
"$reduce": {
|
|
"input": "$streams",
|
|
"initialValue": [],
|
|
"in": {
|
|
"$concatArrays": [
|
|
"$$value",
|
|
{
|
|
"$map": {
|
|
"input": {
|
|
"$filter": {
|
|
"input": "$$this.season.episodes",
|
|
"as": "episode",
|
|
"cond": {"$ne": ["$$episode", None]},
|
|
}
|
|
},
|
|
"as": "episode",
|
|
"in": {
|
|
"id": {
|
|
"$concat": [
|
|
{"$toString": "$_id"},
|
|
":",
|
|
{
|
|
"$toString": "$$this.season.season_number"
|
|
},
|
|
":",
|
|
{
|
|
"$toString": "$$episode.episode_number"
|
|
},
|
|
]
|
|
},
|
|
"title": {
|
|
"$ifNull": [
|
|
"$$episode.title",
|
|
{
|
|
"$concat": [
|
|
"S",
|
|
{
|
|
"$toString": "$$this.season.season_number"
|
|
},
|
|
" EP",
|
|
{
|
|
"$toString": "$$episode.episode_number"
|
|
},
|
|
]
|
|
},
|
|
]
|
|
},
|
|
"season": "$$this.season.season_number",
|
|
"episode": "$$episode.episode_number",
|
|
"released": {
|
|
"$dateToString": {
|
|
"format": "%Y-%m-%dT%H:%M:%S.000Z",
|
|
"date": {
|
|
"$ifNull": [
|
|
"$$episode.released",
|
|
"$$this.created_at",
|
|
]
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
},
|
|
]
|
|
},
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{"$unwind": "$videos"},
|
|
{
|
|
"$group": {
|
|
"_id": {
|
|
"id": "$videos.id",
|
|
"meta_id": "$_id",
|
|
"series_title": "$series_title", # Store series title
|
|
"background": "$background",
|
|
},
|
|
"video": {"$first": "$videos"},
|
|
}
|
|
},
|
|
{
|
|
"$replaceRoot": {
|
|
"newRoot": {
|
|
"id": "$_id.id",
|
|
"season": "$video.season",
|
|
"episode": "$video.episode",
|
|
"meta_id": "$_id.meta_id",
|
|
"series_title": "$_id.series_title",
|
|
"background": "$_id.background",
|
|
"video": "$video",
|
|
}
|
|
}
|
|
},
|
|
{"$sort": {"season": 1, "episode": 1}},
|
|
{
|
|
"$group": {
|
|
"_id": "$meta_id",
|
|
"title": {"$first": "$series_title"},
|
|
"background": {"$first": "$background"},
|
|
"videos": {"$push": "$video"},
|
|
}
|
|
},
|
|
{
|
|
"$project": {
|
|
"_id": 0,
|
|
"meta": {
|
|
"_id": "$_id",
|
|
"type": {"$literal": "series"},
|
|
"title": "$title",
|
|
"poster": {"$literal": poster_path},
|
|
"background": {"$ifNull": ["$background", "$poster"]},
|
|
"videos": "$videos",
|
|
},
|
|
}
|
|
},
|
|
]
|
|
|
|
# Execute the aggregation pipeline
|
|
series_data = await MediaFusionSeriesMetaData.aggregate(pipeline).to_list()
|
|
|
|
return series_data[0] if series_data else {}
|
|
|
|
|
|
async def get_tv_meta(meta_id: str):
|
|
tv_data = await get_tv_data_by_id(meta_id)
|
|
|
|
if not tv_data:
|
|
return {}
|
|
|
|
return {
|
|
"meta": {
|
|
"_id": meta_id,
|
|
**tv_data.model_dump(),
|
|
"description": tv_data.description or tv_data.title,
|
|
}
|
|
}
|
|
|
|
|
|
async def get_existing_metadata(metadata, model):
|
|
filters = {"title": metadata["title"]}
|
|
year_filter = metadata.get("year", 0)
|
|
|
|
if issubclass(model, MediaFusionMovieMetaData):
|
|
filters["year"] = year_filter
|
|
else:
|
|
filters["year"] = {"$gte": year_filter}
|
|
filters["$or"] = [
|
|
{"end_year": {"$lte": year_filter}},
|
|
{"end_year": None},
|
|
]
|
|
|
|
return await model.find_one(
|
|
filters,
|
|
projection_model=schemas.MetaIdProjection,
|
|
)
|
|
|
|
|
|
def create_metadata_object(metadata, imdb_data, model):
|
|
poster = imdb_data.get("poster") or metadata.get("poster")
|
|
background = imdb_data.get("background", metadata.get("background", poster))
|
|
year = imdb_data.get("year", metadata.get("year"))
|
|
end_year = imdb_data.get("end_year", metadata.get("end_year"))
|
|
if isinstance(year, str) and "-" in year:
|
|
year, end_year = year.split("-")
|
|
return model(
|
|
id=metadata["id"],
|
|
title=imdb_data.get("title", metadata["title"]),
|
|
year=year,
|
|
end_year=end_year,
|
|
poster=poster,
|
|
background=background,
|
|
streams=[],
|
|
description=metadata.get("description"),
|
|
runtime=metadata.get("runtime"),
|
|
website=metadata.get("website"),
|
|
is_add_title_to_poster=metadata.get("is_add_title_to_poster", False),
|
|
stars=metadata.get("stars"),
|
|
aka_titles=imdb_data.get("aka_titles", metadata.get("aka_titles")),
|
|
genres=imdb_data.get("genres", metadata.get("genres")),
|
|
)
|
|
|
|
|
|
def create_stream_object(metadata, is_movie: bool = False):
|
|
catalog = metadata.get("catalog")
|
|
return TorrentStreams(
|
|
id=metadata["info_hash"],
|
|
torrent_name=metadata["torrent_name"],
|
|
announce_list=metadata["announce_list"],
|
|
size=metadata["total_size"],
|
|
filename=metadata["largest_file"]["filename"] if is_movie else None,
|
|
file_index=metadata["largest_file"]["index"] if is_movie else None,
|
|
languages=metadata.get("languages"),
|
|
resolution=metadata.get("resolution"),
|
|
codec=metadata.get("codec"),
|
|
quality=metadata.get("quality"),
|
|
audio=metadata.get("audio"),
|
|
source=metadata["source"],
|
|
catalog=[catalog] if isinstance(catalog, str) else catalog,
|
|
created_at=metadata["created_at"],
|
|
meta_id=metadata["id"],
|
|
)
|
|
|
|
|
|
async def get_or_create_metadata(metadata, media_type, is_imdb):
|
|
metadata_class = (
|
|
MediaFusionMovieMetaData if media_type == "movie" else MediaFusionSeriesMetaData
|
|
)
|
|
existing_data = await get_existing_metadata(metadata, metadata_class)
|
|
if not existing_data:
|
|
imdb_data = {}
|
|
if is_imdb:
|
|
imdb_data = search_imdb(metadata["title"], metadata.get("year"), media_type)
|
|
|
|
metadata["id"] = imdb_data.get(
|
|
"imdb_id", metadata.get("id", f"mf{uuid4().fields[-1]}")
|
|
)
|
|
is_exist_db = await metadata_class.find_one({"_id": metadata["id"]}).project(
|
|
schemas.MetaIdProjection
|
|
)
|
|
if not is_exist_db:
|
|
new_data = create_metadata_object(metadata, imdb_data, metadata_class)
|
|
try:
|
|
await new_data.create()
|
|
except DuplicateKeyError:
|
|
logging.warning("Duplicate %s found: %s", media_type, new_data.title)
|
|
else:
|
|
metadata["id"] = existing_data.id
|
|
|
|
return metadata
|
|
|
|
|
|
async def save_metadata(metadata: dict, media_type: str, is_imdb: bool = True):
|
|
if await is_torrent_stream_exists(metadata["info_hash"]):
|
|
logging.info("Stream already exists for %s %s", media_type, metadata["title"])
|
|
return
|
|
metadata = await get_or_create_metadata(metadata, media_type, is_imdb)
|
|
|
|
new_stream = create_stream_object(metadata, media_type == "movie")
|
|
if media_type == "series":
|
|
if metadata.get("episodes") and isinstance(metadata["episodes"][0], Episode):
|
|
episodes = metadata["episodes"]
|
|
else:
|
|
episodes = [
|
|
Episode(
|
|
episode_number=file["episodes"][0],
|
|
filename=file["filename"],
|
|
size=file["size"],
|
|
file_index=file["index"],
|
|
)
|
|
for file in metadata["file_data"]
|
|
if file["episodes"]
|
|
]
|
|
|
|
if not episodes:
|
|
logging.warning("No episodes found for series %s", metadata["title"])
|
|
return
|
|
new_stream.season = Season(
|
|
season_number=metadata.get("seasons")[0] if metadata.get("seasons") else 1,
|
|
episodes=episodes,
|
|
)
|
|
|
|
await new_stream.create()
|
|
logging.info(
|
|
"Added stream for %s %s, info_hash: %s",
|
|
media_type,
|
|
metadata["title"],
|
|
metadata["info_hash"],
|
|
)
|
|
|
|
|
|
async def save_movie_metadata(metadata: dict, is_imdb: bool = True):
|
|
await save_metadata(metadata, "movie", is_imdb)
|
|
|
|
|
|
async def save_series_metadata(metadata: dict, is_imdb: bool = True):
|
|
await save_metadata(metadata, "series", is_imdb)
|
|
|
|
|
|
async def process_search_query(
|
|
search_query: str, catalog_type: str, user_data: schemas.UserData
|
|
) -> dict:
|
|
# Get user's certification filter values
|
|
certification_values = get_filter_certification_values(user_data)
|
|
|
|
# Initialize the match filter
|
|
match_filter = {
|
|
"$text": {"$search": search_query}, # Perform the text search
|
|
"type": catalog_type,
|
|
}
|
|
|
|
if "Disable" not in user_data.nudity_filter:
|
|
match_filter["parent_guide_nudity_status"] = {"$nin": user_data.nudity_filter}
|
|
|
|
if "Disable" not in user_data.certification_filter:
|
|
match_filter["parent_guide_certificates"] = {"$nin": certification_values}
|
|
|
|
# Define the aggregation pipeline
|
|
pipeline = [
|
|
{"$match": match_filter},
|
|
{"$limit": 50}, # Limit the search results to 50
|
|
{
|
|
"$lookup": {
|
|
"from": TorrentStreams.get_collection_name(),
|
|
"localField": "_id",
|
|
"foreignField": "meta_id",
|
|
"pipeline": [
|
|
{
|
|
"$match": {
|
|
"catalog": {"$in": user_data.selected_catalogs},
|
|
"is_blocked": {"$ne": True},
|
|
}
|
|
},
|
|
{"$count": "num_torrents"},
|
|
],
|
|
"as": "torrent_count",
|
|
}
|
|
},
|
|
{"$match": {"torrent_count.num_torrents": {"$gt": 0}}},
|
|
{
|
|
"$set": {
|
|
"poster": {
|
|
"$concat": [
|
|
f"{settings.poster_host_url}/poster/{catalog_type}/",
|
|
"$_id",
|
|
".jpg",
|
|
]
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
# Execute the aggregation pipeline
|
|
search_results = (
|
|
await MediaFusionMetaData.get_motor_collection().aggregate(pipeline).to_list(50)
|
|
)
|
|
|
|
return {"metas": search_results}
|
|
|
|
|
|
async def process_tv_search_query(search_query: str, namespace: str) -> dict:
|
|
pipeline = [
|
|
{
|
|
"$match": {
|
|
"$text": {"$search": search_query}, # Perform the text search
|
|
"type": "tv",
|
|
}
|
|
},
|
|
{"$limit": 50}, # Limit the search results to 50
|
|
{
|
|
"$lookup": {
|
|
"from": TVStreams.get_collection_name(),
|
|
"localField": "_id",
|
|
"foreignField": "meta_id",
|
|
"pipeline": [
|
|
{
|
|
"$match": {
|
|
"is_working": True,
|
|
"namespaces": {"$in": [namespace, "mediafusion", None]},
|
|
}
|
|
},
|
|
{"$count": "num_working_streams"},
|
|
],
|
|
"as": "working_stream_count",
|
|
}
|
|
},
|
|
{
|
|
"$match": {
|
|
"working_stream_count.num_working_streams": {
|
|
"$gt": 0
|
|
} # Ensure at least one working stream exists
|
|
}
|
|
},
|
|
{
|
|
"$set": {
|
|
"poster": {
|
|
"$concat": [
|
|
f"{settings.poster_host_url}/poster/tv/",
|
|
"$_id",
|
|
".jpg",
|
|
]
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
# Execute the aggregation pipeline
|
|
search_results = (
|
|
await MediaFusionMetaData.get_motor_collection().aggregate(pipeline).to_list(50)
|
|
)
|
|
|
|
return {"metas": search_results}
|
|
|
|
|
|
async def get_stream_by_info_hash(info_hash: str) -> TorrentStreams | None:
|
|
stream = await TorrentStreams.find_one(
|
|
{"_id": info_hash, "is_blocked": {"$ne": True}}
|
|
)
|
|
return stream
|
|
|
|
|
|
async def is_torrent_stream_exists(info_hash: str) -> bool:
|
|
stream = await TorrentStreams.find_one({"_id": info_hash}).count()
|
|
return stream > 0
|
|
|
|
|
|
async def save_tv_channel_metadata(tv_metadata: schemas.TVMetaData) -> str:
|
|
channel_id = "mf" + crypto.get_text_hash(tv_metadata.title)
|
|
|
|
# Prepare the genres list
|
|
genres = list(
|
|
filter(
|
|
None,
|
|
set(tv_metadata.genres + [tv_metadata.country, tv_metadata.tv_language]),
|
|
)
|
|
)
|
|
|
|
# Ensure the channel document is upserted
|
|
try:
|
|
channel_data = await MediaFusionTVMetaData.get(channel_id)
|
|
if channel_data:
|
|
if channel_data.is_poster_working is False:
|
|
background = (
|
|
{"background": tv_metadata.background}
|
|
if tv_metadata.background
|
|
else {}
|
|
)
|
|
await channel_data.update(
|
|
Set(
|
|
{
|
|
"poster": tv_metadata.poster,
|
|
"is_poster_working": True,
|
|
**background,
|
|
}
|
|
)
|
|
)
|
|
else:
|
|
channel_data = MediaFusionTVMetaData(
|
|
id=channel_id,
|
|
title=tv_metadata.title,
|
|
poster=tv_metadata.poster,
|
|
background=tv_metadata.background,
|
|
country=tv_metadata.country,
|
|
tv_language=tv_metadata.tv_language,
|
|
logo=tv_metadata.logo,
|
|
genres=genres,
|
|
type="tv",
|
|
streams=[],
|
|
)
|
|
await channel_data.create()
|
|
except DuplicateKeyError:
|
|
pass
|
|
|
|
# Stream processing
|
|
bulk_writer = BulkWriter()
|
|
for stream in tv_metadata.streams:
|
|
# Define stream document with meta_id
|
|
stream_doc = TVStreams(
|
|
url=stream.url,
|
|
name=stream.name,
|
|
behaviorHints=(
|
|
stream.behaviorHints.model_dump(exclude_none=True)
|
|
if stream.behaviorHints
|
|
else None
|
|
),
|
|
ytId=stream.ytId,
|
|
source=stream.source,
|
|
country=stream.country,
|
|
meta_id=channel_id,
|
|
namespaces=[tv_metadata.namespace],
|
|
drm_key_id=stream.drm_key_id,
|
|
drm_key=stream.drm_key,
|
|
)
|
|
|
|
# Check if the stream exists (by URL or ytId) and upsert accordingly
|
|
existing_stream = await TVStreams.find_one(
|
|
TVStreams.url == stream.url,
|
|
TVStreams.ytId == stream.ytId,
|
|
)
|
|
if existing_stream == stream_doc:
|
|
update_data = {}
|
|
if (
|
|
stream.drm_key_id != existing_stream.drm_key_id
|
|
or stream.drm_key != existing_stream.drm_key
|
|
) and tv_metadata.namespace in existing_stream.namespaces:
|
|
update_data = {
|
|
"drm_key_id": stream.drm_key_id,
|
|
"drm_key": stream.drm_key,
|
|
}
|
|
if tv_metadata.namespace not in existing_stream.namespaces:
|
|
existing_stream.namespaces.append(tv_metadata.namespace)
|
|
update_data.update({"namespaces": existing_stream.namespaces})
|
|
|
|
if update_data:
|
|
await existing_stream.update(
|
|
Set(update_data),
|
|
bulk_writer=bulk_writer,
|
|
)
|
|
else:
|
|
await TVStreams.insert_one(stream_doc, bulk_writer=bulk_writer)
|
|
|
|
await bulk_writer.commit()
|
|
logging.info(f"Processed TV channel {tv_metadata.title}")
|
|
return channel_id
|
|
|
|
|
|
async def save_events_data(metadata: dict) -> str:
|
|
# Generate a unique event key
|
|
meta_id = "mf" + crypto.get_text_hash(metadata["title"])
|
|
event_key = f"event:{meta_id}"
|
|
|
|
# Attempt to fetch existing event data
|
|
existing_event_json = await REDIS_ASYNC_CLIENT.get(event_key)
|
|
|
|
if existing_event_json:
|
|
# Deserialize the existing event data
|
|
existing_event_data = MediaFusionEventsMetaData.model_validate_json(
|
|
existing_event_json
|
|
)
|
|
existing_streams = set(existing_event_data.streams)
|
|
else:
|
|
existing_streams = set()
|
|
|
|
# Update or add streams based on the uniqueness of 'url'
|
|
for stream in metadata["streams"]:
|
|
# Create a TVStreams instance for each stream
|
|
stream_instance = TVStreams(meta_id=meta_id, **stream)
|
|
existing_streams.add(stream_instance)
|
|
|
|
streams = list(existing_streams)
|
|
|
|
event_start_timestamp = metadata.get("event_start_timestamp", 0)
|
|
|
|
# Create or update the event data
|
|
events_data = MediaFusionEventsMetaData(
|
|
id=meta_id,
|
|
streams=streams,
|
|
title=metadata["title"],
|
|
description=metadata["description"] or metadata["title"],
|
|
genres=metadata.get("genres", []),
|
|
event_start_timestamp=event_start_timestamp,
|
|
poster=metadata.get("poster"),
|
|
background=metadata.get("background"),
|
|
logo=metadata.get("logo"),
|
|
is_add_title_to_poster=metadata.get("is_add_title_to_poster", False),
|
|
website=metadata.get("website"),
|
|
)
|
|
|
|
# Serialize the event data for storage
|
|
events_json = events_data.model_dump_json(exclude_none=True, by_alias=True)
|
|
|
|
# Set or update the event data in Redis with an appropriate TTL
|
|
cache_ttl = 86400 if events_data.event_start_timestamp == 0 else 3600
|
|
await REDIS_ASYNC_CLIENT.set(event_key, events_json, ex=cache_ttl)
|
|
|
|
logging.info(
|
|
f"{'Updating' if existing_event_json else 'Inserting'} event data for {events_data.title} with event key {event_key}"
|
|
)
|
|
|
|
# Add the event key to a set of all events
|
|
await REDIS_ASYNC_CLIENT.zadd("events:all", {event_key: event_start_timestamp})
|
|
|
|
# Index the event by genre
|
|
for genre in events_data.genres:
|
|
await REDIS_ASYNC_CLIENT.zadd(
|
|
f"events:genre:{genre}", {event_key: event_start_timestamp}
|
|
)
|
|
|
|
return event_key
|
|
|
|
|
|
async def get_events_meta_list(genre=None, skip=0, limit=25) -> list[schemas.Meta]:
|
|
if genre:
|
|
key_pattern = f"events:genre:{genre}"
|
|
else:
|
|
key_pattern = "events:all"
|
|
|
|
# Fetch event keys sorted by timestamp in descending order
|
|
events_keys = await REDIS_ASYNC_CLIENT.zrevrange(
|
|
key_pattern, skip, skip + limit - 1
|
|
)
|
|
events = []
|
|
|
|
# Iterate over event keys, fetching and decoding JSON data
|
|
for key in events_keys:
|
|
events_json = await REDIS_ASYNC_CLIENT.get(key)
|
|
if events_json:
|
|
meta_data = schemas.Meta.model_validate_json(events_json)
|
|
meta_data.poster = (
|
|
f"{settings.poster_host_url}/poster/events/{meta_data.id}.jpg"
|
|
)
|
|
events.append(meta_data)
|
|
else:
|
|
# Cleanup: Remove expired or missing event key from the index
|
|
await REDIS_ASYNC_CLIENT.zrem(key_pattern, key)
|
|
|
|
return events
|
|
|
|
|
|
async def get_event_meta(meta_id: str) -> dict:
|
|
events_key = f"event:{meta_id}"
|
|
events_json = await REDIS_ASYNC_CLIENT.get(events_key)
|
|
if not events_json:
|
|
return {}
|
|
|
|
event_data = MediaFusionTVMetaData.model_validate_json(events_json)
|
|
return {
|
|
"meta": {
|
|
"_id": meta_id,
|
|
**event_data.model_dump(),
|
|
}
|
|
}
|
|
|
|
|
|
async def get_event_data_by_id(meta_id: str) -> MediaFusionEventsMetaData | None:
|
|
event_key = f"event:{meta_id}"
|
|
events_json = await REDIS_ASYNC_CLIENT.get(event_key)
|
|
if not events_json:
|
|
return None
|
|
|
|
return MediaFusionEventsMetaData.model_validate_json(events_json)
|
|
|
|
|
|
async def get_event_streams(meta_id: str, user_data) -> list[Stream]:
|
|
event_key = f"event:{meta_id}"
|
|
event_json = await REDIS_ASYNC_CLIENT.get(event_key)
|
|
if not event_json:
|
|
return await parse_tv_stream_data([], user_data)
|
|
|
|
event_data = MediaFusionEventsMetaData.model_validate_json(event_json)
|
|
return await parse_tv_stream_data(event_data.streams, user_data)
|
|
|
|
|
|
async def get_genres(catalog_type: str) -> list[str]:
|
|
if catalog_type == "movie":
|
|
meta_class = MediaFusionMovieMetaData
|
|
elif catalog_type == "tv":
|
|
meta_class = MediaFusionTVMetaData
|
|
else:
|
|
meta_class = MediaFusionSeriesMetaData
|
|
|
|
genres = await REDIS_ASYNC_CLIENT.get(f"{catalog_type}_genres")
|
|
if genres:
|
|
return json.loads(genres)
|
|
|
|
genres = await meta_class.distinct("genres", {"genres": {"$ne": ""}})
|
|
|
|
# cache the genres for 30 minutes
|
|
await REDIS_ASYNC_CLIENT.set(f"{catalog_type}_genres", json.dumps(genres), ex=1800)
|
|
return genres
|
|
|
|
|
|
async def fetch_last_run(spider_id: str, spider_name: str):
|
|
task_key = f"background_tasks:run_spider:spider_name={spider_id}"
|
|
state_key = f"scrapy_stats:{spider_id}"
|
|
last_run_timestamp = await REDIS_ASYNC_CLIENT.get(task_key)
|
|
last_run_state = await REDIS_ASYNC_CLIENT.get(state_key)
|
|
|
|
if settings.disable_all_scheduler:
|
|
next_schedule_in = None
|
|
is_scheduler_disabled = True
|
|
else:
|
|
crontab_expression = getattr(settings, f"{spider_id}_scheduler_crontab")
|
|
is_scheduler_disabled = getattr(settings, f"disable_{spider_id}_scheduler")
|
|
cron_trigger = CronTrigger.from_crontab(crontab_expression)
|
|
next_time = cron_trigger.get_next_fire_time(
|
|
None, datetime.now(tz=cron_trigger.timezone)
|
|
)
|
|
next_schedule_in = humanize.naturaldelta(
|
|
next_time - datetime.now(tz=cron_trigger.timezone)
|
|
)
|
|
|
|
response = {
|
|
"name": spider_name,
|
|
"last_run": "Never",
|
|
"time_since_last_run": "Never run",
|
|
"time_since_last_run_seconds": -1,
|
|
"next_schedule_in": next_schedule_in,
|
|
"is_scheduler_disabled": is_scheduler_disabled,
|
|
"last_run_state": json.loads(last_run_state or "null"),
|
|
}
|
|
|
|
if last_run_timestamp:
|
|
last_run = datetime.fromtimestamp(float(last_run_timestamp))
|
|
delta = datetime.now() - last_run
|
|
response.update(
|
|
{
|
|
"last_run": last_run.isoformat(),
|
|
"time_since_last_run": humanize.precisedelta(
|
|
delta, minimum_unit="minutes"
|
|
),
|
|
"time_since_last_run_seconds": delta.total_seconds(),
|
|
}
|
|
)
|
|
|
|
return response
|