Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,45 @@ Anything [yt-dlp supports](https://github.com/yt-dlp/yt-dlp/blob/master/supporte

YouTube, TikTok, Instagram, Twitter/X, Reddit, Facebook, Vimeo, Twitch, Dailymotion, SoundCloud, Loom, Streamable, Pinterest, Tumblr, Threads, LinkedIn, and many more.

## Internationalisation (i18n)

All user-facing strings are externalised into JSON files under `translations/`.

| File | Language |
|------|----------|
| `translations/en.json` | English (default) |
| `translations/fr.json` | French |

**How it works**

The active language is resolved in this order:
1. `RECLIP_LANG` environment variable (e.g. `RECLIP_LANG=fr`)
2. The browser's `Accept-Language` header (first locale whose file exists)
3. Fallback: `en`

The server loads the matching JSON file, merges it on top of the English base (so missing keys always fall back to English), and injects the full strings object into the page as `window.i18n`. All HTML strings use Jinja2 `{{ t('key') }}` and all JavaScript strings use `i18n['key']`.

**Adding a new language**

1. Copy `translations/en.json` to `translations/<lang_code>.json` (e.g. `translations/de.json`).
2. Translate all the values — keep the keys unchanged.
3. Set `RECLIP_LANG=de` (or rely on the browser header) — no code changes needed.

**Setting the default language**

```bash
RECLIP_LANG=fr ./reclip.sh
```

Or in Docker:

```bash
docker run -e RECLIP_LANG=fr -p 8899:8899 reclip
```

## Stack

- **Backend:** Python + Flask (~150 lines)
- **Backend:** Python + Flask (~170 lines)
- **Frontend:** Vanilla HTML/CSS/JS (single file, no build step)
- **Download engine:** [yt-dlp](https://github.com/yt-dlp/yt-dlp) + [ffmpeg](https://ffmpeg.org/)
- **Dependencies:** 2 (Flask, yt-dlp)
Expand Down
36 changes: 26 additions & 10 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
import threading
from flask import Flask, request, jsonify, send_file, render_template

from i18n import detect_lang, get_translator

app = Flask(__name__)
DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), "downloads")
os.makedirs(DOWNLOAD_DIR, exist_ok=True)

jobs = {}


def run_download(job_id, url, format_choice, format_id):
def run_download(job_id, url, format_choice, format_id, t):
job = jobs[job_id]
out_template = os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s")

Expand All @@ -38,7 +40,7 @@ def run_download(job_id, url, format_choice, format_id):
files = glob.glob(os.path.join(DOWNLOAD_DIR, f"{job_id}.*"))
if not files:
job["status"] = "error"
job["error"] = "Download completed but no file was found"
job["error"] = t("error.file_not_found")
return

if format_choice == "audio":
Expand Down Expand Up @@ -67,23 +69,28 @@ def run_download(job_id, url, format_choice, format_id):
job["filename"] = os.path.basename(chosen)
except subprocess.TimeoutExpired:
job["status"] = "error"
job["error"] = "Download timed out (5 min limit)"
job["error"] = t("error.download_timeout")
except Exception as e:
job["status"] = "error"
job["error"] = str(e)


@app.route("/")
def index():
return render_template("index.html")
lang = detect_lang(request.headers.get("Accept-Language", ""))
t, strings = get_translator(lang)
return render_template("index.html", t=t, strings=strings, lang=lang)


@app.route("/api/info", methods=["POST"])
def get_info():
lang = detect_lang(request.headers.get("Accept-Language", ""))
t, _ = get_translator(lang)

data = request.json
url = data.get("url", "").strip()
if not url:
return jsonify({"error": "No URL provided"}), 400
return jsonify({"error": t("error.no_url")}), 400

cmd = ["yt-dlp", "--no-playlist", "-j", url]
try:
Expand Down Expand Up @@ -119,26 +126,29 @@ def get_info():
"formats": formats,
})
except subprocess.TimeoutExpired:
return jsonify({"error": "Timed out fetching video info"}), 400
return jsonify({"error": t("error.info_timeout")}), 400
except Exception as e:
return jsonify({"error": str(e)}), 400


@app.route("/api/download", methods=["POST"])
def start_download():
lang = detect_lang(request.headers.get("Accept-Language", ""))
t, _ = get_translator(lang)

data = request.json
url = data.get("url", "").strip()
format_choice = data.get("format", "video")
format_id = data.get("format_id")
title = data.get("title", "")

if not url:
return jsonify({"error": "No URL provided"}), 400
return jsonify({"error": t("error.no_url")}), 400

job_id = uuid.uuid4().hex[:10]
jobs[job_id] = {"status": "downloading", "url": url, "title": title}

thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id))
thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id, t))
thread.daemon = True
thread.start()

Expand All @@ -147,9 +157,12 @@ def start_download():

@app.route("/api/status/<job_id>")
def check_status(job_id):
lang = detect_lang(request.headers.get("Accept-Language", ""))
t, _ = get_translator(lang)

job = jobs.get(job_id)
if not job:
return jsonify({"error": "Job not found"}), 404
return jsonify({"error": t("error.job_not_found")}), 404
return jsonify({
"status": job["status"],
"error": job.get("error"),
Expand All @@ -159,9 +172,12 @@ def check_status(job_id):

@app.route("/api/file/<job_id>")
def download_file(job_id):
lang = detect_lang(request.headers.get("Accept-Language", ""))
t, _ = get_translator(lang)

job = jobs.get(job_id)
if not job or job["status"] != "done":
return jsonify({"error": "File not ready"}), 404
return jsonify({"error": t("error.file_not_ready")}), 404
return send_file(job["file"], as_attachment=True, download_name=job["filename"])


Expand Down
71 changes: 71 additions & 0 deletions i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Lightweight i18n module for ReClip.

Language resolution order:
1. RECLIP_LANG environment variable (e.g. RECLIP_LANG=fr)
2. Accept-Language HTTP header (first locale whose .json file exists)
3. Default: "en"

To add a new language:
- Copy translations/en.json to translations/<lang_code>.json
- Translate the values (keep the keys unchanged)
- That's it — no code changes required.
"""

import json
import os
from pathlib import Path

TRANSLATIONS_DIR = Path(__file__).parent / "translations"
DEFAULT_LANG = "en"

_cache: dict = {}


def _load(lang: str) -> dict:
"""Load and cache a translation file. Returns {} if not found."""
if lang in _cache:
return _cache[lang]
path = TRANSLATIONS_DIR / f"{lang}.json"
if not path.exists():
return {}
with open(path, encoding="utf-8") as fh:
_cache[lang] = json.load(fh)
return _cache[lang]


def detect_lang(accept_language: str = "") -> str:
"""
Return the best supported language code.

Checks RECLIP_LANG env var first, then the Accept-Language header,
then falls back to DEFAULT_LANG.
"""
forced = os.environ.get("RECLIP_LANG", "").strip().lower()
if forced and (TRANSLATIONS_DIR / f"{forced}.json").exists():
return forced

for token in accept_language.split(","):
lang = token.split(";")[0].strip().split("-")[0].lower()
if lang and (TRANSLATIONS_DIR / f"{lang}.json").exists():
return lang

return DEFAULT_LANG


def get_translator(lang: str):
"""
Return (t, strings) for the given language.

- t(key) returns the translated string; falls back to English,
then to the key itself if still not found.
- strings is the complete merged dict exposed as window.i18n in JS.
"""
base = _load(DEFAULT_LANG)
overlay = _load(lang) if lang != DEFAULT_LANG else {}
strings = {**base, **overlay}

def t(key: str) -> str:
return strings.get(key, key)

return t, strings
Loading