diff --git a/README.md b/README.md index f67212c..a7c029e 100644 --- a/README.md +++ b/README.md @@ -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/.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) diff --git a/app.py b/app.py index 703f435..4587701 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,8 @@ 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) @@ -13,7 +15,7 @@ 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") @@ -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": @@ -67,7 +69,7 @@ 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) @@ -75,15 +77,20 @@ def run_download(job_id, url, format_choice, format_id): @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: @@ -119,13 +126,16 @@ 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") @@ -133,12 +143,12 @@ def start_download(): 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() @@ -147,9 +157,12 @@ def start_download(): @app.route("/api/status/") 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"), @@ -159,9 +172,12 @@ def check_status(job_id): @app.route("/api/file/") 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"]) diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..955a064 --- /dev/null +++ b/i18n.py @@ -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/.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 diff --git a/templates/index.html b/templates/index.html index 0bae3d3..1235941 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,9 +1,9 @@ - + - ReClip — Free Media Downloader + {{ t('app.title') }} @@ -384,30 +384,33 @@

ReClip

-

Free media downloader

+

{{ t('app.tagline') }}

- -

Multiple links? Separate with spaces, commas, or newlines.

+ +

{{ t('form.url.hint') }}

- - + +
- +
diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..dfb4c98 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,45 @@ +{ + "app.title": "ReClip — Free Media Downloader", + "app.tagline": "Free media downloader", + + "form.url.placeholder": "Paste one or more URLs...", + "form.url.hint": "Multiple links? Separate with spaces, commas, or newlines.", + + "form.format.mp4": "MP4", + "form.format.mp3": "MP3", + + "form.submit": "Fetch", + "form.submit.loading": "Loading...", + + "card.untitled": "Untitled", + "card.download": "Download", + "card.download.in_progress": "Downloading...", + "card.save": "Save", + "card.retry": "Retry", + "card.fetch_error": "Could not fetch video", + + "batch.download_all": "Download All", + "batch.download_all.in_progress": "Downloading...", + + "error.no_url": "No URL provided", + "error.file_not_found": "Download completed but no file was found", + "error.download_timeout": "Download timed out (5 min limit)", + "error.info_timeout": "Timed out fetching video info", + "error.job_not_found": "Job not found", + "error.file_not_ready": "File not ready", + "error.download_failed": "Download failed", + "error.connection_lost": "Lost connection to server", + "error.unsupported_url": "This URL is not supported", + "error.video_unavailable": "Video is unavailable or private", + "error.private_video": "This video is private", + "error.forbidden": "Access denied by the platform", + "error.not_found": "Video not found", + "error.copyright": "Video blocked due to copyright", + "error.geo_blocked": "Video not available in your region", + "error.timeout": "Request timed out — try again", + "error.network": "Network error — check your connection", + + "sites.line1": "YouTube · TikTok · Instagram · Twitter/X · Reddit · Facebook", + "sites.line2": "Vimeo · Twitch · Dailymotion · SoundCloud · Loom · Streamable", + "sites.line3": "Pinterest · Tumblr · Threads · LinkedIn · 1000+ more" +} diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..ed0c1a5 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,45 @@ +{ + "app.title": "ReClip — Téléchargeur multimédia gratuit", + "app.tagline": "Téléchargeur multimédia gratuit", + + "form.url.placeholder": "Collez une ou plusieurs URL...", + "form.url.hint": "Plusieurs liens ? Séparez-les par des espaces, virgules ou retours à la ligne.", + + "form.format.mp4": "MP4", + "form.format.mp3": "MP3", + + "form.submit": "Récupérer", + "form.submit.loading": "Chargement...", + + "card.untitled": "Sans titre", + "card.download": "Télécharger", + "card.download.in_progress": "Téléchargement...", + "card.save": "Enregistrer", + "card.retry": "Réessayer", + "card.fetch_error": "Impossible de récupérer la vidéo", + + "batch.download_all": "Tout télécharger", + "batch.download_all.in_progress": "Téléchargement...", + + "error.no_url": "Aucune URL fournie", + "error.file_not_found": "Téléchargement terminé mais aucun fichier trouvé", + "error.download_timeout": "Délai de téléchargement dépassé (limite : 5 min)", + "error.info_timeout": "Délai dépassé lors de la récupération des informations", + "error.job_not_found": "Tâche introuvable", + "error.file_not_ready": "Fichier non disponible", + "error.download_failed": "Échec du téléchargement", + "error.connection_lost": "Connexion au serveur perdue", + "error.unsupported_url": "Cette URL n'est pas prise en charge", + "error.video_unavailable": "Vidéo indisponible ou privée", + "error.private_video": "Cette vidéo est privée", + "error.forbidden": "Accès refusé par la plateforme", + "error.not_found": "Vidéo introuvable", + "error.copyright": "Vidéo bloquée pour droits d'auteur", + "error.geo_blocked": "Vidéo non disponible dans votre région", + "error.timeout": "Délai dépassé — réessayez", + "error.network": "Erreur réseau — vérifiez votre connexion", + + "sites.line1": "YouTube · TikTok · Instagram · Twitter/X · Reddit · Facebook", + "sites.line2": "Vimeo · Twitch · Dailymotion · SoundCloud · Loom · Streamable", + "sites.line3": "Pinterest · Tumblr · Threads · LinkedIn · 1000+ sites" +}