Skip to content

Add subtitle downloader web application with OpenSubtitles API integration#1075

Open
Aharon123 wants to merge 1 commit intof:mainfrom
Aharon123:claude/subtitle-downloader-tool-mc2Mm
Open

Add subtitle downloader web application with OpenSubtitles API integration#1075
Aharon123 wants to merge 1 commit intof:mainfrom
Aharon123:claude/subtitle-downloader-tool-mc2Mm

Conversation

@Aharon123
Copy link

@Aharon123 Aharon123 commented Mar 15, 2026

Description

This PR introduces a complete web application for downloading TV show subtitles from OpenSubtitles API. The application includes:

  • Backend (Flask): REST API endpoints for searching and downloading subtitles
  • Frontend (HTML/CSS/JS): Responsive web interface with real-time search and download functionality
  • Subtitle Handler: OpenSubtitles API integration with input validation and error handling
  • Configuration: Environment-based config management with language support for 20+ languages

Key Features

  • Search subtitles by series name, season/episode, and language
  • Support for multiple season/episode formats (S01E01 or 1x1)
  • Direct subtitle file downloads with proper filename handling
  • Input validation and error messaging
  • Responsive design with gradient UI and smooth animations
  • Comprehensive logging for debugging

Files Added

  • app.py - Flask application with API endpoints
  • subtitle_handler/opensubtitles.py - OpenSubtitles API client
  • subtitle_handler/utils.py - Input validation and parsing utilities
  • subtitle_handler/__init__.py - Module exports
  • config.py - Configuration management (dev/prod/test)
  • templates/index.html - HTML template with form and results display
  • static/css/style.css - Complete styling with responsive design
  • static/js/script.js - Frontend logic for search, download, and UI interactions
  • requirements.txt - Python dependencies (Flask, requests, python-dotenv)
  • .gitignore - Standard Python project ignores

Type of Change

  • New feature
  • Bug fix
  • Documentation update
  • Other

Test Plan

The application can be tested by:

  1. Installing dependencies: pip install -r requirements.txt
  2. Running the Flask app: python app.py
  3. Accessing the web interface at http://localhost:5000
  4. Testing search functionality with valid series names and season/episode formats
  5. Verifying subtitle downloads work correctly
  6. Testing error handling with invalid inputs

https://claude.ai/code/session_01Xg36Q4fJoN1hKHsbpYbNCS

Summary by CodeRabbit

  • New Features
    • Launched a web-based subtitle search and download application.
    • Search for subtitles by series name, season/episode, and language.
    • Download subtitles directly with metadata including release name, upload date, and popularity.
    • Responsive interface with input validation and comprehensive error handling.

Creates a complete Flask-based web application for downloading TV show subtitles
from OpenSubtitles.com. The application allows users to:
- Search for subtitles by series name, season/episode, and language
- View available subtitles with metadata (release name, downloads, upload date)
- Download SRT files directly to their machine

Key components:
- Backend: Flask web framework with OpenSubtitles API integration
- Frontend: Responsive HTML/CSS/JavaScript interface
- API Layer: OpenSubtitlesHandler class for API communication
- Utils: Input validation and helper functions
- Configuration: Environment-based settings for multiple deployment modes

Project structure:
- app.py: Main Flask application with routes
- config.py: Configuration management (languages, API settings)
- subtitle_handler/: Module for subtitle operations
  - opensubtitles.py: OpenSubtitles API wrapper
  - utils.py: Input validation and parsing utilities
- templates/index.html: Web UI template
- static/css/style.css: Responsive styling with gradient background
- static/js/script.js: Frontend logic for search and download
- requirements.txt: Python dependencies

Tested:
- Python syntax validation for all modules
- Configuration loading
- Input validation functions
- Season/episode parsing

https://claude.ai/code/session_01Xg36Q4fJoN1hKHsbpYbNCS
@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

A new Flask-based web application for subtitle downloading is introduced, featuring API endpoints for searching and downloading subtitles via OpenSubtitles integration, a configuration system with environment-based settings, and a complete frontend UI with search, results display, and download functionality.

Changes

Cohort / File(s) Summary
Project Configuration
.gitignore, config.py, requirements.txt
Added Python project .gitignore patterns, environment-based configuration classes (DevelopmentConfig, ProductionConfig, TestingConfig) with OpenSubtitles API settings and language mappings, and pinned dependencies (Flask 2.3.0, requests 2.31.0, python-dotenv 1.0.0).
Backend API Layer
app.py, subtitle_handler/__init__.py, subtitle_handler/opensubtitles.py, subtitle_handler/utils.py
Established Flask application with REST API endpoints: / (index), /api/search (POST), /api/download/<subtitle_id> (GET), /api/languages (GET), plus error handlers. OpenSubtitlesHandler class manages API communication with search, download, and language-fetch methods. Utility functions validate input, parse season/episode formats (SxxExx or xx\x), and map language names to codes.
Frontend Interface
templates/index.html, static/css/style.css, static/js/script.js
Added HTML template with form inputs for series name, season/episode, and language selection. Comprehensive CSS stylesheet with global styling, responsive design, animations (loading spinner, slide-down errors), and interactive states. JavaScript module implements form submission, input validation with error collection, API request handling, dynamic results rendering, and per-item download triggering via blob conversion.

Sequence Diagrams

sequenceDiagram
    participant Client as Browser Client
    participant Server as Flask App
    participant Handler as OpenSubtitlesHandler
    participant API as OpenSubtitles API

    Client->>Server: POST /api/search (series_name, season_episode, language)
    Server->>Server: validate_input()
    Server->>Server: get_language_code()
    Server->>Handler: search_subtitles(name, episode, code)
    Handler->>Handler: parse_season_episode()
    Handler->>API: GET /search (query parameters)
    API-->>Handler: JSON response (subtitle list)
    Handler->>Handler: Parse and structure results
    Handler-->>Server: List of subtitles
    Server-->>Client: JSON with results or empty set
    Client->>Client: Display results or no-results message
Loading
sequenceDiagram
    participant Client as Browser Client
    participant Server as Flask App
    participant Handler as OpenSubtitlesHandler
    participant API as OpenSubtitles API
    participant FS as Filesystem

    Client->>Server: GET /api/download/<subtitle_id>
    Server->>Handler: download_subtitle(subtitle_id)
    Handler->>API: GET /subtitles/{id}/download
    API-->>Handler: Redirect URL + metadata
    Handler->>API: GET subtitle file (via redirect)
    API-->>Handler: File content (binary)
    Handler-->>Server: (content, file_name)
    Server->>Server: Create BytesIO object
    Server-->>Client: File as attachment (send_file)
    Client->>Client: Browser downloads file
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Whiskers twitching with delight,
A subtitle quest shines ever bright!
With Flask and search and download's call,
This web app serves the TV lovers all! ⭐🎬

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely summarizes the main change: adding a complete web application for subtitle downloading with OpenSubtitles API integration, matching the core objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes.

Add @coderabbitai placeholder anywhere in the title of your PR and CodeRabbit will replace it with a title based on the changes in the PR. You can change the placeholder by changing the reviews.auto_title_placeholder setting.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (5)
subtitle-downloader/.gitignore (2)

9-39: Remove duplicate downloads/ ignore entry.

downloads/ is listed at both Line 9 and Line 39. Keep a single entry to reduce config noise.

Proposed cleanup
@@
-downloads/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/.gitignore` around lines 9 - 39, Remove the duplicated
ignore entry "downloads/" in the .gitignore content: locate both occurrences of
the literal "downloads/" entry and delete one so only a single "downloads/" line
remains; ensure file still contains the other ignore patterns unchanged and keep
exactly one "downloads/" entry to avoid redundant entries.

30-30: Broaden env-file ignore patterns to reduce secret leakage risk.

Line 30 ignores .env, but variant files (for example .env.local / .env.production) can still be committed accidentally.

Proposed hardening
 .env
+.env.*
+!.env.example
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/.gitignore` at line 30, The .gitignore currently only
ignores `.env` which still allows variants like `.env.local` to be committed;
update the ignore rules in .gitignore by replacing or augmenting the `.env`
entry with a broader pattern such as `.env*` (to cover `.env.local`,
`.env.production`, etc.) and add an allow-list entry `!.env.example` if you want
to keep an example file tracked; adjust the `.env` line accordingly in
.gitignore.
subtitle-downloader/templates/index.html (1)

45-67: Language options are hardcoded and duplicated from config.py.

The language dropdown duplicates the Config.LANGUAGES dictionary. If languages are added/removed in config.py, this HTML must be manually updated. Consider dynamically populating the dropdown via the /api/languages endpoint on page load, or use Jinja2 templating to generate options from the config.

♻️ Option 1: Use Jinja2 to render from config

In app.py, pass languages to the template:

`@app.route`('/')
def index():
    return render_template('index.html', languages=Config.LANGUAGES)

In index.html:

<select id="language" name="language" required>
    <option value="">Select Language</option>
    {% for name, code in languages.items() %}
    <option value="{{ name }}">{{ name }}</option>
    {% endfor %}
</select>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/templates/index.html` around lines 45 - 67, The language
select in index.html is hardcoded and duplicates Config.LANGUAGES; update the
template to populate options dynamically instead of static <select> entries by
either (a) passing Config.LANGUAGES from app.py's index() route into
render_template() and iterating over it in index.html to emit <option>s
(reference: Config.LANGUAGES and index() / render_template), or (b) fetching
/api/languages on page load and filling the element with id="language" via
JavaScript; remove the hardcoded <option> lines and ensure the select keeps
id="language" and name="language" and the empty "Select Language" placeholder
option remains.
subtitle-downloader/subtitle_handler/opensubtitles.py (1)

118-122: Move re import to module level.

The re module is imported inside the function. For consistency and slight performance benefit, move it to the top with other imports.

♻️ Proposed fix

At the top of the file (after line 4):

import re

Then remove line 119:

                 if 'content-disposition' in download_response.headers:
-                    import re
                     match = re.search(r'filename="?(.+?)"?(?:;|$)', download_response.headers['content-disposition'])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/subtitle_handler/opensubtitles.py` around lines 118 -
122, Move the inline import of the regular expressions module out of the
function and into the module-level imports: remove the local "import re" inside
the block that checks download_response.headers and add "import re" at the top
of the file alongside the other imports in opensubtitles.py; ensure the code
that uses re.search (the block referencing download_response.headers and
file_name) continues to work unchanged after removing the local import.
subtitle-downloader/app.py (1)

127-137: Keep browser error responses HTML.

The app serves index.html, but Lines 127-137 force JSON for every 404/500. Missing pages or template/static failures in the browser will render as raw API payloads instead of a usable error page. Consider returning JSON only for /api/* and rendering HTML for the UI routes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/app.py` around lines 127 - 137, The not_found and
server_error handlers currently always return JSON; change them to return JSON
only for API requests and return the UI HTML for browser routes: inside
not_found(error) and server_error(error) detect API calls (e.g., if
request.path.startswith('/api/') or request.accept_mimetypes.accept_json) and
return jsonify with the appropriate status for those cases, otherwise render or
send your frontend index.html (using render_template('index.html') or
send_from_directory) with the same 404/500 status; keep the logger.error(...) in
server_error and ensure you import request and
render_template/send_from_directory as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@subtitle-downloader/app.py`:
- Around line 18-19: Current code indexes config with config[env] which will
raise a KeyError on typos/unexpected FLASK_ENV values; change to an explicit
lookup: check whether env is in the config registry (e.g., if env not in config)
and either raise a clear startup error (RuntimeError with a message including
the invalid env and allowed keys) or assign a deliberate fallback (e.g.,
fallback = 'development') before calling app.config.from_object; use the env
variable name and app.config.from_object(config_lookup) to locate the change
points.
- Around line 145-148: Change the dev server bind in the app.run call so it
defaults to localhost instead of all interfaces: replace the hardcoded '0.0.0.0'
host in the app.run(...) invocation with a configurable value that defaults to
'127.0.0.1' (e.g., read from an environment variable such as FLASK_RUN_HOST or a
custom HOST env var) while keeping the existing app.config['DEBUG'] usage;
update the app.run call site so it uses that env-derived host to allow explicit
wider binding when needed.
- Around line 43-52: Change request.get_json() to request.get_json(silent=True)
and validate before calling .strip(): ensure the returned data is a dict (return
jsonify error 400 if not), then for each field (series_name, season_episode,
language) fetch raw = data.get('field') and if raw is None or not
isinstance(raw, str) return a 400 JSON error; only after those checks call
raw.strip() into series_name/season_episode/language. Also adjust the broad
exception handling so that client-side issues (malformed JSON, type errors,
missing fields) return 400 rather than being swallowed as 500—e.g., explicitly
return 400 for known client errors and let unexpected exceptions propagate to
the existing server-error handler.

In `@subtitle-downloader/config.py`:
- Line 5: The config currently sets SECRET_KEY = os.environ.get('SECRET_KEY') or
'dev-secret-key-change-in-production', which provides a dangerous production
fallback; remove the hardcoded fallback and make ProductionConfig require the
env var explicitly (e.g. read via os.environ['SECRET_KEY'] or validate and raise
RuntimeError/AssertionError if SECRET_KEY is missing) so that the app fails to
start when SECRET_KEY is not provided; update the SECRET_KEY declaration and/or
override it in the ProductionConfig class (referencing SECRET_KEY and
ProductionConfig) to enforce this check and include a clear error message.

In `@subtitle-downloader/requirements.txt`:
- Line 1: Update the Flask dependency entry in requirements.txt from
"Flask==2.3.0" to a patched version; change the line to "Flask==2.3.2" (minimum
remediation) or "Flask==3.1.3" for full coverage of both CVEs, then re-run any
lock/installation steps (e.g., pip install -r requirements.txt or your
dependency lock generation) to ensure the updated package is applied; locate the
single dependency line "Flask==2.3.0" in requirements.txt and replace it
accordingly.

In `@subtitle-downloader/static/css/style.css`:
- Line 239: Replace the deprecated CSS declaration using "word-break:
break-word" by removing that property and adding "overflow-wrap: break-word"
(and optionally keep "word-break: normal" for explicit fallback) wherever
"word-break: break-word" appears in the stylesheet so the rule is updated to use
the modern, better-supported overflow-wrap behavior.

In `@subtitle-downloader/static/js/script.js`:
- Around line 156-161: The condition incorrectly checks subtitle.download_count
while the API returns subtitle.downloads, so update the check in the block that
creates downloadSpan to use subtitle.downloads (e.g., change the condition from
subtitle.download_count !== undefined to subtitle.downloads !== undefined or use
a truthy check) and ensure the created downloadSpan still uses
subtitle.downloads (and falls back to 0) before appending it to metaDiv.

In `@subtitle-downloader/subtitle_handler/opensubtitles.py`:
- Around line 154-162: get_supported_languages currently returns a dict mapping
language codes to names (languages variable) but on exception returns
Config.LANGUAGES which is the inverse mapping (name->code); change the exception
return to transform Config.LANGUAGES into the same {code: name} shape before
returning (e.g., iterate Config.LANGUAGES items and build {code: name}) so the
API contract is preserved; update the except block that logs via logger.error to
return the transformed mapping instead of Config.LANGUAGES directly.

In `@subtitle-downloader/subtitle_handler/utils.py`:
- Around line 16-17: The docstring for the function in subtitle_handler.utils
that documents a return of {'valid': bool, 'error': str or None} is out of sync
with the implementation which returns {'valid': bool, 'errors': list}; update
the docstring to describe the actual return shape (e.g., {'valid': bool,
'errors': list[str]} and what items in the list mean) or alternatively change
the function to return a single 'error' string to match the current
docstring—locate the function by the return statement that constructs {'valid':
..., 'errors': ...} and make the docstring and implementation consistent.

---

Nitpick comments:
In `@subtitle-downloader/.gitignore`:
- Around line 9-39: Remove the duplicated ignore entry "downloads/" in the
.gitignore content: locate both occurrences of the literal "downloads/" entry
and delete one so only a single "downloads/" line remains; ensure file still
contains the other ignore patterns unchanged and keep exactly one "downloads/"
entry to avoid redundant entries.
- Line 30: The .gitignore currently only ignores `.env` which still allows
variants like `.env.local` to be committed; update the ignore rules in
.gitignore by replacing or augmenting the `.env` entry with a broader pattern
such as `.env*` (to cover `.env.local`, `.env.production`, etc.) and add an
allow-list entry `!.env.example` if you want to keep an example file tracked;
adjust the `.env` line accordingly in .gitignore.

In `@subtitle-downloader/app.py`:
- Around line 127-137: The not_found and server_error handlers currently always
return JSON; change them to return JSON only for API requests and return the UI
HTML for browser routes: inside not_found(error) and server_error(error) detect
API calls (e.g., if request.path.startswith('/api/') or
request.accept_mimetypes.accept_json) and return jsonify with the appropriate
status for those cases, otherwise render or send your frontend index.html (using
render_template('index.html') or send_from_directory) with the same 404/500
status; keep the logger.error(...) in server_error and ensure you import request
and render_template/send_from_directory as needed.

In `@subtitle-downloader/subtitle_handler/opensubtitles.py`:
- Around line 118-122: Move the inline import of the regular expressions module
out of the function and into the module-level imports: remove the local "import
re" inside the block that checks download_response.headers and add "import re"
at the top of the file alongside the other imports in opensubtitles.py; ensure
the code that uses re.search (the block referencing download_response.headers
and file_name) continues to work unchanged after removing the local import.

In `@subtitle-downloader/templates/index.html`:
- Around line 45-67: The language select in index.html is hardcoded and
duplicates Config.LANGUAGES; update the template to populate options dynamically
instead of static <select> entries by either (a) passing Config.LANGUAGES from
app.py's index() route into render_template() and iterating over it in
index.html to emit <option>s (reference: Config.LANGUAGES and index() /
render_template), or (b) fetching /api/languages on page load and filling the
element with id="language" via JavaScript; remove the hardcoded <option> lines
and ensure the select keeps id="language" and name="language" and the empty
"Select Language" placeholder option remains.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a00d26da-9fa6-4ffb-a71d-8c38ab9226c5

📥 Commits

Reviewing files that changed from the base of the PR and between e1a8af3 and 6a4d270.

📒 Files selected for processing (10)
  • subtitle-downloader/.gitignore
  • subtitle-downloader/app.py
  • subtitle-downloader/config.py
  • subtitle-downloader/requirements.txt
  • subtitle-downloader/static/css/style.css
  • subtitle-downloader/static/js/script.js
  • subtitle-downloader/subtitle_handler/__init__.py
  • subtitle-downloader/subtitle_handler/opensubtitles.py
  • subtitle-downloader/subtitle_handler/utils.py
  • subtitle-downloader/templates/index.html

Comment on lines +18 to +19
env = os.environ.get('FLASK_ENV', 'development')
app.config.from_object(config[env])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard unsupported FLASK_ENV values 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
env = os.environ.get('FLASK_ENV', 'development')
app.config.from_object(config[env])
env = os.environ.get('FLASK_ENV', 'development')
config_obj = config.get(env)
if config_obj is None:
raise RuntimeError(f"Unsupported FLASK_ENV: {env!r}")
app.config.from_object(config_obj)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/app.py` around lines 18 - 19, Current code indexes config
with config[env] which will raise a KeyError on typos/unexpected FLASK_ENV
values; change to an explicit lookup: check whether env is in the config
registry (e.g., if env not in config) and either raise a clear startup error
(RuntimeError with a message including the invalid env and allowed keys) or
assign a deliberate fallback (e.g., fallback = 'development') before calling
app.config.from_object; use the env variable name and
app.config.from_object(config_lookup) to locate the change points.

Comment on lines +43 to +52
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()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "app.py" -path "*/subtitle-downloader/*" -type f

Repository: f/prompts.chat

Length of output: 86


🏁 Script executed:

cat -n ./subtitle-downloader/app.py | head -100

Repository: f/prompts.chat

Length of output: 3501


🌐 Web query:

Flask request.get_json() behavior malformed JSON exception werkzeug

💡 Result:

flask.request.get_json() is implemented by Werkzeug’s Request.get_json(), and its error behavior depends on (a) whether the request claims to be JSON and (b) whether parsing succeeds.

What happens on malformed JSON?

  • If the request has a JSON content type (e.g. Content-Type: application/json) but the body is invalid JSON, get_json() calls on_json_loading_failed(); by default this raises a werkzeug.exceptions.BadRequest (HTTP 400). [1]

What happens if Content-Type isn’t JSON?

  • If the mimetype does not indicate JSON, Werkzeug will also call on_json_loading_failed() (with e=None). In newer Werkzeug/Flask stacks this commonly surfaces as HTTP 415 Unsupported Media Type (this changed over time; see changelogs). [1][2][3]

Parameters that control this

  • silent=True: suppresses mimetype/parsing errors and returns None instead of raising. [1]
  • force=True: ignores the mimetype and tries to parse JSON anyway. [1]
  • cache=True: caches the parsed result for subsequent calls (and Flask fixed some edge cases around caching failures with silent=True). [1][2]

Practical patterns

1) 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"}, 400

2) 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"}, 400

3) Centralize handling

  • Register an error handler for BadRequest, or override Request.on_json_loading_failed() to customize the response. The default behavior is to raise BadRequest. [1]

Sources: Werkzeug Request.get_json / on_json_loading_failed docs (including 400 vs 415 notes) [1], Flask changelog notes about get_json behavior [2], Werkzeug changelog notes about 415 vs 400 behavior changes [3].

[1] (werkzeug.palletsprojects.com)
[2] (flask.palletsprojects.com)
[3] (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())}")
EOF

Repository: 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())}")
EOF

Repository: 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. request.get_json() raises BadRequest on malformed JSON; dict.get('key', '') returns None (not the default) when the key exists with a null value or non-string; and .strip() on None or numeric types raises AttributeError. Because the exception handler at lines 80–82 catches all exceptions indiscriminately, these client-side errors are returned as 500 instead of 400. Use silent=True to suppress BadRequest, validate the data structure and field types before calling .strip(), and let only unexpected server errors reach the broad exception handler.

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
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/app.py` around lines 43 - 52, Change request.get_json()
to request.get_json(silent=True) and validate before calling .strip(): ensure
the returned data is a dict (return jsonify error 400 if not), then for each
field (series_name, season_episode, language) fetch raw = data.get('field') and
if raw is None or not isinstance(raw, str) return a 400 JSON error; only after
those checks call raw.strip() into series_name/season_episode/language. Also
adjust the broad exception handling so that client-side issues (malformed JSON,
type errors, missing fields) return 400 rather than being swallowed as 500—e.g.,
explicitly return 400 for known client errors and let unexpected exceptions
propagate to the existing server-error handler.

Comment on lines +145 to +148
app.run(
host='0.0.0.0',
port=5000,
debug=app.config['DEBUG']
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "app.py" | head -20

Repository: f/prompts.chat

Length of output: 86


🏁 Script executed:

head -160 subtitle-downloader/app.py | tail -30

Repository: 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.py

Repository: f/prompts.chat

Length of output: 1270


🏁 Script executed:

grep -n "DEBUG\|config\|Config" subtitle-downloader/app.py | head -20

Repository: f/prompts.chat

Length of output: 318


🏁 Script executed:

find . -type f -name "config.py" | head -10

Repository: f/prompts.chat

Length of output: 89


🏁 Script executed:

cat -n subtitle-downloader/config.py

Repository: f/prompts.chat

Length of output: 1985


Default Flask dev server to 127.0.0.1 instead of 0.0.0.0.

Lines 145-148 bind the dev server to all network interfaces. While this code only runs via python app.py (not in production), unnecessarily exposing the dev server to the network increases risk, especially when debug mode is enabled. Default to 127.0.0.1 (localhost only) and allow wider binding through explicit environment variable configuration.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.run(
host='0.0.0.0',
port=5000,
debug=app.config['DEBUG']
app.run(
host=os.environ.get('HOST', '127.0.0.1'),
port=5000,
debug=app.config['DEBUG']
)
🧰 Tools
🪛 Ruff (0.15.5)

[error] 146-146: Possible binding to all interfaces

(S104)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/app.py` around lines 145 - 148, Change the dev server
bind in the app.run call so it defaults to localhost instead of all interfaces:
replace the hardcoded '0.0.0.0' host in the app.run(...) invocation with a
configurable value that defaults to '127.0.0.1' (e.g., read from an environment
variable such as FLASK_RUN_HOST or a custom HOST env var) while keeping the
existing app.config['DEBUG'] usage; update the app.run call site so it uses that
env-derived host to allow explicit wider binding when needed.


class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded fallback secret key is a security risk in production.

The fallback 'dev-secret-key-change-in-production' will be used if the environment variable is not set, which could lead to predictable session tokens in production. Consider failing explicitly when SECRET_KEY is not set in production mode.

🛡️ 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 = False

Then in ProductionConfig:

 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SECRET_KEY = os.environ.get('SECRET_KEY')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/config.py` at line 5, The config currently sets
SECRET_KEY = os.environ.get('SECRET_KEY') or
'dev-secret-key-change-in-production', which provides a dangerous production
fallback; remove the hardcoded fallback and make ProductionConfig require the
env var explicitly (e.g. read via os.environ['SECRET_KEY'] or validate and raise
RuntimeError/AssertionError if SECRET_KEY is missing) so that the app fails to
start when SECRET_KEY is not provided; update the SECRET_KEY declaration and/or
override it in the ProductionConfig class (referencing SECRET_KEY and
ProductionConfig) to enforce this check and include a clear error message.

@@ -0,0 +1,3 @@
Flask==2.3.0
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:

Flask 2.3.0 security vulnerabilities GHSA-m2qf-hxjv-5gpq GHSA-68rp-wp8r-4726

💡 Result:

Flask 2.3.0 is affected by both advisories

Advisory What it is Affected versions (includes Flask 2.3.0) Fix
GHSA-m2qf-hxjv-5gpq (CVE-2023-30861) Missing Vary: Cookie in a session refresh edge-case can let a caching proxy cache a response (and potentially a Set-Cookie), risking session-cookie disclosure when specific conditions are met. NVD lists affected as 2.3.0 to < 2.3.2 (and older branches up to < 2.2.5). [2] GitHub advisory describes the cache/proxy + session.permanent=True conditions and patched releases. [1] Upgrade to 2.3.2 (or 2.2.5 on the 2.2 line). [1][2]
GHSA-68rp-wp8r-4726 (CVE-2026-27205) Session access via some patterns (notably the Python in operator) may fail to add Vary: Cookie, enabling cache poisoning / sensitive-response caching behind certain proxies. 3.1.2 and below (i.e., < 3.1.3). [3][4] Upgrade to 3.1.3. [3][4]

Practical remediation for a Flask 2.3.0 app

  • For GHSA-m2qf-hxjv-5gpq: upgrade at least to 2.3.2. [1][2]
  • For GHSA-68rp-wp8r-4726: there is no fix in the 2.3.x line in the advisory data; upgrading to 3.1.3 is the stated fix. [3][4]
  • If you can’t upgrade immediately, mitigate cache risk by ensuring responses are not cached (e.g., set Cache-Control: private, no-store where appropriate) and avoid relying on session access patterns that might not trigger Vary: Cookie. [3][4]

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:

Flask 2.3.0 session cookie vulnerability Vary Cookie header

💡 Result:

Flask 2.3.0 is affected by CVE-2023-30861: under certain conditions, Flask may send a refreshed permanent session cookie (Set-Cookie) without also adding Vary: Cookie, which can let a shared/caching proxy cache and replay a response (and potentially the Set-Cookie header) across different clients. This can result in disclosure of another user’s session cookie. [1][2]

When it can happen (all must apply): behind a caching proxy that doesn’t ignore/strip cookies, session.permanent = True, your view does not access/modify session, SESSION_REFRESH_EACH_REQUEST=True (default), and you don’t mark responses as private/not cacheable (e.g., missing protective Cache-Control). [1][2]

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:

  • Ensure sensitive/session-dependent responses are not shared-cached (e.g., Cache-Control: private or no-store). [1][2]
  • (Defense-in-depth) Add Vary: Cookie on responses where a session cookie might be refreshed. [1][2]

Sources: GitHub Advisory (GHSA-m2qf-hxjv-5gpq) / CVE-2023-30861, and NVD entry. [1][2]


🌐 Web query:

Flask security advisory PYSEC-2023-62

💡 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 session cookie (Set-Cookie) to be sent to other clients when Vary: Cookie is missing/insufficient. [1]

When it can happen (all required):

  • App is behind a caching proxy that might cache responses even with cookies. [1]
  • session.permanent = True. [1]
  • The request does not access/modify the session, but the session cookie gets refreshed. [1]
  • SESSION_REFRESH_EACH_REQUEST = True (default). [1]
  • No Cache-Control marking the response as private / not cacheable. [1]

Affected versions (per PyPI advisory aggregations):

  • Flask < 2.2.5, and 2.3.0–2.3.1 (i.e., >2.3.0, <2.3.2). [2]

Fixed versions:

  • Upgrade to Flask 2.2.5 (2.2.x line) or Flask 2.3.2 (2.3.x line). [1][2]

Mitigations (if you can’t upgrade immediately):

  • Ensure responses that may set/refresh cookies are not cacheable (e.g., appropriate Cache-Control), and/or configure your proxy/CDN to not cache responses with Set-Cookie / vary on cookies correctly. [1]

Sources: GitHub Security Advisory (Flask) [1]; PYSEC-2023-62 aggregation with affected ranges [2].

[1] (github.com)
[2] (resolvedsecurity.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:

  • GHSA-m2qf-hxjv-5gpq (CVE-2023-30861): Missing Vary: Cookie header in session refresh scenarios can allow caching proxies to leak session cookies across clients when session.permanent=True and responses lack protective Cache-Control headers. Fixed in Flask 2.3.2+ (or 2.2.5 for 2.2.x line).
  • GHSA-68rp-wp8r-4726 (CVE-2026-27205): Session access via certain patterns (e.g., Python in operator) may fail to add Vary: Cookie, enabling cache poisoning behind shared proxies. Requires Flask 3.1.3+ (no patch available in 2.3.x line).

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 Vary: Cookie header when accessed in some ways

(GHSA-68rp-wp8r-4726)


[HIGH] 1-1: flask 2.3.0: Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header

(GHSA-m2qf-hxjv-5gpq)


[HIGH] 1-1: flask 2.3.0: undefined

(PYSEC-2023-62)


[HIGH] 1-1: flask 2.3.0: Flask session does not add Vary: Cookie header when accessed in some ways

(GHSA-68rp-wp8r-4726)


[HIGH] 1-1: flask 2.3.0: Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header

(GHSA-m2qf-hxjv-5gpq)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/requirements.txt` at line 1, Update the Flask dependency
entry in requirements.txt from "Flask==2.3.0" to a patched version; change the
line to "Flask==2.3.2" (minimum remediation) or "Flask==3.1.3" for full coverage
of both CVEs, then re-run any lock/installation steps (e.g., pip install -r
requirements.txt or your dependency lock generation) to ensure the updated
package is applied; locate the single dependency line "Flask==2.3.0" in
requirements.txt and replace it accordingly.

font-weight: 600;
color: #333;
margin-bottom: 6px;
word-break: break-word;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use overflow-wrap: break-word instead of deprecated word-break: break-word.

The keyword break-word for word-break is deprecated. Use overflow-wrap: break-word for the same effect with better browser support.

📝 Proposed fix
 .subtitle-name {
     font-weight: 600;
     color: `#333`;
     margin-bottom: 6px;
-    word-break: break-word;
+    overflow-wrap: break-word;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
word-break: break-word;
.subtitle-name {
font-weight: 600;
color: `#333`;
margin-bottom: 6px;
overflow-wrap: break-word;
}
🧰 Tools
🪛 Stylelint (17.4.0)

[error] 239-239: Unexpected deprecated keyword "break-word" for property "word-break" (declaration-property-value-keyword-no-deprecated)

(declaration-property-value-keyword-no-deprecated)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/static/css/style.css` at line 239, Replace the deprecated
CSS declaration using "word-break: break-word" by removing that property and
adding "overflow-wrap: break-word" (and optionally keep "word-break: normal" for
explicit fallback) wherever "word-break: break-word" appears in the stylesheet
so the rule is updated to use the modern, better-supported overflow-wrap
behavior.

Comment on lines +156 to +161
if (subtitle.download_count !== undefined) {
const downloadSpan = document.createElement('span');
downloadSpan.className = 'meta-item';
downloadSpan.textContent = `📥 ${subtitle.downloads || 0} downloads`;
metaDiv.appendChild(downloadSpan);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Field name mismatch: checking download_count but API returns downloads.

The condition checks subtitle.download_count but the API response from opensubtitles.py (line 73) uses the field name downloads. This condition will always be falsy, hiding download count metadata.

🐛 Proposed fix
-    if (subtitle.download_count !== undefined) {
+    if (subtitle.downloads !== undefined) {
         const downloadSpan = document.createElement('span');
         downloadSpan.className = 'meta-item';
         downloadSpan.textContent = `📥 ${subtitle.downloads || 0} downloads`;
         metaDiv.appendChild(downloadSpan);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/static/js/script.js` around lines 156 - 161, The
condition incorrectly checks subtitle.download_count while the API returns
subtitle.downloads, so update the check in the block that creates downloadSpan
to use subtitle.downloads (e.g., change the condition from
subtitle.download_count !== undefined to subtitle.downloads !== undefined or use
a truthy check) and ensure the created downloadSpan still uses
subtitle.downloads (and falls back to 0) before appending it to metaDiv.

Comment on lines +154 to +162
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fallback returns inconsistent data structure, breaking API contract.

On success, get_supported_languages() returns {code: name} (e.g., {'en': 'English'}), but the fallback on line 162 returns Config.LANGUAGES which has {name: code} structure (e.g., {'English': 'en'}). This breaks the /api/languages endpoint contract for clients.

🐛 Proposed fix to invert the fallback structure
         except Exception as e:
             logger.error(f"Error fetching languages: {str(e)}")
-            return Config.LANGUAGES
+            # Invert Config.LANGUAGES to match API response structure {code: name}
+            return {code: name for name, code in Config.LANGUAGES.items()}
🧰 Tools
🪛 Ruff (0.15.5)

[warning] 160-160: Do not catch blind exception: Exception

(BLE001)


[warning] 161-161: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/subtitle_handler/opensubtitles.py` around lines 154 -
162, get_supported_languages currently returns a dict mapping language codes to
names (languages variable) but on exception returns Config.LANGUAGES which is
the inverse mapping (name->code); change the exception return to transform
Config.LANGUAGES into the same {code: name} shape before returning (e.g.,
iterate Config.LANGUAGES items and build {code: name}) so the API contract is
preserved; update the except block that logs via logger.error to return the
transformed mapping instead of Config.LANGUAGES directly.

Comment on lines +16 to +17
Returns:
dict: {'valid': bool, 'error': str or None}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring does not match return type.

The docstring states the return is {'valid': bool, 'error': str or None} but the function actually returns {'valid': bool, 'errors': list} (line 44-47).

📝 Proposed fix
     Returns:
-        dict: {'valid': bool, 'error': str or None}
+        dict: {'valid': bool, 'errors': list}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Returns:
dict: {'valid': bool, 'error': str or None}
Returns:
dict: {'valid': bool, 'errors': list}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@subtitle-downloader/subtitle_handler/utils.py` around lines 16 - 17, The
docstring for the function in subtitle_handler.utils that documents a return of
{'valid': bool, 'error': str or None} is out of sync with the implementation
which returns {'valid': bool, 'errors': list}; update the docstring to describe
the actual return shape (e.g., {'valid': bool, 'errors': list[str]} and what
items in the list mean) or alternatively change the function to return a single
'error' string to match the current docstring—locate the function by the return
statement that constructs {'valid': ..., 'errors': ...} and make the docstring
and implementation consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants