-
-
Notifications
You must be signed in to change notification settings - Fork 20.1k
Add subtitle downloader web application with OpenSubtitles API integration #1075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/ |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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() | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
+43
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "app.py" -path "*/subtitle-downloader/*" -type fRepository: f/prompts.chat Length of output: 86 🏁 Script executed: cat -n ./subtitle-downloader/app.py | head -100Repository: f/prompts.chat Length of output: 3501 🌐 Web query:
💡 Result:
What happens on malformed JSON?
What happens if Content-Type isn’t JSON?
Parameters that control this
Practical patterns1) Return a clean 400 instead of an exception page from flask import request
from werkzeug.exceptions import BadRequest
try:
data = request.get_json() # default: raises on bad JSON
except BadRequest:
return {"error": "Malformed JSON"}, 4002) Treat malformed JSON the same as “no JSON provided” data = request.get_json(silent=True)
if data is None:
return {"error": "Missing or invalid JSON"}, 4003) Centralize handling
Sources: Werkzeug [1] (werkzeug.palletsprojects.com) Citations:
🏁 Script executed: python3 <<'EOF'
# Test dict.get() behavior with different value types
data = {'key_with_null': None, 'key_with_num': 42, 'key_missing': None}
print("=== dict.get() behavior ===")
print(f"Missing key with default '': {repr(data.get('key_missing', ''))}")
print(f"Null value with default '': {repr(data.get('key_with_null', ''))}")
print(f"Numeric value with default '': {repr(data.get('key_with_num', ''))}")
print("\n=== str.strip() behavior ===")
try:
result = None.strip()
except AttributeError as e:
print(f"None.strip() → AttributeError: {e}")
try:
result = 42.strip()
except AttributeError as e:
print(f"42.strip() → AttributeError: {e}")
print(f"' hello '.strip() → {repr(' hello '.strip())}")
EOFRepository: f/prompts.chat Length of output: 161 🏁 Script executed: python3 <<'EOF'
# Test dict.get() behavior with different value types
data = {'key_with_null': None, 'key_with_num': 42, 'key_missing': None}
print("=== dict.get() behavior ===")
print(f"Missing key with default '': {repr(data.get('key_missing', ''))}")
print(f"Null value with default '': {repr(data.get('key_with_null', ''))}")
print(f"Numeric value with default '': {repr(data.get('key_with_num', ''))}")
print("\n=== str.strip() behavior ===")
try:
None.strip()
except AttributeError as e:
print(f"None.strip() → AttributeError: {e}")
try:
num = 42
num.strip()
except AttributeError as e:
print(f"42.strip() → AttributeError: {e}")
print(f"' hello '.strip() → {repr(' hello '.strip())}")
EOFRepository: f/prompts.chat Length of output: 387 🏁 Script executed: cat -n ./subtitle-downloader/app.py | sed -n '43,82p'Repository: f/prompts.chat Length of output: 1658 Bad JSON and type mismatches currently return 500 instead of 400. Lines 44–51 can fail before proper validation. Suggested fix- data = request.get_json()
+ data = request.get_json(silent=True)
- if not data:
- return jsonify({'error': 'No JSON data provided'}), 400
+ if not isinstance(data, dict):
+ return jsonify({'error': 'Invalid JSON body'}), 400
- series_name = data.get('series_name', '').strip()
- season_episode = data.get('season_episode', '').strip()
- language = data.get('language', '').strip()
+ series_name = data.get('series_name')
+ season_episode = data.get('season_episode')
+ language = data.get('language')
+
+ if not all(isinstance(value, str) for value in (series_name, season_episode, language)):
+ return jsonify({
+ 'error': 'series_name, season_episode, and language must be strings'
+ }), 400
+
+ series_name = series_name.strip()
+ season_episode = season_episode.strip()
+ language = language.strip()🤖 Prompt for AI Agents |
||||||||||||||||||||
| # 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/<subtitle_id>', 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'] | ||||||||||||||||||||
|
Comment on lines
+145
to
+148
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "app.py" | head -20Repository: f/prompts.chat Length of output: 86 🏁 Script executed: head -160 subtitle-downloader/app.py | tail -30Repository: f/prompts.chat Length of output: 825 🏁 Script executed: cat -n subtitle-downloader/app.py | sed -n '130,160p'Repository: f/prompts.chat Length of output: 693 🏁 Script executed: head -50 subtitle-downloader/app.pyRepository: f/prompts.chat Length of output: 1270 🏁 Script executed: grep -n "DEBUG\|config\|Config" subtitle-downloader/app.py | head -20Repository: f/prompts.chat Length of output: 318 🏁 Script executed: find . -type f -name "config.py" | head -10Repository: f/prompts.chat Length of output: 89 🏁 Script executed: cat -n subtitle-downloader/config.pyRepository: f/prompts.chat Length of output: 1985 Default Flask dev server to Lines 145-148 bind the dev server to all network interfaces. While this code only runs via Suggested fix app.run(
- host='0.0.0.0',
+ host=os.environ.get('HOST', '127.0.0.1'),
port=5000,
debug=app.config['DEBUG']
)📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.15.5)[error] 146-146: Possible binding to all interfaces (S104) 🤖 Prompt for AI Agents |
||||||||||||||||||||
| ) | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||||
| import os | ||||||
|
|
||||||
| class Config: | ||||||
| """Base configuration""" | ||||||
| SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded fallback secret key is a security risk in production. The fallback 🛡️ Proposed fix class Config:
"""Base configuration"""
- SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
+ SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = False
TESTING = FalseThen in class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
+
+ def __init__(self):
+ if not self.SECRET_KEY:
+ raise ValueError("SECRET_KEY environment variable must be set in production")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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 | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,3 @@ | ||||||||||||||
| Flask==2.3.0 | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check latest Flask version and security advisories
curl -s https://pypi.org/pypi/flask/json | jq '.info.version'
# Check for security advisories
gh api graphql -f query='
{
securityVulnerabilities(first: 5, ecosystem: PIP, package: "flask") {
nodes {
advisory {
summary
severity
publishedAt
}
vulnerableVersionRange
firstPatchedVersion {
identifier
}
}
}
}'Repository: f/prompts.chat Length of output: 226 🌐 Web query:
💡 Result: Flask 2.3.0 is affected by both advisories
Practical remediation for a Flask 2.3.0 app
Sources: GitHub Security Advisory for GHSA-m2qf-hxjv-5gpq [1], NVD CVE-2023-30861 [2], OSV GHSA-68rp-wp8r-4726 [3], NVD CVE-2026-27205 [4]. 🌐 Web query:
💡 Result: Flask 2.3.0 is affected by CVE-2023-30861: under certain conditions, Flask may send a refreshed permanent session cookie ( When it can happen (all must apply): behind a caching proxy that doesn’t ignore/strip cookies, Fix: upgrade Flask to 2.3.2+ (or 2.2.5 on the 2.2.x line). [1][2] Mitigations if you can’t upgrade immediately:
Sources: GitHub Advisory (GHSA-m2qf-hxjv-5gpq) / CVE-2023-30861, and NVD entry. [1][2] 🌐 Web query:
💡 Result: PYSEC-2023-62 (Flask) maps to CVE-2023-30861 / GHSA-m2qf-hxjv-5gpq: a scenario where a caching proxy can cache and replay responses across clients, potentially causing one client’s permanent When it can happen (all required):
Affected versions (per PyPI advisory aggregations):
Fixed versions:
Mitigations (if you can’t upgrade immediately):
Sources: GitHub Security Advisory (Flask) [1]; PYSEC-2023-62 aggregation with affected ranges [2]. [1] (github.com) Citations: Upgrade Flask from 2.3.0 to patch critical session cookie vulnerabilities. Flask 2.3.0 is affected by two HIGH severity vulnerabilities:
Minimum remediation: upgrade to Flask 2.3.2. For complete coverage of both vulnerabilities, upgrade to 3.1.3. 🧰 Tools🪛 OSV Scanner (2.3.3)[HIGH] 1-1: flask 2.3.0: undefined (PYSEC-2023-62) [HIGH] 1-1: flask 2.3.0: Flask session does not add [HIGH] 1-1: flask 2.3.0: Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header [HIGH] 1-1: flask 2.3.0: undefined (PYSEC-2023-62) [HIGH] 1-1: flask 2.3.0: Flask session does not add [HIGH] 1-1: flask 2.3.0: Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header 🤖 Prompt for AI Agents |
||||||||||||||
| requests==2.31.0 | ||||||||||||||
| python-dotenv==1.0.0 | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard unsupported
FLASK_ENVvalues at startup.Lines 18-19 index the config registry directly, so a typo or unexpected env value will fail during import and the app never starts. Please switch to an explicit lookup and raise a clear startup error or apply a deliberate fallback.
Suggested fix
env = os.environ.get('FLASK_ENV', 'development') -app.config.from_object(config[env]) +config_obj = config.get(env) +if config_obj is None: + raise RuntimeError(f"Unsupported FLASK_ENV: {env!r}") +app.config.from_object(config_obj)📝 Committable suggestion
🤖 Prompt for AI Agents