diff --git a/subtitle-downloader/.gitignore b/subtitle-downloader/.gitignore new file mode 100644 index 00000000000..8c773eeabd2 --- /dev/null +++ b/subtitle-downloader/.gitignore @@ -0,0 +1,39 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.env +.DS_Store +*.log +.idea/ +.vscode/ +*.swp +*.swo +.flake8 +.pytest_cache/ +downloads/ diff --git a/subtitle-downloader/app.py b/subtitle-downloader/app.py new file mode 100644 index 00000000000..db5ca3cc202 --- /dev/null +++ b/subtitle-downloader/app.py @@ -0,0 +1,149 @@ +"""Flask application for Subtitle Downloader""" + +import os +import logging +from flask import Flask, render_template, request, jsonify, send_file +from io import BytesIO +from config import config +from subtitle_handler import OpenSubtitlesHandler, validate_input, get_language_code + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create Flask app +app = Flask(__name__) + +# Load configuration +env = os.environ.get('FLASK_ENV', 'development') +app.config.from_object(config[env]) + +# Initialize OpenSubtitles handler +handler = OpenSubtitlesHandler() + + +@app.route('/') +def index(): + """Render main page""" + return render_template('index.html') + + +@app.route('/api/search', methods=['POST']) +def search_subtitles(): + """ + API endpoint to search for subtitles + + Expected JSON: + { + "series_name": "Breaking Bad", + "season_episode": "S01E01", + "language": "English" + } + """ + try: + data = request.get_json() + + if not data: + return jsonify({'error': 'No JSON data provided'}), 400 + + series_name = data.get('series_name', '').strip() + season_episode = data.get('season_episode', '').strip() + language = data.get('language', '').strip() + + # Validate input + validation = validate_input(series_name, season_episode, language) + if not validation['valid']: + return jsonify({ + 'error': 'Validation failed', + 'errors': validation['errors'] + }), 400 + + # Get language code + language_code = get_language_code(language) + + # Search for subtitles + subtitles = handler.search_subtitles(series_name, season_episode, language_code) + + if not subtitles: + return jsonify({ + 'success': True, + 'message': 'No subtitles found for this series and episode', + 'results': [] + }), 200 + + return jsonify({ + 'success': True, + 'message': f'Found {len(subtitles)} subtitle(s)', + 'results': subtitles + }), 200 + + except Exception as e: + logger.error(f"Search error: {str(e)}") + return jsonify({'error': 'An error occurred during search'}), 500 + + +@app.route('/api/download/', methods=['GET']) +def download_subtitle(subtitle_id): + """ + API endpoint to download a subtitle file + + Args: + subtitle_id: ID of the subtitle to download + """ + try: + # Download subtitle + content, file_name = handler.download_subtitle(subtitle_id) + + if content is None: + return jsonify({'error': 'Failed to download subtitle'}), 500 + + # Return file as attachment + return send_file( + BytesIO(content), + mimetype='text/plain', + as_attachment=True, + download_name=file_name + ) + + except Exception as e: + logger.error(f"Download error: {str(e)}") + return jsonify({'error': 'An error occurred during download'}), 500 + + +@app.route('/api/languages', methods=['GET']) +def get_languages(): + """API endpoint to get list of supported languages""" + try: + languages = handler.get_supported_languages() + return jsonify({ + 'success': True, + 'languages': languages + }), 200 + except Exception as e: + logger.error(f"Languages error: {str(e)}") + return jsonify({'error': 'Failed to fetch languages'}), 500 + + +@app.errorhandler(404) +def not_found(error): + """Handle 404 errors""" + return jsonify({'error': 'Not found'}), 404 + + +@app.errorhandler(500) +def server_error(error): + """Handle 500 errors""" + logger.error(f"Server error: {str(error)}") + return jsonify({'error': 'Internal server error'}), 500 + + +if __name__ == '__main__': + # Create downloads folder if it doesn't exist + os.makedirs(app.config['DOWNLOAD_FOLDER'], exist_ok=True) + + # Run the Flask app + app.run( + host='0.0.0.0', + port=5000, + debug=app.config['DEBUG'] + ) diff --git a/subtitle-downloader/config.py b/subtitle-downloader/config.py new file mode 100644 index 00000000000..c4fe059f37e --- /dev/null +++ b/subtitle-downloader/config.py @@ -0,0 +1,63 @@ +import os + +class Config: + """Base configuration""" + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + DEBUG = False + TESTING = False + + # OpenSubtitles API settings + OPENSUBTITLES_API_URL = 'https://api.opensubtitles.com/api/v1' + OPENSUBTITLES_USER_AGENT = 'DownloadSubtitles v1.0' + + # Supported languages mapping + LANGUAGES = { + 'English': 'en', + 'Spanish': 'es', + 'French': 'fr', + 'German': 'de', + 'Italian': 'it', + 'Portuguese': 'pt', + 'Russian': 'ru', + 'Japanese': 'ja', + 'Korean': 'ko', + 'Chinese': 'zh', + 'Dutch': 'nl', + 'Polish': 'pl', + 'Turkish': 'tr', + 'Greek': 'el', + 'Swedish': 'sv', + 'Norwegian': 'no', + 'Danish': 'da', + 'Finnish': 'fi', + 'Arabic': 'ar', + 'Hebrew': 'he', + } + + # File settings + DOWNLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'downloads') + MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB max file size + + +class DevelopmentConfig(Config): + """Development configuration""" + DEBUG = True + + +class ProductionConfig(Config): + """Production configuration""" + DEBUG = False + + +class TestingConfig(Config): + """Testing configuration""" + TESTING = True + + +# Configuration dictionary +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig +} diff --git a/subtitle-downloader/requirements.txt b/subtitle-downloader/requirements.txt new file mode 100644 index 00000000000..02967665c9e --- /dev/null +++ b/subtitle-downloader/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.3.0 +requests==2.31.0 +python-dotenv==1.0.0 diff --git a/subtitle-downloader/static/css/style.css b/subtitle-downloader/static/css/style.css new file mode 100644 index 00000000000..914df3e8689 --- /dev/null +++ b/subtitle-downloader/static/css/style.css @@ -0,0 +1,350 @@ +/* General Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + flex-direction: column; + color: #333; +} + +.container { + max-width: 900px; + width: 100%; + margin: 0 auto; + padding: 20px; + flex: 1; + display: flex; + flex-direction: column; +} + +/* Header */ +header { + text-align: center; + color: white; + margin-bottom: 40px; + padding-top: 20px; +} + +header h1 { + font-size: 2.5em; + margin-bottom: 10px; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); +} + +header .subtitle { + font-size: 1.1em; + opacity: 0.95; + font-weight: 300; +} + +/* Main Content */ +main { + flex: 1; +} + +/* Search Section */ +.search-section { + background: white; + border-radius: 10px; + padding: 30px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + margin-bottom: 30px; +} + +.search-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.form-group label { + font-weight: 600; + margin-bottom: 8px; + color: #333; + font-size: 0.95em; +} + +.form-group input, +.form-group select { + padding: 12px 15px; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 1em; + transition: all 0.3s ease; + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-group input::placeholder { + color: #999; +} + +.form-group small { + font-size: 0.85em; + color: #666; + margin-top: 4px; +} + +/* Search Button */ +.search-btn { + padding: 14px 30px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 1.05em; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + min-height: 48px; +} + +.search-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); +} + +.search-btn:active { + transform: translateY(0); +} + +.search-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-spinner { + font-size: 1.2em; +} + +/* Error Messages */ +.error-container { + background-color: #fee; + border-left: 4px solid #f44; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + animation: slideDown 0.3s ease; +} + +.error-message { + color: #c33; + font-weight: 500; + flex: 1; +} + +.error-close { + background: none; + border: none; + font-size: 1.5em; + color: #c33; + cursor: pointer; + padding: 0; + margin-left: 10px; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Results Section */ +.results-section { + background: white; + border-radius: 10px; + padding: 30px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.results-section h2 { + margin-bottom: 20px; + color: #333; + font-size: 1.5em; +} + +.results-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.subtitle-item { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.3s ease; + background-color: #f9f9f9; +} + +.subtitle-item:hover { + border-color: #667eea; + background-color: #f5f5ff; + box-shadow: 0 3px 10px rgba(102, 126, 234, 0.15); +} + +.subtitle-info { + flex: 1; +} + +.subtitle-name { + font-weight: 600; + color: #333; + margin-bottom: 6px; + word-break: break-word; +} + +.subtitle-meta { + display: flex; + gap: 15px; + font-size: 0.85em; + color: #666; +} + +.meta-item { + display: flex; + align-items: center; + gap: 4px; +} + +.download-btn { + padding: 10px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; + margin-left: 15px; +} + +.download-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); +} + +.download-btn:active { + transform: translateY(0); +} + +.download-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.no-results { + background: white; + border-radius: 10px; + padding: 40px; + text-align: center; + color: #666; + font-size: 1.1em; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +/* Footer */ +footer { + text-align: center; + color: white; + padding: 20px; + opacity: 0.9; + font-size: 0.9em; +} + +/* Responsive Design */ +@media (max-width: 768px) { + header h1 { + font-size: 2em; + } + + .form-row { + grid-template-columns: 1fr; + } + + .subtitle-item { + flex-direction: column; + align-items: flex-start; + } + + .download-btn { + margin-left: 0; + margin-top: 12px; + width: 100%; + } + + .search-section { + padding: 20px; + } + + .results-section { + padding: 20px; + } +} + +/* Loading State */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Animations */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spinner { + display: inline-block; + animation: spin 1s linear infinite; +} diff --git a/subtitle-downloader/static/js/script.js b/subtitle-downloader/static/js/script.js new file mode 100644 index 00000000000..45ad0e1e4e7 --- /dev/null +++ b/subtitle-downloader/static/js/script.js @@ -0,0 +1,271 @@ +/** + * Subtitle Downloader Frontend Script + */ + +// DOM Elements +const searchForm = document.getElementById('searchForm'); +const searchBtn = document.getElementById('searchBtn'); +const btnText = document.querySelector('.btn-text'); +const btnSpinner = document.querySelector('.btn-spinner'); +const errorContainer = document.getElementById('errorContainer'); +const errorMessage = document.getElementById('errorMessage'); +const resultsSection = document.getElementById('resultsSection'); +const resultsList = document.getElementById('resultsList'); +const noResults = document.getElementById('noResults'); + +// Event Listeners +searchForm.addEventListener('submit', handleSearch); + +/** + * Handle search form submission + */ +async function handleSearch(e) { + e.preventDefault(); + + // Get form values + const seriesName = document.getElementById('seriesName').value.trim(); + const seasonEpisode = document.getElementById('seasonEpisode').value.trim(); + const language = document.getElementById('language').value.trim(); + + // Validate input + const validation = validateInput(seriesName, seasonEpisode, language); + if (!validation.valid) { + showError(validation.errors.join('\n')); + return; + } + + // Clear previous results + clearResults(); + hideError(); + + // Show loading state + setButtonLoading(true); + + try { + const response = await fetch('/api/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + series_name: seriesName, + season_episode: seasonEpisode, + language: language + }) + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMsg = data.errors ? data.errors.join('\n') : data.error; + showError(errorMsg); + setButtonLoading(false); + return; + } + + if (!data.success) { + showError(data.error || 'Search failed'); + setButtonLoading(false); + return; + } + + // Handle results + if (data.results && data.results.length > 0) { + displayResults(data.results); + } else { + noResults.style.display = 'block'; + } + + } catch (error) { + showError('Network error. Please try again.'); + console.error('Search error:', error); + } finally { + setButtonLoading(false); + } +} + +/** + * Validate user input + */ +function validateInput(seriesName, seasonEpisode, language) { + const errors = []; + + if (!seriesName) { + errors.push('Series name is required'); + } else if (seriesName.length > 100) { + errors.push('Series name is too long'); + } + + if (!seasonEpisode) { + errors.push('Season and episode is required'); + } else if (!/^[sS]\d{1,2}[eE]\d{1,2}$|^\d{1,2}[xX]\d{1,2}$/.test(seasonEpisode)) { + errors.push('Invalid format. Use S01E01 or 1x1'); + } + + if (!language) { + errors.push('Language is required'); + } + + return { + valid: errors.length === 0, + errors: errors + }; +} + +/** + * Display search results + */ +function displayResults(subtitles) { + resultsSection.style.display = 'block'; + noResults.style.display = 'none'; + resultsList.innerHTML = ''; + + subtitles.forEach(subtitle => { + const item = createSubtitleItem(subtitle); + resultsList.appendChild(item); + }); + + // Scroll to results + resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +/** + * Create a subtitle item element + */ +function createSubtitleItem(subtitle) { + const div = document.createElement('div'); + div.className = 'subtitle-item'; + + const infoDiv = document.createElement('div'); + infoDiv.className = 'subtitle-info'; + + const nameDiv = document.createElement('div'); + nameDiv.className = 'subtitle-name'; + nameDiv.textContent = subtitle.file_name || subtitle.release_name || subtitle.id; + + const metaDiv = document.createElement('div'); + metaDiv.className = 'subtitle-meta'; + + if (subtitle.release_name) { + const releaseSpan = document.createElement('span'); + releaseSpan.className = 'meta-item'; + releaseSpan.textContent = `📺 ${subtitle.release_name}`; + metaDiv.appendChild(releaseSpan); + } + + if (subtitle.download_count !== undefined) { + const downloadSpan = document.createElement('span'); + downloadSpan.className = 'meta-item'; + downloadSpan.textContent = `📥 ${subtitle.downloads || 0} downloads`; + metaDiv.appendChild(downloadSpan); + } + + if (subtitle.upload_date) { + const dateSpan = document.createElement('span'); + dateSpan.className = 'meta-item'; + dateSpan.textContent = `📅 ${new Date(subtitle.upload_date).toLocaleDateString()}`; + metaDiv.appendChild(dateSpan); + } + + infoDiv.appendChild(nameDiv); + infoDiv.appendChild(metaDiv); + + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'download-btn'; + downloadBtn.textContent = '⬇️ Download'; + downloadBtn.onclick = (e) => handleDownload(e, subtitle.id, subtitle.file_name); + + div.appendChild(infoDiv); + div.appendChild(downloadBtn); + + return div; +} + +/** + * Handle subtitle download + */ +async function handleDownload(e, subtitleId, fileName) { + e.preventDefault(); + const btn = e.target; + + try { + btn.disabled = true; + btn.textContent = '⏳ Downloading...'; + + const response = await fetch(`/api/download/${subtitleId}`); + + if (!response.ok) { + throw new Error('Download failed'); + } + + // Create blob from response + const blob = await response.blob(); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName || `subtitle_${subtitleId}.srt`; + + // Trigger download + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + btn.textContent = '✓ Downloaded'; + setTimeout(() => { + btn.textContent = '⬇️ Download'; + btn.disabled = false; + }, 2000); + + } catch (error) { + showError('Download failed. Please try again.'); + console.error('Download error:', error); + btn.textContent = '⬇️ Download'; + btn.disabled = false; + } +} + +/** + * Show error message + */ +function showError(message) { + errorMessage.textContent = message; + errorContainer.style.display = 'flex'; +} + +/** + * Hide error message + */ +function hideError() { + errorContainer.style.display = 'none'; +} + +/** + * Clear results + */ +function clearResults() { + resultsList.innerHTML = ''; + resultsSection.style.display = 'none'; + noResults.style.display = 'none'; +} + +/** + * Set button loading state + */ +function setButtonLoading(loading) { + searchBtn.disabled = loading; + if (loading) { + btnText.style.display = 'none'; + btnSpinner.style.display = 'inline-block'; + } else { + btnText.style.display = 'inline-block'; + btnSpinner.style.display = 'none'; + } +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', () => { + console.log('Subtitle Downloader loaded'); +}); diff --git a/subtitle-downloader/subtitle_handler/__init__.py b/subtitle-downloader/subtitle_handler/__init__.py new file mode 100644 index 00000000000..96c06d00733 --- /dev/null +++ b/subtitle-downloader/subtitle_handler/__init__.py @@ -0,0 +1,6 @@ +"""Subtitle Handler Module""" + +from .opensubtitles import OpenSubtitlesHandler +from .utils import validate_input, get_language_code + +__all__ = ['OpenSubtitlesHandler', 'validate_input', 'get_language_code'] diff --git a/subtitle-downloader/subtitle_handler/opensubtitles.py b/subtitle-downloader/subtitle_handler/opensubtitles.py new file mode 100644 index 00000000000..ffa519e32be --- /dev/null +++ b/subtitle-downloader/subtitle_handler/opensubtitles.py @@ -0,0 +1,162 @@ +"""OpenSubtitles API handler""" + +import requests +import logging +from config import Config +from .utils import parse_season_episode + +logger = logging.getLogger(__name__) + + +class OpenSubtitlesHandler: + """Handler for OpenSubtitles API interactions""" + + def __init__(self): + self.api_url = Config.OPENSUBTITLES_API_URL + self.user_agent = Config.OPENSUBTITLES_USER_AGENT + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': self.user_agent + }) + + def search_subtitles(self, series_name, season_episode, language_code): + """ + Search for subtitles on OpenSubtitles + + Args: + series_name: Name of the TV series + season_episode: Season and episode (e.g., S01E01) + language_code: Language code (e.g., en, es, fr) + + Returns: + list: List of subtitle results or empty list if none found + """ + try: + se_info = parse_season_episode(season_episode) + if not se_info.get('valid'): + logger.error(f"Invalid season/episode format: {season_episode}") + return [] + + season = se_info['season'] + episode = se_info['episode'] + + # Build search parameters for OpenSubtitles API + params = { + 'query': series_name, + 'season_number': season, + 'episode_number': episode, + 'languages': language_code, + } + + logger.info(f"Searching subtitles: {series_name} S{season:02d}E{episode:02d} ({language_code})") + + # Make API request + response = self.session.get( + f"{self.api_url}/search", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + + # Parse and format results + subtitles = [] + if 'data' in data: + for item in data['data']: + subtitle = { + 'id': item.get('id'), + 'file_name': item.get('attributes', {}).get('files', [{}])[0].get('file_name', ''), + 'language': item.get('attributes', {}).get('language', ''), + 'release_name': item.get('attributes', {}).get('release', ''), + 'upload_date': item.get('attributes', {}).get('upload_date', ''), + 'downloads': item.get('attributes', {}).get('download_count', 0), + 'url': item.get('attributes', {}).get('url', ''), + } + subtitles.append(subtitle) + + logger.info(f"Found {len(subtitles)} subtitles") + return subtitles + + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {str(e)}") + return [] + except Exception as e: + logger.error(f"Error searching subtitles: {str(e)}") + return [] + + def download_subtitle(self, subtitle_id): + """ + Download subtitle file from OpenSubtitles + + Args: + subtitle_id: ID of the subtitle to download + + Returns: + tuple: (content, file_name) or (None, None) on failure + """ + try: + # Get download link for subtitle + response = self.session.get( + f"{self.api_url}/subtitles/{subtitle_id}/download", + timeout=10 + ) + response.raise_for_status() + + data = response.json() + + if 'link' in data: + # Download the actual subtitle file + download_response = self.session.get( + data['link'], + timeout=30 + ) + download_response.raise_for_status() + + # Extract filename from response headers if available + file_name = subtitle_id + '.srt' + if 'content-disposition' in download_response.headers: + import re + match = re.search(r'filename="?(.+?)"?(?:;|$)', download_response.headers['content-disposition']) + if match: + file_name = match.group(1) + + logger.info(f"Downloaded subtitle: {file_name}") + return download_response.content, file_name + + logger.error(f"No download link found for subtitle {subtitle_id}") + return None, None + + except requests.exceptions.RequestException as e: + logger.error(f"Download failed: {str(e)}") + return None, None + except Exception as e: + logger.error(f"Error downloading subtitle: {str(e)}") + return None, None + + def get_supported_languages(self): + """ + Get list of supported languages from OpenSubtitles API + + Returns: + dict: Dictionary of language codes and names + """ + try: + response = self.session.get( + f"{self.api_url}/languages", + timeout=10 + ) + response.raise_for_status() + + data = response.json() + languages = {} + + if 'data' in data: + for lang in data['data']: + languages[lang.get('code')] = lang.get('name') + + return languages + + except Exception as e: + logger.error(f"Error fetching languages: {str(e)}") + return Config.LANGUAGES diff --git a/subtitle-downloader/subtitle_handler/utils.py b/subtitle-downloader/subtitle_handler/utils.py new file mode 100644 index 00000000000..1bd55acf453 --- /dev/null +++ b/subtitle-downloader/subtitle_handler/utils.py @@ -0,0 +1,83 @@ +"""Utility functions for subtitle handling""" + +import re +from config import Config + + +def validate_input(series_name, season_episode, language): + """ + Validate user input for subtitle search + + Args: + series_name: Name of the TV series + season_episode: Season and episode in format "S01E01" or "1x1" + language: Language name + + Returns: + dict: {'valid': bool, 'error': str or None} + """ + errors = [] + + # Validate series name + if not series_name or not isinstance(series_name, str): + errors.append("Series name is required") + elif len(series_name.strip()) == 0: + errors.append("Series name cannot be empty") + elif len(series_name) > 100: + errors.append("Series name is too long (max 100 characters)") + + # Validate season/episode format + if not season_episode or not isinstance(season_episode, str): + errors.append("Season and episode information is required") + else: + # Try to parse S01E01 or 1x1 format + se_pattern = r'^[sS](\d{1,2})[eE](\d{1,2})$|^(\d{1,2})[xX](\d{1,2})$' + if not re.match(se_pattern, season_episode): + errors.append("Invalid format. Use S01E01 or 1x1 format (e.g., S01E05 or 1x5)") + + # Validate language + if not language or not isinstance(language, str): + errors.append("Language is required") + elif language not in Config.LANGUAGES: + errors.append(f"Language not supported. Supported languages: {', '.join(Config.LANGUAGES.keys())}") + + return { + 'valid': len(errors) == 0, + 'errors': errors + } + + +def get_language_code(language_name): + """ + Convert language name to ISO 639-1 code + + Args: + language_name: Full name of language (e.g., "English") + + Returns: + str: Language code (e.g., "en") or None if not found + """ + return Config.LANGUAGES.get(language_name) + + +def parse_season_episode(season_episode): + """ + Parse season and episode from string + + Args: + season_episode: String in format S01E01 or 1x1 + + Returns: + dict: {'season': int, 'episode': int} or {'valid': False} + """ + # Try S01E01 format + match = re.match(r'^[sS](\d{1,2})[eE](\d{1,2})$', season_episode) + if match: + return {'season': int(match.group(1)), 'episode': int(match.group(2)), 'valid': True} + + # Try 1x1 format + match = re.match(r'^(\d{1,2})[xX](\d{1,2})$', season_episode) + if match: + return {'season': int(match.group(1)), 'episode': int(match.group(2)), 'valid': True} + + return {'valid': False} diff --git a/subtitle-downloader/templates/index.html b/subtitle-downloader/templates/index.html new file mode 100644 index 00000000000..8372329826a --- /dev/null +++ b/subtitle-downloader/templates/index.html @@ -0,0 +1,100 @@ + + + + + + Subtitle Downloader + + + +
+
+

🎬 Subtitle Downloader

+

Download subtitles for your favorite TV shows

+
+ +
+
+
+
+ + +
+ +
+
+ + + Format: S01E05 or 1x5 +
+ +
+ + +
+
+ + +
+
+ + + + + + +
+ +
+

Subtitle Downloader | Powered by OpenSubtitles

+
+
+ + + +