-
Enter episode numbers separated by commas or a range (e.g., 1,2,3 or 1-10)
+
+
+
+
+
+
+
+ Please specify season and episode numbers for each video file
+
+
+
+
+
+
Bulk Season Assignment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
For multi-season packs. Will assign seasons sequentially to files.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -315,7 +444,8 @@
diff --git a/resources/js/scraperControl.js b/resources/js/scraperControl.js
index 6441253..25eb65c 100644
--- a/resources/js/scraperControl.js
+++ b/resources/js/scraperControl.js
@@ -255,19 +255,234 @@ function showConfirmationDialog(validationErrors, torrentData, infoHash) {
});
}
-function updateMetaType() {
+function updateContentType() {
const metaType = document.getElementById('metaType').value;
- if (metaType === 'movie') {
- setElementDisplay('catalogsSeries', 'none');
- setElementDisplay('catalogsMovie', 'block');
- setElementDisplay('seriesParameters', 'none');
+
+ if (metaType === 'sports') {
+ setElementDisplay('sportsMetadata', 'block');
+ setElementDisplay('torrentImdbIdContainer', 'none');
+ setElementDisplay('catalogsSelection', 'none');
} else {
- setElementDisplay('catalogsMovie', 'none');
- setElementDisplay('catalogsSeries', 'block');
- setElementDisplay('seriesParameters', 'block');
+ setElementDisplay('sportsMetadata', 'none');
+ setElementDisplay('torrentImdbIdContainer', 'block');
+ setElementDisplay('catalogsSelection', 'block');
+ if (metaType === 'movie') {
+ setElementDisplay('catalogsSeries', 'none');
+ setElementDisplay('catalogsMovie', 'block');
+ } else {
+ setElementDisplay('catalogsMovie', 'none');
+ setElementDisplay('catalogsSeries', 'block');
+ }
}
}
+function collectSportsMetadata() {
+ return {
+ title: document.getElementById('sportsTitle').value,
+ year: document.getElementById('sportsYear').value,
+ poster: document.getElementById('sportsPoster').value,
+ background: document.getElementById('sportsBackground').value,
+ logo: document.getElementById('sportsLogo').value,
+ description: document.getElementById('sportsDescription').value,
+ website: document.getElementById('sportsWebsite').value,
+ is_add_title_to_poster: document.getElementById('addTitleToPoster').checked,
+ catalogs: document.getElementById('sportsCatalog').value
+ };
+}
+
+function parseSeasonsInput(input) {
+ const seasons = [];
+ const parts = input.split(',');
+
+ for (const part of parts) {
+ if (part.includes('-')) {
+ const [start, end] = part.split('-').map(num => parseInt(num.trim()));
+ for (let i = start; i <= end; i++) {
+ seasons.push(i);
+ }
+ } else {
+ const season = parseInt(part.trim());
+ if (!isNaN(season)) {
+ seasons.push(season);
+ }
+ }
+ }
+
+ return seasons;
+}
+
+function showFileAnnotationModal(files) {
+ const modal = document.getElementById('fileAnnotationModal');
+ const fileList = document.getElementById('fileAnnotationList');
+ fileList.innerHTML = '';
+
+ const isSportsContent = document.getElementById('metaType').value === 'sports';
+
+ // Sort files by filename
+ files.sort((a, b) => {
+ return a.filename.localeCompare(b.filename, undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ });
+ });
+
+ files.forEach((file, index) => {
+ const fileRow = `
+
+
+
${file.filename}
+
+
+ ${isSportsContent ? `
+
+ ` : `
+ `}
+
+
`;
+ fileList.insertAdjacentHTML('beforeend', fileRow);
+ });
+
+ // Set up bulk season assignment handler
+ document.getElementById('applyBulkSeason').onclick = () => {
+ const season = document.getElementById('bulkSeason').value;
+ if (season) {
+ document.querySelectorAll('.season-input').forEach(input => {
+ input.value = season;
+ });
+ }
+ };
+
+ // Set up multiple seasons handler
+ document.getElementById('applyMultiSeasons').onclick = () => {
+ const seasonsInput = document.getElementById('multipleSeasons').value;
+ if (!seasonsInput) return;
+
+ const seasons = parseSeasonsInput(seasonsInput);
+ if (seasons.length === 0) return;
+
+ const fileGroupingOptions = document.getElementById('fileGroupingOptions');
+ fileGroupingOptions.style.display = 'block';
+
+ const applySeasons = () => {
+ const distribution = document.querySelector('input[name="seasonDistribution"]:checked').value;
+ const episodesPerSeason = parseInt(document.getElementById('episodeCount').value) || 0;
+
+ const seasonInputs = document.querySelectorAll('.season-input');
+ if (distribution === 'auto') {
+ // Distribute episodes evenly across seasons
+ const filesPerSeason = Math.ceil(seasonInputs.length / seasons.length);
+ seasonInputs.forEach((input, index) => {
+ const seasonIndex = Math.floor(index / filesPerSeason);
+ input.value = seasons[Math.min(seasonIndex, seasons.length - 1)];
+ });
+ } else {
+ // Manual distribution based on episodes per season
+ if (episodesPerSeason > 0) {
+ seasonInputs.forEach((input, index) => {
+ const seasonIndex = Math.floor(index / episodesPerSeason);
+ input.value = seasons[Math.min(seasonIndex, seasons.length - 1)];
+ });
+ }
+ }
+ };
+
+ // Set up distribution method handlers
+ document.querySelectorAll('input[name="seasonDistribution"]').forEach(radio => {
+ radio.onchange = () => {
+ document.getElementById('episodesPerSeason').style.display =
+ radio.value === 'manual' ? 'block' : 'none';
+ if (radio.value === 'auto') {
+ applySeasons();
+ }
+ };
+ });
+
+ document.getElementById('episodeCount').onchange = () => {
+ if (document.getElementById('manualGroup').checked) {
+ applySeasons();
+ }
+ };
+
+ // Initial application
+ applySeasons();
+ };
+
+ const bsModal = new bootstrap.Modal(modal);
+ bsModal.show();
+
+ return new Promise((resolve, reject) => {
+ document.getElementById('confirmAnnotation').onclick = () => {
+ const annotatedFiles = files.map((file, index) => {
+ const baseData = {
+ ...file,
+ season_number: parseInt(document.getElementById(`season-${index}`).value) || null,
+ episode_number: parseInt(document.getElementById(`episode-${index}`).value) || null,
+ };
+
+ if (isSportsContent) {
+ return {
+ ...baseData,
+ title: document.getElementById(`title-${index}`).value || null,
+ overview: document.getElementById(`overview-${index}`).value || null,
+ thumbnail: document.getElementById(`thumbnail-${index}`).value || null,
+ release_date: document.getElementById(`release-${index}`).value || null,
+
+ };
+ }
+ return baseData;
+ });
+ bsModal.hide();
+ resolve(annotatedFiles);
+ };
+
+ modal.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('Annotation cancelled'));
+ }, {once: true});
+ });
+}
+
+
function toggleInput(disableId, input) {
document.getElementById(disableId).disabled = !!input.value;
}
@@ -293,7 +508,7 @@ function updateFormFields() {
case 'add_torrent':
setElementDisplay("torrentUploadParameters", "block");
authRequired = false;
- updateMetaType();
+ updateContentType();
break;
case 'scrapy':
// Show Scrapy-specific parameters
@@ -389,40 +604,62 @@ function constructTvMetadata() {
return tvMetaData;
}
-
-async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false) {
+async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false, annotatedFiles = null) {
let formData = new FormData();
- const imdbId = document.getElementById('torrentImdbId').value;
const metaType = document.getElementById('metaType').value;
+ const isSportsContent = metaType === 'sports';
- // Basic validation
- const imdbIdNumeric = parseInt(imdbId.slice(2), 10);
- if (!imdbId.startsWith('tt') || imdbId.length < 3 || imdbId.length > 10 || isNaN(imdbIdNumeric)) {
- showNotification('Invalid IMDb ID', 'error');
- resetButton(submitBtn, loadingSpinner);
- return;
- }
-
- formData.append('meta_id', imdbId);
- formData.append('meta_type', metaType);
-
- // Handle optional catalogs
- const catalogInputs = metaType === 'movie'
- ? document.querySelectorAll('#catalogsMovie input[name="catalogs"]:checked')
- : document.querySelectorAll('#catalogsSeries input[name="catalogs"]:checked');
-
- const catalogs = Array.from(catalogInputs).map(el => el.value);
- if (catalogs.length > 0) {
- formData.append('catalogs', catalogs.join(','));
- }
-
- // Handle languages
- const selectedLanguages = Array.from(document.querySelectorAll('input[name="languages"]:checked'))
- .map(el => el.value);
- if (selectedLanguages.length > 0) {
- formData.append('languages', selectedLanguages.join(','));
+ // Handle sports content metadata
+ if (isSportsContent) {
+ const sportsMetadata = collectSportsMetadata();
+
+ // Validate required fields
+ if (!sportsMetadata.title || !sportsMetadata.poster || !sportsMetadata.catalogs) {
+ showNotification('Title, poster, and sports category are required.', 'error');
+ resetButton(submitBtn, loadingSpinner);
+ return;
+ }
+
+ // Add sports metadata to formData
+ Object.entries(sportsMetadata).forEach(([key, value]) => {
+ if (value !== null && value !== '') {
+ formData.append(key, value);
+ }
+ });
+
+ // Set meta_type based on catalog
+ const isSeriesType = ['formula_racing', 'motogp_racing'].includes(sportsMetadata.catalog);
+ formData.append('meta_type', isSeriesType ? 'series' : 'movie');
+ formData.append('is_sports_content', 'true');
+ } else {
+ // Handle regular IMDb content
+ const imdbId = document.getElementById('torrentImdbId').value;
+ const imdbIdNumeric = parseInt(imdbId.slice(2), 10);
+ if (!imdbId.startsWith('tt') || imdbId.length < 3 || imdbId.length > 10 || isNaN(imdbIdNumeric)) {
+ showNotification('Invalid IMDb ID', 'error');
+ resetButton(submitBtn, loadingSpinner);
+ return;
+ }
+ formData.append('meta_id', imdbId);
+ formData.append('meta_type', metaType);
+
+ // Handle optional catalogs
+ const catalogInputs = metaType === 'movie'
+ ? document.querySelectorAll('#catalogsMovie input[name="catalogs"]:checked')
+ : document.querySelectorAll('#catalogsSeries input[name="catalogs"]:checked');
+
+ const catalogs = Array.from(catalogInputs).map(el => el.value);
+ if (catalogs.length > 0) {
+ formData.append('catalogs', catalogs.join(','));
+ }
+ const selectedLanguages = Array.from(document.querySelectorAll('input[name="languages"]:checked'))
+ .map(el => el.value);
+ if (selectedLanguages.length > 0) {
+ formData.append('languages', selectedLanguages.join(','));
+ }
}
+ // Handle common form fields
const createdAt = document.getElementById('createdAt').value;
if (!createdAt) {
showNotification('Created At is required.', 'error');
@@ -431,28 +668,11 @@ async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false)
}
formData.append('created_at', createdAt);
- if (metaType === 'series') {
- const season = document.getElementById('season').value;
- let episodes = document.getElementById('episodes').value;
- if (!season || !episodes) {
- showNotification('Season and Episodes are required for TV Series.', 'error');
- resetButton(submitBtn, loadingSpinner);
- return;
- }
- if (episodes.includes('-')) {
- const [start, end] = episodes.split('-');
- episodes = Array.from({length: end - start + 1}, (_, i) => parseInt(start) + i).join(',');
- } else {
- episodes = episodes.split(',').map(e => e.trim());
- }
-
- formData.append('season', season);
- formData.append('episodes', episodes);
- }
-
+ // Handle torrent file/magnet
const magnetLink = document.getElementById('magnetLink').value;
const torrentFile = document.getElementById('torrentFile').files[0];
const torrentType = document.getElementById('torrentType').value;
+
if (!magnetLink && !torrentFile) {
showNotification('Either Magnet Link or Torrent File is required.', 'error');
resetButton(submitBtn, loadingSpinner);
@@ -464,8 +684,8 @@ async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false)
resetButton(submitBtn, loadingSpinner);
return;
}
- formData.append('torrent_type', torrentType);
+ formData.append('torrent_type', torrentType);
if (magnetLink) {
formData.append('magnet_link', magnetLink);
} else {
@@ -477,6 +697,15 @@ async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false)
formData.append('force_import', 'true');
}
+ // Add uploader name
+ const uploaderName = document.getElementById('uploaderName').value.trim() || 'Anonymous';
+ formData.append('uploader', uploaderName);
+
+ // Add annotated files if available
+ if (annotatedFiles) {
+ formData.append('file_data', JSON.stringify(annotatedFiles));
+ }
+
try {
const response = await fetch('/scraper/torrent', {
method: 'POST',
@@ -485,8 +714,19 @@ async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false)
const data = await response.json();
- if (data.status === 'validation_failed' && !forceImport) {
- // Show confirmation dialog
+ if (data.status === 'needs_annotation') {
+ try {
+ // Show modal for file annotation
+ const newAnnotatedFiles = await showFileAnnotationModal(data.files);
+ // Retry with the annotated files
+ await handleAddTorrent(submitBtn, loadingSpinner, forceImport, newAnnotatedFiles);
+ return;
+ } catch (annotationError) {
+ console.error('Error annotating files:', annotationError);
+ showNotification('File annotation was cancelled', 'warning');
+ }
+ } else if (data.status === 'validation_failed' && !forceImport) {
+ // Show confirmation dialog but preserve the annotated files
const shouldForceImport = await showConfirmationDialog(
data.errors,
data.torrent_data,
@@ -494,25 +734,46 @@ async function handleAddTorrent(submitBtn, loadingSpinner, forceImport = false)
);
if (shouldForceImport) {
- // Retry with force import
- submitBtn.disabled = true;
- loadingSpinner.style.display = 'inline-block';
- await handleAddTorrent(submitBtn, loadingSpinner, true);
+ // Pass the existing annotated files to the next attempt
+ await handleAddTorrent(submitBtn, loadingSpinner, true, annotatedFiles);
return;
}
+ } else if (data.status === 'validation_failed' && forceImport) {
+ showNotification(`Validation failed: ${JSON.stringify(data.errors)}`, 'error');
} else if (data.detail) {
showNotification(data.detail, 'error');
} else {
showNotification(data.status, 'success');
+ // Clear the form on success
+ resetForm();
}
} catch (error) {
- console.error('Error submitting scraper form:', error);
- showNotification(`Error submitting scraper form. Error: ${error.toString()}`, 'error');
+ console.error('Error submitting torrent:', error);
+ showNotification(`Error submitting torrent: ${error.toString()}`, 'error');
} finally {
resetButton(submitBtn, loadingSpinner);
}
}
+function resetForm() {
+ document.getElementById('torrentImdbId').value = '';
+ document.getElementById('magnetLink').value = '';
+ document.getElementById('torrentFile').value = '';
+ document.getElementById('uploaderName').value = '';
+
+ // Reset sports metadata if present
+ if (document.getElementById('sportsMetadata')) {
+ document.getElementById('sportsTitle').value = '';
+ document.getElementById('sportsYear').value = '';
+ document.getElementById('sportsPoster').value = '';
+ document.getElementById('sportsBackground').value = '';
+ document.getElementById('sportsLogo').value = '';
+ document.getElementById('sportsDescription').value = '';
+ document.getElementById('sportsWebsite').value = '';
+ document.getElementById('addTitleToPoster').checked = false;
+ }
+}
+
async function handleAddTvMetadata(payload, submitBtn, loadingSpinner) {
try {
payload['tv_metadata'] = constructTvMetadata();
@@ -714,5 +975,7 @@ async function submitScraperForm() {
// Initial update for form fields on page load
-document.addEventListener('DOMContentLoaded', updateFormFields);
+document.addEventListener('DOMContentLoaded', function () {
+ updateFormFields();
+});
document.getElementById('spiderName').addEventListener('change', toggleSpiderSpecificFields);
\ No newline at end of file
diff --git a/scrapers/routes.py b/scrapers/routes.py
index 19123e8..0e70334 100644
--- a/scrapers/routes.py
+++ b/scrapers/routes.py
@@ -1,6 +1,7 @@
+import json
import logging
from datetime import date, datetime
-from typing import Literal
+from typing import Literal, Optional
from uuid import uuid4
from fastapi import HTTPException, UploadFile, File, Form, APIRouter, BackgroundTasks
@@ -14,17 +15,19 @@ from db.crud import (
get_series_data_by_id,
get_stream_by_info_hash,
store_new_torrent_streams,
+ update_metadata,
+ get_or_create_metadata,
)
from db.enums import TorrentType
from db.models import TorrentStreams, EpisodeFile
from db.redis_database import REDIS_ASYNC_CLIENT
from mediafusion_scrapy.task import run_spider
-from scrapers import imdb_data
from scrapers.tv import add_tv_metadata, parse_m3u_playlist
from utils import const, torrent
from utils.network import get_request_namespace
-from utils.parser import calculate_max_similarity_ratio
+from utils.parser import calculate_max_similarity_ratio, convert_bytes_to_readable
from utils.runtime_const import TEMPLATES
+from utils.telegram_bot import telegram_notifier
router = APIRouter()
@@ -36,7 +39,7 @@ def validate_api_password(api_password: str):
def raise_error(error_msg: str):
- error_msg = f"User upload Failed due to: {error_msg}"
+ error_msg = f"Failed due to: {error_msg}"
logging.warning(error_msg)
raise HTTPException(status_code=200, detail=error_msg)
@@ -44,16 +47,20 @@ def raise_error(error_msg: str):
@router.get("/", tags=["scraper"])
async def get_scraper(
request: Request,
+ action: str = None,
meta_id: str = None,
meta_type: str = None,
season: int = None,
episode: str = None,
+ info_hash: str = None,
):
prefill_data = {
"meta_id": meta_id,
"meta_type": meta_type,
"season": season,
"episode": episode,
+ "action": action,
+ "info_hash": info_hash,
}
return TEMPLATES.TemplateResponse(
"html/scraper.html",
@@ -157,7 +164,7 @@ async def update_imdb_data(
if not movie:
raise_error(f"Movie with ID {meta_id} not found.")
- await imdb_data.process_imdb_data([meta_id], media_type)
+ await update_metadata([meta_id], media_type)
if redirect_video:
return RedirectResponse(
@@ -169,17 +176,28 @@ async def update_imdb_data(
@router.post("/torrent", tags=["scraper"])
async def add_torrent(
- meta_id: str = Form(...),
meta_type: Literal["movie", "series"] = Form(...),
- catalogs: str = Form(None),
- languages: str = Form(None),
+ meta_id: Optional[str] = Form(None),
+ # Sports metadata fields
+ title: Optional[str] = Form(None),
+ year: Optional[int] = Form(None),
+ poster: Optional[str] = Form(None),
+ background: Optional[str] = Form(None),
+ logo: Optional[str] = Form(None),
+ description: Optional[str] = Form(None),
+ website: Optional[str] = Form(None),
+ is_add_title_to_poster: bool = Form(False),
+ catalogs: Optional[str] = Form(None),
+ is_sports_content: bool = Form(False),
+ # Common fields
+ languages: Optional[str] = Form(None),
created_at: date = Form(...),
- season: int | None = Form(None),
- episodes: str | None = Form(None),
torrent_type: TorrentType = Form(TorrentType.PUBLIC),
- magnet_link: str = Form(None),
- torrent_file: UploadFile = File(None),
+ magnet_link: Optional[str] = Form(None),
+ torrent_file: Optional[UploadFile] = File(None),
force_import: bool = Form(False),
+ file_data: Optional[str] = Form(None),
+ uploader: Optional[str] = Form("Anonymous"),
):
torrent_data: dict = {}
info_hash = None
@@ -189,8 +207,6 @@ async def add_torrent(
raise_error("Either magnet link or torrent file must be provided.")
if torrent_type != TorrentType.PUBLIC and not torrent_file:
raise_error(f"Torrent file must be provided for {torrent_type} torrents.")
- if not meta_id.startswith("tt") or not meta_id[2:].isdigit():
- raise_error("Invalid IMDb ID. Must start with 'tt'.")
# Convert catalogs string to list if provided
catalog_list = catalogs.split(",") if catalogs else []
@@ -198,9 +214,6 @@ async def add_torrent(
# Convert languages string to list if provided
language_list = languages.split(",") if languages else []
- if meta_type == "series" and not (season and episodes):
- raise_error("Season and episode number must be provided for series.")
-
# Process magnet link or torrent file
if magnet_link:
info_hash, trackers = torrent.parse_magnet(magnet_link)
@@ -233,6 +246,84 @@ async def add_torrent(
"status": f"Torrent already exists and attached to meta id: {torrent_stream.meta_id}"
}
+ # Handle sports content metadata
+ if is_sports_content:
+ if not title or not poster or not catalog_list:
+ raise_error("Title, poster, and sports catalog are required.")
+
+ # Validate catalog is in CATALOG_DATA
+ if catalog_list[0] not in const.CATALOG_DATA:
+ raise_error(
+ f"Invalid sports catalog. Must be one of: {', '.join(const.CATALOG_DATA.keys())}"
+ )
+
+ # Determine if it's a series type catalog
+ is_series_catalog = catalog_list[0] in ["formula_racing", "motogp_racing"]
+ if is_series_catalog:
+ meta_type = "series"
+ else:
+ meta_type = "movie"
+
+ # Create metadata for sports content
+ sports_metadata = {
+ "title": title,
+ "year": year,
+ "poster": poster,
+ "background": background or poster, # Use poster as fallback
+ "logo": logo,
+ "description": description,
+ "website": website,
+ "is_add_title_to_poster": is_add_title_to_poster,
+ "catalogs": catalog_list,
+ "created_at": created_at,
+ }
+
+ # Get or create metadata
+ metadata_result = await get_or_create_metadata(
+ sports_metadata, meta_type, is_search_imdb_title=False, is_imdb_only=False
+ )
+
+ if not metadata_result:
+ raise_error("Failed to create metadata for sports content.")
+
+ meta_id = metadata_result["id"]
+ else:
+ # Regular IMDb content validation
+ if not meta_id or not meta_id.startswith("tt") or not meta_id[2:].isdigit():
+ raise_error("Invalid IMDb ID. Must start with 'tt'.")
+
+ # For series, check if we need file annotation
+ if meta_type == "series":
+ has_all_metadata = all(
+ file.get("season_number") and file.get("episode_number")
+ for file in torrent_data.get("file_data", [])
+ )
+
+ # If we received annotated file data, update the torrent_data
+ if file_data:
+ annotated_files = json.loads(file_data)
+ torrent_data["file_data"] = annotated_files
+ # Update seasons and episodes based on annotations
+ seasons = {
+ file["season_number"]
+ for file in annotated_files
+ if file["season_number"]
+ }
+ episodes = {
+ file["episode_number"]
+ for file in annotated_files
+ if file["episode_number"]
+ }
+ torrent_data["seasons"] = list(seasons)
+ torrent_data["episodes"] = list(episodes)
+
+ # If automatic detection failed and we don't have annotations yet
+ elif not has_all_metadata:
+ return {
+ "status": "needs_annotation",
+ "files": torrent_data.get("file_data", []),
+ }
+
source = "Contribution Stream"
languages = set(language_list).union(torrent_data.get("languages", []))
torrent_data.update(
@@ -241,6 +332,7 @@ async def add_torrent(
"created_at": created_at,
"languages": languages,
"torrent_type": torrent_type,
+ "uploader": uploader,
}
)
if torrent_type == TorrentType.PUBLIC:
@@ -254,6 +346,8 @@ async def add_torrent(
torrent_data["catalog"] = ["contribution_stream"]
validation_errors = []
+ seasons_and_episodes = {}
+ stream_cache_keys = []
if meta_type == "movie":
movie_data = await get_movie_data_by_id(meta_id)
@@ -314,19 +408,7 @@ async def add_torrent(
)
# Episode validation
- episodes = [int(ep) for ep in episodes.split(",")]
- if torrent_data.get("episodes"):
- if (
- not set(torrent_data.get("episodes")).issubset(set(episodes))
- and not force_import
- ):
- validation_errors.append(
- {
- "type": "episode_mismatch",
- "message": f"Episode mismatch: '{torrent_data.get('episodes')}' != '{episodes}'",
- }
- )
- else:
+ if not torrent_data.get("episodes"):
validation_errors.append(
{
"type": "episodes_not_found",
@@ -355,9 +437,14 @@ async def add_torrent(
info_hash, torrent_data, meta_id
)
- stream_cache_keys = [
- f"torrent_streams:{meta_id}:{season}:{ep}" for ep in episodes
- ]
+ # Prepare seasons and episodes for cache cleanup
+ for ep in torrent_stream.episode_files:
+ if ep.season_number not in seasons_and_episodes:
+ seasons_and_episodes[ep.season_number] = []
+ seasons_and_episodes[ep.season_number].append(ep.episode_number)
+ stream_cache_keys.append(
+ f"torrent_streams:{meta_id}:{ep.season_number}:{ep.episode_number}"
+ )
if not torrent_stream:
raise_error("Failed to store torrent data. Contact support.")
@@ -366,6 +453,21 @@ async def add_torrent(
for key in stream_cache_keys:
await REDIS_ASYNC_CLIENT.delete(key)
+ # Send Telegram notification
+ if settings.telegram_bot_token:
+ await telegram_notifier.send_contribution_notification(
+ meta_id=meta_id,
+ title=title,
+ meta_type=meta_type,
+ poster=f"{settings.poster_host_url}/poster/{meta_type}/{meta_id}.jpg",
+ uploader=uploader,
+ info_hash=info_hash,
+ torrent_type=torrent_type,
+ size=convert_bytes_to_readable(torrent_stream.size),
+ torrent_name=torrent_stream.torrent_name,
+ seasons_and_episodes=seasons_and_episodes,
+ )
+
return {
"status": f"Successfully added torrent: {torrent_stream.id} for {title}. Thanks for your contribution."
}
@@ -388,7 +490,9 @@ async def handle_movie_stream_store(info_hash, parsed_data, video_id):
codec=parsed_data.get("codec"),
quality=parsed_data.get("quality"),
audio=parsed_data.get("audio"),
+ hdr=parsed_data.get("hdr"),
source=parsed_data.get("source"),
+ uploader=parsed_data.get("uploader"),
catalog=parsed_data.get("catalog"),
updated_at=datetime.now(),
seeders=parsed_data.get("seeders"),
@@ -440,7 +544,9 @@ async def handle_series_stream_store(info_hash, parsed_data, video_id):
codec=parsed_data.get("codec"),
quality=parsed_data.get("quality"),
audio=parsed_data.get("audio"),
+ hdr=parsed_data.get("hdr"),
source=parsed_data.get("source"),
+ uploader=parsed_data.get("uploader"),
catalog=parsed_data.get("catalog"),
updated_at=datetime.now(),
seeders=parsed_data.get("seeders"),
diff --git a/utils/telegram_bot.py b/utils/telegram_bot.py
new file mode 100644
index 0000000..4e90dca
--- /dev/null
+++ b/utils/telegram_bot.py
@@ -0,0 +1,119 @@
+import logging
+from typing import Optional
+
+import aiohttp
+
+from db.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class TelegramNotifier:
+ def __init__(self):
+ self.bot_token = settings.telegram_bot_token
+ self.chat_id = settings.telegram_chat_id
+ self.base_url = f"https://api.telegram.org/bot{self.bot_token}"
+ self.enabled = bool(self.bot_token and self.chat_id)
+
+ async def send_contribution_notification(
+ self,
+ meta_id: str,
+ title: str,
+ meta_type: str,
+ poster: str,
+ uploader: str,
+ info_hash: str,
+ torrent_type: str,
+ size: str,
+ torrent_name: str,
+ seasons_and_episodes: Optional[dict] = None,
+ ):
+ """Send notification about new contribution to Telegram channel"""
+ if not self.enabled:
+ logger.warning("Telegram notifications are disabled. Check bot token.")
+ return
+
+ # Create block URL - will open scraper page with block_torrent action and info hash
+ block_url = (
+ f"{settings.host_url}/scraper?"
+ f"action=block_torrent&"
+ f"info_hash={info_hash}"
+ )
+ meta_id_data = (
+ f"*IMDb*: [{meta_id}](https://www.imdb.com/title/{meta_id}/)\n"
+ if meta_id.startswith("tt")
+ else f"Meta ID: {meta_id}\n"
+ )
+
+ # Build the message
+ message = (
+ f"🎬 New Contribution\n\n"
+ f"*Title*: {title}\n"
+ f"*Type*: {meta_type.title()}\n"
+ f"{meta_id_data}"
+ f"*Uploader*: {uploader}\n"
+ f"*Size*: {size}\n"
+ f"*Torrent Name*: `{torrent_name}`\n"
+ f"*Info Hash*: `{info_hash}`\n"
+ f"*Type*: {torrent_type}\n"
+ f"*Poster*: [View]({poster})"
+ )
+
+ # Add season/episode info for series
+ if meta_type == "series" and seasons_and_episodes:
+ message += "\n*Seasons*: "
+ for season, episodes in seasons_and_episodes.items():
+ message += f"\n- Season {season}: "
+ if len(episodes) == 1:
+ message += f"{episodes[0]}"
+ else:
+ message += f"{min(episodes)} - {max(episodes)}"
+ message += "\n"
+
+ # Add block link
+ message += f"\n\n[🚫 Block Torrent]({block_url})"
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ # Send a single message with photo and caption
+ async with session.post(
+ f"{self.base_url}/sendPhoto",
+ json={
+ "chat_id": self.chat_id,
+ "photo": poster,
+ "caption": message,
+ "parse_mode": "Markdown",
+ },
+ ) as response:
+ if not response.ok:
+ error_data = await response.json()
+ logger.error(
+ f"Failed to send Telegram notification: {error_data}"
+ )
+ # Fallback to text-only message if photo fails
+ await self._send_text_only_message(message)
+ return await response.json()
+ except Exception as e:
+ logger.error(f"Error sending Telegram notification: {e}")
+ # Fallback to text-only message if there's an error
+ await self._send_text_only_message(message)
+
+ async def _send_text_only_message(self, message: str):
+ """Fallback method to send text-only message if photo sending fails"""
+ try:
+ async with aiohttp.ClientSession() as session:
+ await session.post(
+ f"{self.base_url}/sendMessage",
+ json={
+ "chat_id": self.chat_id,
+ "text": message,
+ "parse_mode": "Markdown",
+ "disable_web_page_preview": False,
+ },
+ )
+ except Exception as e:
+ logger.error(f"Error sending fallback text message: {e}")
+
+
+# Create a singleton instance
+telegram_notifier = TelegramNotifier()