Improve Contribution torrents import workflow and add Telegram notifications

Implemented support for sports content, including metadata collection, validation, and episode file annotation logic with modal dialogs for detailed input. Added a Telegram bot for notifications regarding new contributions.
This commit is contained in:
mhdzumair
2024-12-30 14:13:33 +05:30
parent bd4199bbfe
commit d1783341d5
4 changed files with 739 additions and 121 deletions
+151 -21
View File
@@ -41,17 +41,89 @@
<!-- Torrent Upload Parameters -->
<div id="torrentUploadParameters" style="display: none;">
<div class="mb-3">
<label for="metaType" class="form-label">Content Type</label>
<select class="form-select" id="metaType" name="metaType" onchange="updateContentType()" required>
<option value="movie" {% if prefill_data.meta_type == 'movie' %}selected{% endif %}>Movie</option>
<option value="series" {% if prefill_data.meta_type == 'series' %}selected{% endif %}>Series</option>
<option value="sports">Sports Content</option>
</select>
</div>
<div class="mb-3" id="torrentImdbIdContainer">
<label for="torrentImdbId" class="form-label">IMDb ID</label>
<input type="text" class="form-control" id="torrentImdbId" name="torrentImdbId" maxlength="10" placeholder="Enter IMDb ID (e.g., tt1234567)"
value="{% if prefill_data.meta_id %}{{ prefill_data.meta_id }}{% endif %}">
</div>
<div class="mb-3">
<label for="metaType" class="form-label">Meta Type</label>
<select class="form-select" id="metaType" name="metaType" onchange="updateMetaType()" required>
<option value="movie" {% if prefill_data.meta_type == 'movie' %}selected{% endif %}>Movie</option>
<option value="series" {% if prefill_data.meta_type == 'series' %}selected{% endif %}>Series</option>
</select>
<!-- Sports Metadata Section -->
<div id="sportsMetadata" style="display: none;" class="mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sports Content Details</h5>
<div class="mb-3">
<label for="sportsCatalog" class="form-label">Sports Category *</label>
<select class="form-select" id="sportsCatalog" name="sportsCatalog" required>
<option value="american_football">American Football</option>
<option value="baseball">Baseball</option>
<option value="basketball">Basketball</option>
<option value="football">Football</option>
<option value="formula_racing">Formula Racing</option>
<option value="hockey">Hockey</option>
<option value="motogp_racing">MotoGP Racing</option>
<option value="other_sports">Other Sports</option>
<option value="rugby">Rugby/AFL</option>
<option value="fighting">Fighting (WWE, UFC)</option>
</select>
</div>
<div class="mb-3">
<label for="sportsTitle" class="form-label">Title *</label>
<input type="text" class="form-control" id="sportsTitle" name="sportsTitle" required>
</div>
<div class="mb-3">
<label for="sportsYear" class="form-label">Year</label>
<input type="text" class="form-control" id="sportsYear" name="sportsYear" placeholder="YYYY or YYYY-YYYY">
</div>
<div class="mb-3">
<label for="sportsPoster" class="form-label">Poster URL *</label>
<input type="url" class="form-control" id="sportsPoster" name="sportsPoster" required>
</div>
<div class="mb-3">
<label for="sportsBackground" class="form-label">Background URL</label>
<input type="url" class="form-control" id="sportsBackground" name="sportsBackground" placeholder="Leave empty to use poster">
</div>
<div class="mb-3">
<label for="sportsLogo" class="form-label">Logo URL</label>
<input type="url" class="form-control" id="sportsLogo" name="sportsLogo">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="addTitleToPoster" name="addTitleToPoster">
<label class="form-check-label" for="addTitleToPoster">
Add title to poster
</label>
</div>
</div>
<div class="mb-3">
<label for="sportsDescription" class="form-label">Description</label>
<textarea class="form-control" id="sportsDescription" name="sportsDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="sportsWebsite" class="form-label">Website</label>
<input type="url" class="form-control" id="sportsWebsite" name="sportsWebsite">
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="createdAt" class="form-label">Created At</label>
<input type="date" class="form-control" id="createdAt" name="createdAt" required>
@@ -77,7 +149,7 @@
</div>
<!-- Catalogs Selection -->
<div class="mb-3">
<div class="mb-3" id="catalogsSelection">
<label class="form-label">Catalogs (Optional)</label>
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i> Selecting catalogs helps organize content but is optional
@@ -114,24 +186,81 @@
</div>
</div>
<!-- Series Parameters -->
<div id="seriesParameters" class="mb-3" style="display: none;">
<div class="row">
<div class="col-md-6">
<label for="season" class="form-label">Season</label>
<input type="number" class="form-control" id="season" name="season" min="1" value="{% if prefill_data.season %}{{ prefill_data.season }}{% endif %}">
</div>
<div class="col-md-6">
<label for="episodes" class="form-label">Episodes</label>
<input type="text" class="form-control" id="episodes" name="episodes" value="{% if prefill_data.episode %}{{ prefill_data.episode }}{% endif %}"
placeholder="1,2,3 or 1-10">
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i> Enter episode numbers separated by commas or a range (e.g., 1,2,3 or 1-10)
<!-- File Annotation Modal -->
<div class="modal fade" id="fileAnnotationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Annotate Video Files</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Please specify season and episode numbers for each video file
</div>
<!-- Bulk Season Assignment -->
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title">Bulk Season Assignment</h6>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Season</label>
<div class="input-group">
<input type="number" class="form-control" id="bulkSeason" min="1" placeholder="Season number">
<button class="btn btn-outline-secondary" type="button" id="applyBulkSeason">Apply to All</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Multiple Seasons</label>
<div class="input-group">
<input type="text" class="form-control" id="multipleSeasons" placeholder="e.g., 1-3 or 1,2,4">
<button class="btn btn-outline-secondary" type="button" id="applyMultiSeasons">Apply Sequence</button>
</div>
<small class="text-muted">For multi-season packs. Will assign seasons sequentially to files.</small>
</div>
</div>
<!-- File grouping options -->
<div id="fileGroupingOptions" style="display: none;">
<h6 class="mt-3">Season Distribution</h6>
<div class="form-check">
<input class="form-check-input" type="radio" name="seasonDistribution" id="autoDistribute" value="auto" checked>
<label class="form-check-label" for="autoDistribute">
Auto-distribute episodes across seasons
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="seasonDistribution" id="manualGroup" value="manual">
<label class="form-check-label" for="manualGroup">
Manually group episodes per season
</label>
</div>
<div id="episodesPerSeason" class="mt-2" style="display: none;">
<label class="form-label">Episodes per season</label>
<input type="number" class="form-control" id="episodeCount" min="1" placeholder="Number of episodes per season">
</div>
</div>
</div>
</div>
<!-- File list for annotation -->
<div id="fileAnnotationList"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmAnnotation">Confirm</button>
</div>
</div>
</div>
</div>
<!-- Uploader Information -->
<div class="mb-3">
<label for="uploaderName" class="form-label">Uploader Name (Optional)</label>
<input type="text" class="form-control" id="uploaderName" name="uploaderName" placeholder="Enter your name or leave blank for Anonymous">
</div>
<!-- Upload Methods -->
<div class="mb-4">
<label class="form-label">Upload Method</label>
@@ -315,7 +444,8 @@
<div id="blockTorrentParameters" style="display: none;">
<div class="mb-3">
<label for="blockTorrentInfoHash" class="form-label">Torrent Info Hash</label>
<input type="text" class="form-control" id="blockTorrentInfoHash" name="blockTorrentInfoHash" required>
<input type="text" class="form-control" id="blockTorrentInfoHash" name="blockTorrentInfoHash" required
value="{% if prefill_data.info_hash %}{{ prefill_data.info_hash }}{% endif %}">
</div>
</div>
</div>
+330 -67
View File
@@ -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 = `
<div class="card mb-3">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">${file.filename}</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label">Season</label>
<input type="number" class="form-control season-input"
id="season-${index}"
data-index="${index}"
value="${file.season_number || ''}"
min="1">
</div>
<div class="col-md-6">
<label class="form-label">Episode</label>
<input type="number" class="form-control"
id="episode-${index}"
value="${file.episode_number || ''}"
min="1">
</div>
</div>
${isSportsContent ? `
<div class="episode-metadata mt-3">
<div class="mb-2">
<label class="form-label">Episode Title</label>
<input type="text" class="form-control"
id="title-${index}"
placeholder="Optional">
</div>
<div class="mb-2">
<label class="form-label">Episode Overview</label>
<textarea class="form-control"
id="overview-${index}"
rows="2"
placeholder="Optional"></textarea>
</div>
<div class="mb-2">
<label class="form-label">Thumbnail URL</label>
<input type="url" class="form-control"
id="thumbnail-${index}"
placeholder="Optional">
</div>
<div class="mb-2">
<label class="form-label">Release Date</label>
<input type="date" class="form-control"
id="release-${index}">
</div>
</div>
` : `
`}
</div>
</div>`;
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);
+139 -33
View File
@@ -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"),
+119
View File
@@ -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()