dots

Mika's dotfiles
git clone git://popovic.xyz/dots.git/
Log | Files | Refs | README

commit 73959372594b7b33b6296874f5dd5a36a55b60e8
parent 363cea507239c8ffcb81ef12f7e19060286ad475
Author: Milutin Popovic <milutin@popovic.xyz>
Date:   Sat, 11 Apr 2026 15:41:25 +0100

add opensubtitles downloader

Diffstat:
M.config/shell/aliases | 1-
A.local/bin/scripts/OpenSubtittlesDowload.py | 1321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.local/bin/scripts/fsubs | 3+++
3 files changed, 1324 insertions(+), 1 deletion(-)

diff --git a/.config/shell/aliases b/.config/shell/aliases @@ -22,7 +22,6 @@ alias htop="sudo htop" alias multitex="latexmk -pdf -outdir=build" alias neomutt="TZ=Europe/Berlin neomutt" alias cmake="cmake -D CMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" - alias cpr="rsync --archive -hh --partial --info=stats1,progress2 --modify-window=1" alias mvr="rsync --archive -hh --partial --info=stats1,progress2 --modify-window=1 --remove-source-files" alias pyenv="source $HOME/.local/py_env/bin/activate" diff --git a/.local/bin/scripts/OpenSubtittlesDowload.py b/.local/bin/scripts/OpenSubtittlesDowload.py @@ -0,0 +1,1321 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# OpenSubtitlesDownload.py / Version 6.5 +# This software is designed to help you find and download subtitles for your favorite videos! + +# You can browse the project's GitHub page: +# - https://github.com/emericg/OpenSubtitlesDownload + +# Learn much more about it on the wiki: +# - https://github.com/emericg/OpenSubtitlesDownload/wiki + +# Copyright (c) 2026 by Emeric GRANGE <emeric.grange@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import os +import sys +import time +import shutil +import struct +import argparse +import mimetypes +import subprocess + +from threading import Lock +from collections import deque + +import json +import urllib +import urllib.request +import urllib.error + +# ==== OpenSubtitles.com server settings ======================================= + +# Track API availability: +# > https://92500a62-df9e-42ed-82a4-e6b3eeb89365.site.hbuptime.com/ + +# API endpoints +API_URL = 'https://api.opensubtitles.com/api/v1/' +API_URL_LOGIN = API_URL + 'login' +API_URL_LOGOUT = API_URL + 'logout' +API_URL_SEARCH = API_URL + 'subtitles' +API_URL_DOWNLOAD = API_URL + 'download' + +# This application is registered: +APP_NAME = 'OpenSubtitlesDownload' +APP_VERSION = '6.5' +APP_API_KEY = 'FNyoC96mlztsk3ALgNdhfSNapfFY9lOi' + +# ==== OpenSubtitles.com account (required) ==================================== + +# A valid account from opensubtitles.com is REQUIRED. +# You can use a VIP account to avoid "in-subtitles" advertisement and bypass download limits. + +# The username is NOT your account email address, but in fact, your username... +# Be careful about your password security, it will be stored right here, in plain text... +# Can be overridden at run time with '-u' and '-p' arguments. +# Can be overridden at run time with 'OSD_ENV_USERNAME' and 'OSD_ENV_PASSWORD' environment variables. +osd_username = '' +osd_password = '' + +# ==== Language settings ======================================================= + +# Full guide: https://github.com/emericg/OpenSubtitlesDownload/wiki/Adjust-settings + +# 1/ Change the search language by using any supported 2-letter (ISO 639-1) language code: +# > https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes +# > Supported language codes: https://opensubtitles.stoplight.io/docs/opensubtitles-api/1de776d20e873-languages +# > Ex: opt_languages = 'en' +# 2/ Search for subtitles in several languages by using multiple codes separated by a comma: +# > Ex: opt_languages = 'en,fr' +opt_languages = 'en' + +# Write language code (ex: _en) at the end of the subtitles file. 'on', 'off' or 'auto'. +# If you are regularly searching for several language at once, you sould use 'on'. +opt_language_suffix = 'auto' + +# Character used to separate file path from the language code (ex: file_en.srt). +opt_language_suffix_separator = '_' + +# ==== Search settings ========================================================= + +# Subtitles search mode. Can be overridden at run time with '-s' argument. +# - hash (search using file hash only) +# - filename (search using filename only) +# - hash_then_filename (search using file hash, then if no results, by filename) (default) +# - hash_and_filename (search using both methods) +opt_search_mode = 'hash_then_filename' + +# Search and download a subtitles even if one already exists. +opt_search_overwrite = True + +# Subtitles selection mode. Can be overridden at run time with '-t' argument. +# - default (in case of multiple results, lets you choose the subtitles you want) +# - manual (always let you choose the subtitles you want) +# - auto (automatically select the best subtitles found) +opt_selection_mode = 'default' + +# Customize subtitles download path. Can be overridden at run time with '-o' argument. +# By default, subtitles are downloaded next to their video file. +opt_output_path = '' + +# Ignore Hearing Impaired (HI) subtitles? +opt_ignore_hi = False + +# Ignore machine translated subtitles? +opt_ignore_machine_translated = True + +# Ignore AI translated subtitles? +opt_ignore_ai_translated = False + +# Ignore "foreign parts only" subtitles? +opt_ignore_foreign_parts_only = False + +# ==== GUI settings ============================================================ + +# Select your GUI. Can be overridden at run time with '--gui=xxx' argument. +# - auto (autodetection, fallback on CLI) +# - gnome (GNOME/GTK based environments, using 'zenity' backend) +# - kde (KDE/Qt based environments, using 'kdialog' backend) +# - cli (Command Line Interface) +opt_gui = 'auto' + +# Change the subtitles selection GUI size: +opt_gui_width = 940 +opt_gui_height = 480 + +# Various GUI columns to show/hide during subtitles selection. You can set them to 'on', 'off' or 'auto'. +opt_selection_language = 'auto' +opt_selection_match = 'auto' +opt_selection_hi = 'auto' +opt_selection_fps = 'off' +opt_selection_rating = 'off' +opt_selection_count = 'off' + +# ==== HOOK ==================================================================== + +# Use a secondary tool on the subtitles file after a successful download? +custom_command = "" + +# ==== Check file path & type ================================================== + +def checkFileValidity(path): + """Check mimetype and/or file extension to detect valid video file""" + if os.path.isfile(path) is False: + superPrint("info", "File not found", f"The file provided was not found:<br><i>{path}</i>") + return False + + fileMimeType, encoding = mimetypes.guess_type(path) + if fileMimeType is None: + fileExtension = path.rsplit('.', 1) + if fileExtension[1] not in ['avi', 'mov', 'mp4', 'mp4v', 'm4v', 'mkv', 'mk3d', 'webm', \ + 'ts', 'mts', 'm2ts', 'ps', 'vob', 'evo', 'mpeg', 'mpg', \ + 'asf', 'wm', 'wmv', 'rm', 'rmvb', 'divx', 'xvid']: + #superPrint("error", "File type error!", f"This file is not a video (unknown mimetype AND invalid file extension):<br><i>{path}</i>") + return False + else: + fileMimeType = fileMimeType.split('/', 1) + if fileMimeType[0] != 'video': + #superPrint("error", "File type error!", f"This file is not a video (unknown mimetype):<br><i>{path}</i>") + return False + + return True + +# ==== Check for existing subtitles file ======================================= + +def checkSubtitlesExists(path): + """Check if a subtitles already exists for the current file""" + extList = ['srt', 'sub', 'mpl', 'webvtt', 'dfxp', 'txt', 'sbv', 'smi', 'ssa', 'ass', 'usf'] + sepList = ['_', '-', '.'] + tryList = [''] + + if opt_language_suffix_separator not in sepList: + sepList.append(opt_language_suffix_separator) + + if opt_language_suffix in ('on', 'auto'): + for language in languageList: + for sep in sepList: + tryList.append(sep + language) + + for ext in extList: + for teststring in tryList: + subPath = path.rsplit('.', 1)[0] + teststring + '.' + ext + if os.path.isfile(subPath) is True: + superPrint("info", "Subtitles already downloaded!", f"A subtitles file already exists for this file:<br><i>{subPath}</i>") + return True + + return False + +# ==== Hashing algorithm ======================================================= +# Info: https://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes +# This particular implementation is coming from SubDownloader: https://subdownloader.net + +def hashFile(path): + """Produce a hash for a video file: size + 64bit chksum of the first and + last 64k (even if they overlap because the file is smaller than 128k)""" + try: + longlongformat = 'Q' # unsigned long long little endian + bytesize = struct.calcsize(longlongformat) + fmt = "<%d%s" % (65536//bytesize, longlongformat) + + f = open(path, "rb") + + filesize = os.fstat(f.fileno()).st_size + filehash = filesize + + if filesize < 65536 * 2: + superPrint("error", "File size error!", f"File size error while generating hash for this file:<br><i>{path}</i>") + return "SizeError" + + buf = f.read(65536) + longlongs = struct.unpack(fmt, buf) + filehash += sum(longlongs) + + f.seek(-65536, os.SEEK_END) # size is always > 131072 + buf = f.read(65536) + longlongs = struct.unpack(fmt, buf) + filehash += sum(longlongs) + filehash &= 0xFFFFFFFFFFFFFFFF + + f.close() + returnedhash = "%016x" % filehash + return returnedhash + + except IOError: + superPrint("error", "I/O error!", f"Input/Output error while generating hash for this file:<br><i>{path}</i>") + return "IOError" + + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + +# ==== String escaping ========================================================= +# Title and filename may need string sanitizing to avoid zenity/kdialog handling errors + +def escapeGUI_title(string): + if opt_gui != 'cli': + string = string.replace('"', '\\"') + return string + +def escapeGUI_zenity(string): + if opt_gui == 'gnome': + string = string.replace('"', '\\"') + string = string.replace("'", "\\'") + string = string.replace('`', '\\`') + string = string.replace("&", "&amp;") + return string + +def escapeGUI_kdialog(string): + if opt_gui == 'kde': + string = string.replace('"', '\\"') + string = string.replace('`', '\\`') + return string + +def escapePath_wget(string): + string = string.replace('"', '\\"') + string = string.replace('`', '\\`') + return string + +# ==== Super Print ============================================================= +# priority: info, warning, error +# title: only for zenity and kdialog messages +# message: full text, with tags and breaks (tags will be cleaned up for CLI) + +def superPrint(priority, title, message): + """Print messages through terminal, zenity or kdialog""" + if opt_gui == 'gnome': + # Adapt to zenity + message = message.replace("<br>", "\n") + # Escape + message = escapeGUI_zenity(message) + # Print message + subprocess.call(['zenity', '--width=' + str(opt_gui_width), f'--{priority}', f'--title={title}', f'--text={message}']) + elif opt_gui == 'kde': + # Adapt to kdialog + message = message.replace("\n", "<br>") + if priority == 'warning': + priority = 'sorry' + elif priority == 'info': + priority = 'msgbox' + # Print message + subprocess.call(['kdialog', '--geometry=' + str(opt_gui_width-220) + 'x' + str(opt_gui_height-128) + '+128+128', f'--title={title}', f'--{priority}={message}']) + else: + # Clean up format tags and line breaks + message = message.replace("\n\n", "\n") + message = message.replace("<br><br>", "\n") + message = message.replace("<br>", "\n") + message = message.replace("<i>", "") + message = message.replace("</i>", "") + message = message.replace("<b>", "") + message = message.replace("</b>", "") + # Print message + print(">> " + message) + +# ==== GNOME (zenity) selection window ========================================= + +def selectionGnome(subtitlesResultList): + """GNOME subtitles selection window using zenity""" + subtitlesSelectedName = u'' + subtitlesSelectedIndex = -1 + + subtitlesItems = u'' + subtitlesMatchedByHash = 0 + subtitlesMatchedByName = 0 + columnHi = '' + columnLn = '' + columnMatch = '' + columnRate = '' + columnCount = '' + columnFPS = '' + + videoTitle_window = escapeGUI_title(videoTitle) + videoTitle_escaped = escapeGUI_zenity(videoTitle) + videoFileName_escaped = escapeGUI_zenity(videoFileName) + + # Generate selection window content + for idx, item in enumerate(subtitlesResultList['data']): + if opt_ignore_hi and item['attributes'].get('hearing_impaired', False) == True: + continue + if opt_ignore_foreign_parts_only and item['attributes'].get('foreign_parts_only', False) == True: + continue + if opt_ignore_ai_translated and item['attributes'].get('ai_translated', False) == True: + continue + if opt_ignore_machine_translated and item['attributes'].get('machine_translated', False) == True: + continue + + if item['attributes'].get('moviehash_match', False) == True: + subtitlesMatchedByHash += 1 + else: + subtitlesMatchedByName += 1 + + subtitlesItems += f'{idx} "' + escapeGUI_zenity(item['attributes']['files'][0]['file_name']) + '" ' + + if opt_selection_hi == 'on': + columnHi = '--column="HI" ' + if item['attributes'].get('hearing_impaired', False) == True: + subtitlesItems += u'"✔" ' + else: + subtitlesItems += '"" ' + if opt_selection_language == 'on': + columnLn = '--column="Language" ' + subtitlesItems += '"' + item['attributes']['language'] + '" ' + if opt_selection_match == 'on': + columnMatch = '--column="MatchedBy" ' + if item['attributes'].get('moviehash_match', False) == True: + subtitlesItems += '"HASH" ' + else: + subtitlesItems += '"name" ' + if opt_selection_rating == 'on': + columnRate = '--column="Rating" ' + subtitlesItems += '"' + str(item['attributes']['ratings']) + '" ' + if opt_selection_count == 'on': + columnCount = '--column="Downloads" ' + subtitlesItems += '"' + str(item['attributes']['download_count']).zfill(5) + '" ' + if opt_selection_fps == 'on': + columnFPS = '--column="FPS" ' + subtitlesItems += '"' + str(item['attributes']['fps']) + '" ' + + if subtitlesMatchedByName == 0: + tilestr = f' --title="Subtitles for: {videoTitle_window}" ' + textstr = f' --text="<b>Video title:</b> {videoTitle_escaped}\n<b>File name:</b> {videoFileName_escaped}" ' + elif subtitlesMatchedByHash == 0: + tilestr = ' --title="Subtitles Search" ' + textstr = f' --text="Search results using file name, NOT video detection. <b>May be unreliable...</b>\n<b>File name:</b> {videoFileName_escaped}" ' + else: # a mix of the two + tilestr = f' --title="Subtitles for: {videoTitle_window}" ' + textstr = f' --text="Search results using file name AND video detection.\n<b>Video title:</b> {videoTitle_escaped}\n<b>File name:</b> {videoFileName_escaped}" ' + + # Spawn zenity "list" dialog + process_subtitlesSelection = subprocess.Popen('zenity --width=' + str(opt_gui_width) + ' --height=' + str(opt_gui_height) + ' --list' + tilestr + textstr + ' --column "id" --hide-column=1 ' + + '--column="Available subtitles" ' + columnHi + columnLn + columnMatch + columnRate + columnCount + columnFPS + subtitlesItems + ' --print-column=ALL', + shell=True, stdout=subprocess.PIPE) + + # Get back the user's choice + result_subtitlesSelection = process_subtitlesSelection.communicate() + + # The results contain a subtitles? + if result_subtitlesSelection[0]: + result = str(result_subtitlesSelection[0], 'utf-8', 'replace').strip("\n") + + # Get index and result + [subtitlesSelectedIndex, subtitlesSelectedName] = result.split('|')[0:2] + else: + if process_subtitlesSelection.returncode == 0: + subtitlesSelectedName = subtitlesResultList['data'][0]['attributes']['files'][0]['file_name'] + subtitlesSelectedIndex = 0 + + # Return the result (selected subtitles name and index) + return (subtitlesSelectedName, subtitlesSelectedIndex) + +# ==== KDE (kdialog) selection window ========================================== + +def selectionKDE(subtitlesResultList): + """KDE subtitles selection window using kdialog""" + subtitlesSelectedName = u'' + subtitlesSelectedIndex = -1 + + subtitlesItems = u'' + subtitlesMatchedByHash = 0 + subtitlesMatchedByName = 0 + + videoTitle_window = videoTitle + videoTitle_escaped = videoTitle + videoFileName_escaped = escapeGUI_kdialog(videoFileName) + + # Generate selection window content + # TODO doesn't support additional columns + index = 0 + + for idx, item in enumerate(subtitlesResultList['data']): + if opt_ignore_hi and item['attributes'].get('hearing_impaired', False) == True: + continue + if opt_ignore_foreign_parts_only and item['attributes'].get('foreign_parts_only', False) == True: + continue + if opt_ignore_ai_translated and item['attributes'].get('ai_translated', False) == True: + continue + if opt_ignore_machine_translated and item['attributes'].get('machine_translated', False) == True: + continue + + if item['attributes'].get('moviehash_match', False) == True: + subtitlesMatchedByHash += 1 + else: + subtitlesMatchedByName += 1 + + # key + subtitles name + subtitlesItems += str(index) + ' "' + item['attributes']['files'][0]['file_name'] + '" ' + index += 1 + + if subtitlesMatchedByName == 0: + tilestr = f' --title="Subtitles for {videoTitle_window}" ' + menustr = f' --menu="<b>Video title:</b> {videoTitle_escaped}<br><b>File name:</b> {videoFileName_escaped}" ' + elif subtitlesMatchedByHash == 0: + tilestr = ' --title="Subtitles Search" ' + menustr = f' --menu="Search results using file name, NOT video detection. <b>May be unreliable...</b><br><b>File name:</b> {videoFileName_escaped}" ' + else: # a mix of the two + tilestr = f' --title="Subtitles for {videoTitle_window}" ' + menustr = f' --menu="Search results using file name AND video detection.<br><b>Video title:</b> {videoTitle_escaped}<br><b>File name:</b> {videoFileName_escaped}" ' + + # Spawn kdialog "radiolist" + process_subtitlesSelection = subprocess.Popen('kdialog --geometry=' + str(opt_gui_width-220) + 'x' + str(opt_gui_height-128) + f'+128+128 {tilestr} {menustr} {subtitlesItems}', + shell=True, stdout=subprocess.PIPE) + + # Get back the user's choice + result_subtitlesSelection = process_subtitlesSelection.communicate() + + # The results contain the key matching a subtitles? + if result_subtitlesSelection[0]: + subtitlesSelectedIndex = int(str(result_subtitlesSelection[0], 'utf-8', 'replace').strip("\n")) + subtitlesSelectedName = subtitlesResultList['data'][subtitlesSelectedIndex]['attributes']['files'][0]['file_name'] + + # Return the result (selected subtitles name and index) + return (subtitlesSelectedName, subtitlesSelectedIndex) + +# ==== CLI selection mode ====================================================== + +def selectionCLI(subtitlesResultList): + """Command Line Interface, subtitles selection inside your current terminal""" + subtitlesSelectedName = u'' + subtitlesSelectedIndex = -1 + + subtitlesMatchedByHash = 0 + subtitlesMatchedByName = 0 + + # Check if search has results by hash or name + for item in subtitlesResultList['data']: + if item['attributes'].get('moviehash_match', False) == True: + subtitlesMatchedByHash += 1 + else: + subtitlesMatchedByName += 1 + + # Print video infos + if subtitlesMatchedByName == 0: + print("\n>> Subtitles for: " + videoTitle) + elif subtitlesMatchedByHash == 0: + print("\n>> Subtitles for file: " + videoFileName) + print(">> Search results using file name, NOT video detection. May be unreliable...") + else: # a mix of the two + print("\n>> Subtitles for: " + videoTitle) + print(">> Search results using using file name AND video detection.") + + print("\n>> Available subtitles:") + + # Print subtitles list on the terminal + for idx, item in enumerate(subtitlesResultList['data']): + if opt_ignore_hi and item['attributes'].get('hearing_impaired', False) == True: + continue + if opt_ignore_foreign_parts_only and item['attributes'].get('foreign_parts_only', False) == True: + continue + if opt_ignore_ai_translated and item['attributes'].get('ai_translated', False) == True: + continue + if opt_ignore_machine_translated and item['attributes'].get('machine_translated', False) == True: + continue + + subtitlesItemPre = u'> ' + subtitlesItem = u'"' + item['attributes']['files'][0]['file_name'] + u'"' + subtitlesItemPost = u'' + + if opt_selection_match == 'on': + if item['attributes'].get('moviehash_match', False) == True: + subtitlesItemPre += '(hash) > ' + else: + subtitlesItemPre += '(name) > ' + if opt_selection_language == 'on': + subtitlesItemPre += item['attributes']['language'].upper() + ' > ' + + if opt_selection_hi == 'on' and item['attributes'].get('hearing_impaired', False) == True: + subtitlesItemPost += ' > ' + '\033[44m' + ' HI ' + '\033[0m' + if opt_selection_fps == 'on': + subtitlesItemPost += ' > ' + '\033[100m' + str(item['attributes']['fps']) + ' FPS' + '\033[0m' + if opt_selection_rating == 'on': + subtitlesItemPost += ' > ' + '\033[100m' + 'Rating: ' + str(item['attributes']['ratings']) + '\033[0m' + if opt_selection_count == 'on': + subtitlesItemPost += ' > ' + '\033[100m' + 'Downloads: ' + str(item['attributes']['download_count']) + '\033[0m' + + # type # season_number # episode_number + if (item['attributes']['feature_details'].get('season_number', 0) != 0 and item['attributes']['feature_details'].get('episode_number', 0) != 0): + subtitlesItemPost += ' > ' + '\033[100m' + 'S' + str(item['attributes']['feature_details']['season_number']).zfill(2) + 'E' + str(item['attributes']['feature_details']['episode_number']).zfill(2) + '\033[0m' + + idx += 1 # We display subtitles indexes starting from 1, 0 is reserved for cancel + + if item['attributes'].get('moviehash_match', False) == True: + print("\033[92m[" + str(idx).rjust(2, ' ') + "]\033[0m " + subtitlesItemPre + subtitlesItem + subtitlesItemPost) + else: + print("\033[93m[" + str(idx).rjust(2, ' ') + "]\033[0m " + subtitlesItemPre + subtitlesItem + subtitlesItemPost) + + # Ask user to selected a subtitles + print("\033[91m[ 0]\033[0m Cancel search") + while (subtitlesSelectedIndex < 0 or subtitlesSelectedIndex > idx): + try: + subtitlesSelectedIndex = int(input("\n>> Enter your choice [0-" + str(idx) + "]: ")) + except KeyboardInterrupt: + sys.exit(1) + except: + subtitlesSelectedIndex = -1 + + if subtitlesSelectedIndex <= 0: + print("Cancelling search...") + return ("", -1) + + subtitlesSelectedIndex -= 1 + subtitlesSelectedName = subtitlesResultList['data'][subtitlesSelectedIndex]['attributes']['files'][0]['file_name'] + + # Return the result (selected subtitles name and index) + return (subtitlesSelectedName, subtitlesSelectedIndex) + +# ==== Automatic selection mode ================================================ + +def selectionAuto(subtitlesResultList, languageList): + """Automatic subtitles selection using filename match""" + subtitlesSelectedName = u'' + subtitlesSelectedIndex = -1 + + videoFileParts = videoFileName.replace('-', '.').replace(' ', '.').replace('_', '.').lower().split('.') + languageListReversed = list(reversed(languageList)) + maxScore = -1 + + for idx, item in enumerate(subtitlesResultList['data']): + score = 0 + # points to respect languages priority + score += languageListReversed.index(item['attributes']['language']) * 100 + # extra point if the sub is found by hash + if item['attributes'].get('moviehash_match', False) == True: + score += 1 + # points for filename mach + subFileParts = item['attributes']['files'][0]['file_name'].replace('-', '.').replace(' ', '.').replace('_', '.').lower().split('.') + for subPart in subFileParts: + for filePart in videoFileParts: + if subPart == filePart: + score += 1 + if score > maxScore: + maxScore = score + subtitlesSelectedIndex = idx + subtitlesSelectedName = subtitlesResultList['data'][subtitlesSelectedIndex]['attributes']['files'][0]['file_name'] + + # Return the result (selected subtitles name and index) + return (subtitlesSelectedName, subtitlesSelectedIndex) + +# ==== Dependency checkers ===================================================== + +def pythonChecker(): + """Check the availability of Python 3.6 interpreter""" + if sys.version_info < (3, 6): + superPrint("error", "Wrong Python version", "You need <b>Python 3.6</b> to use OpenSubtitlesDownload.") + return False + return True + +def dependencyChecker(): + """Check the availability of tools used as dependencies""" + if opt_gui == 'gnome': + for tool in ['wget']: + path = shutil.which(tool) + if path is None: + superPrint("error", "Missing dependency!", f"<b>{tool}</b> is not available, please install it!") + return False + return True + +# ==== REST API helpers ======================================================== + +def getUserToken(username, password): + try: + headers = { + "User-Agent": f"{APP_NAME} v{APP_VERSION}", + "Api-key": f"{APP_API_KEY}", + "Accept": "application/json", + "Content-Type": "application/json" + } + payload = { + "username": username, + "password": password + } + + data = json.dumps(payload).encode('utf-8') + req = urllib_request_Request(API_URL_LOGIN, data=data, headers=headers) + with urllib_request_urlopen(req) as response: + response_data = json.loads(response.read().decode('utf-8')) + + #print("getUserToken() response data: " + str(response_data)) + return response_data['token'] + + except (urllib.error.HTTPError, urllib.error.URLError) as err: + print("Urllib error (", err.code, ") ", err.reason) + superPrint("error", "OpenSubtitles.com login error!", "An error occurred while connecting to the OpenSubtitles.com server") + sys.exit(2) + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + superPrint("error", "OpenSubtitles.com login error!", "An error occurred while connecting to the OpenSubtitles.com server") + sys.exit(2) + +def destroyUserToken(USER_TOKEN): + try: + headers = { + "User-Agent": f"{APP_NAME} v{APP_VERSION}", + "Api-key": f"{APP_API_KEY}", + "Authorization": f"Bearer {USER_TOKEN}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + req = urllib_request_Request(API_URL_LOGOUT, headers=headers) + with urllib_request_urlopen(req) as response: + response_data = json.loads(response.read().decode('utf-8')) + + #print("destroyUserToken() response data: " + str(response_data)) + return response_data + + except (urllib.error.HTTPError, urllib.error.URLError) as err: + print("Urllib error (", err.code, ") ", err.reason) + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + +def searchSubtitles(**kwargs): + try: + headers = { + "User-Agent": f"{APP_NAME} v{APP_VERSION}", + "Api-key": f"{APP_API_KEY}" + } + + query_params = urllib.parse.urlencode(kwargs) + url = f"{API_URL_SEARCH}?{query_params}" + req = urllib_request_Request(url, headers=headers) + with urllib_request_urlopen(req) as response: + response_data = json.loads(response.read().decode('utf-8')) + + #print("searchSubtitles() response data: " + str(response_data)) + return response_data + + except (urllib.error.HTTPError, urllib.error.URLError) as err: + print("Urllib error (", err.code, ") ", err.reason) + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + +def getSubtitlesInfo(USER_TOKEN, file_id): + try: + headers = { + "User-Agent": f"{APP_NAME} v{APP_VERSION}", + "Api-key": f"{APP_API_KEY}", + "Authorization": f"Bearer {USER_TOKEN}", + "Accept": "application/json", + "Content-Type": "application/json" + } + payload = { + "file_id": file_id + } + + data = json.dumps(payload).encode('utf-8') + req = urllib_request_Request(API_URL_DOWNLOAD, data=data, headers=headers) + with urllib_request_urlopen(req) as response: + response_data = json.loads(response.read().decode('utf-8')) + + #print("getSubtitlesInfo() response data:" + response_data) + return response_data + + except (urllib.error.HTTPError, urllib.error.URLError) as err: + print("Urllib error (", err.code, ") ", err.reason) + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + +def downloadSubtitles(USER_TOKEN, subURL, subPath): + try: + headers = { + "User-Agent": f"{APP_NAME} v{APP_VERSION}", + "Api-key": f"{APP_API_KEY}", + "Authorization": f"Bearer {USER_TOKEN}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + req = urllib_request_Request(subURL, headers=headers) + with urllib_request_urlopen(req) as response: + decodedStr = response.read().decode('utf-8') + byteswritten = open(subPath, 'w', encoding='utf-8', errors='replace').write(decodedStr) + if byteswritten > 0: + return 0 + + return 1 + + except (urllib.error.HTTPError, urllib.error.URLError) as err: + print("Urllib error (", err.code, ") ", err.reason) + except Exception: + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) + +# ==== Rate-limit handling ===================================================== + +class OpenSubtitlesRateLimiter: + def __init__(self, max_requests=10, time_window=60, min_delay=0.1, max_retries=3): + """Rate-limited wrapper for urllib.request with OpenSubtitles 429 handling""" + + # max_requests: Maximum requests allowed in time_window (fallback) + # time_window: Time window in seconds (fallback) + # min_delay: Minimum delay between requests in seconds + # max_retries: Maximum number of retry attempts for 429 errors + + self.max_requests = max_requests + self.time_window = time_window + self.min_delay = min_delay + self.max_retries = max_retries + + self.request_times = deque() + self.lock = Lock() + self.last_request_time = 0 + + self.api_limit = None + self.api_remaining = None + self.api_reset_time = None + + def _parse_rate_limit_headers(self, headers): + """Parse OpenSubtitles rate limit headers""" + try: + if 'X-RateLimit-Limit' in headers: + self.api_limit = int(headers['X-RateLimit-Limit']) + if 'X-RateLimit-Remaining' in headers: + self.api_remaining = int(headers['X-RateLimit-Remaining']) + if 'X-RateLimit-Reset' in headers: + # Convert to timestamp if needed + reset_value = headers['X-RateLimit-Reset'] + if isinstance(reset_value, str) and reset_value.isdigit(): + self.api_reset_time = int(reset_value) + else: + self.api_reset_time = int(time.time()) + 60 # fallback + except (ValueError, KeyError): + pass # Ignore parsing errors, fall back to local rate limiting + + def _should_wait_for_api_limits(self): + """Check if we should wait based on API-provided rate limit info""" + if self.api_remaining is not None and self.api_remaining <= 0: + if self.api_reset_time: + current_time = int(time.time()) + if current_time < self.api_reset_time: + wait_time = self.api_reset_time - current_time + print(f"API rate limit exhausted. Waiting {wait_time}s until reset...") + return wait_time + return 0 + + def _wait_if_needed(self): + """Implement rate limiting logic with API feedback""" + with self.lock: + current_time = time.time() + + # First check API-provided limits + api_wait = self._should_wait_for_api_limits() + if api_wait > 0: + time.sleep(api_wait) + current_time = time.time() + + # Fallback to local rate limiting if no API info + if self.api_remaining is None: + # Remove old requests outside the time window + while self.request_times and current_time - self.request_times[0] > self.time_window: + self.request_times.popleft() + + # Check if we've hit the local rate limit + if len(self.request_times) >= self.max_requests: + sleep_time = self.time_window - (current_time - self.request_times[0]) + 0.1 + if sleep_time > 0: + print(f"Local rate limit reached. Sleeping for {sleep_time:.2f} seconds...") + time.sleep(sleep_time) + current_time = time.time() + + # Ensure minimum delay between requests + time_since_last = current_time - self.last_request_time + if time_since_last < self.min_delay: + sleep_time = self.min_delay - time_since_last + time.sleep(sleep_time) + current_time = time.time() + + # Record this request for local tracking + self.request_times.append(current_time) + self.last_request_time = current_time + + def _handle_429_retry(self, url, data, headers, retry_count=0): + """Handle 429 responses with proper retry logic""" + if retry_count >= self.max_retries: + raise urllib.error.HTTPError(url, 429, "Max retries exceeded for 429 Too Many Requests", headers, None) + + try: + # Create and execute request + req = urllib.request.Request( + url=url, + data=data, + headers=headers or {} + ) + + response = urllib.request.urlopen(req) + + # Parse rate limit headers from successful response + self._parse_rate_limit_headers(response.headers) + + return response + + except urllib.error.HTTPError as e: + if e.code == 406: # 406 Not Acceptable - Account out of downloads for 24hr period + superPrint("error", "HTTP error!", + "OpenSubtitlesDownload encountered an <b>HTTP error</b>, sorry about that...<br><br>" + \ + "Error: <b>HTTP 406 Not Acceptable</b> Account has exceeded daily download quota<br>" + \ + "Your OpenSubtitles account is out of downloads for the current 24-hour period<br>." + \ + "Please wait until your quota resets or upgrade your account.") + sys.exit(1) + + if e.code == 429: # 429 Too Many Requests + print(f"Received 429 Too Many Requests (attempt {retry_count + 1}/{self.max_retries})") + + # Parse rate limit headers from error response + if hasattr(e, 'headers') and e.headers: + self._parse_rate_limit_headers(e.headers) + + # Get retry delay from Retry-After header or API reset time + retry_after = None + if hasattr(e, 'headers') and e.headers: + retry_after = e.headers.get('Retry-After') + + if retry_after: + wait_time = int(retry_after) + print(f"Retry-After header suggests waiting {wait_time} seconds") + elif self.api_reset_time: + wait_time = max(1, self.api_reset_time - int(time.time())) + print(f"Using API reset time, waiting {wait_time} seconds") + else: + # Exponential backoff as fallback + wait_time = min(300, (2 ** retry_count) * 10) # Cap at 5 minutes + print(f"Using exponential backoff, waiting {wait_time} seconds") + + time.sleep(wait_time) + + # Reset API remaining counter since we waited + self.api_remaining = None + + # Recursive retry + return self._handle_429_retry(url, data, headers, retry_count + 1) + + else: + # Re-raise non-429 errors + raise + + def Request(self, url, data=None, headers=None, origin_req_host=None, unverifiable=False, method=None): + """ + Rate-limited replacement for urllib.request.Request with 429 handling + Returns the actual response object, not just a Request object + """ + self._wait_if_needed() + + # Convert headers dict to the format urllib expects + if headers is None: + headers = {} + + # Handle the request with 429 retry logic + return self._handle_429_retry(url, data, headers) + +class OpenSubtitlesRequestWrapper: + """ + Wrapper that maintains the original urllib.request.Request interface + but adds rate limiting and 429 handling behind the scenes + """ + def __init__(self, max_requests=10, time_window=60, min_delay=0.1, max_retries=1): + self.rate_limiter = OpenSubtitlesRateLimiter(max_requests, time_window, min_delay, max_retries) + + def Request(self, url, data=None, headers=None, origin_req_host=None, unverifiable=False, method=None): + """ + Drop-in replacement for urllib.request.Request + This returns a Request object like the original, but the actual HTTP call + happens when you use urllib.request.urlopen() + """ + # Create the request object as normal + req = urllib.request.Request( + url=url, + data=data, + headers=headers or {}, + origin_req_host=origin_req_host, + unverifiable=unverifiable, + method=method + ) + + # Add rate limiting metadata to the request object + req._rate_limiter = self.rate_limiter + return req + +# Enhanced urlopen function that handles the rate limiting +def rate_limited_urlopen(url_or_request, data=None, timeout=None): + """Rate-limited replacement for urllib.request.urlopen """ + if hasattr(url_or_request, '_rate_limiter'): + # This is our wrapped request object + rate_limiter = url_or_request._rate_limiter + return rate_limiter.Request( + url=url_or_request.full_url, + data=url_or_request.data, + headers=dict(url_or_request.headers) + ) + else: + # Fallback to regular urlopen for unwrapped requests + return urllib.request.urlopen(url_or_request, data, timeout) + +# Global instances # Adjust these parameters based on OpenSubtitles API documentation +request_wrapper = OpenSubtitlesRequestWrapper( + max_requests=40, # Conservative limit (adjust based on your API tier) + time_window=10, # 10-second windows for responsive limiting + min_delay=0.25, # 250ms minimum between requests + max_retries=5 # Retry 429 errors up to 5 times +) + +def urllib_request_Request(*args, **kwargs): + """Drop-in replacement for urllib.request.Request with OpenSubtitles rate limiting""" + return request_wrapper.Request(*args, **kwargs) + +def urllib_request_urlopen(*args, **kwargs): + """Drop-in replacement for urllib.request.urlopen with rate limiting""" + return rate_limited_urlopen(*args, **kwargs) + +# ============================================================================== +# ==== Main program (execution starts here) ==================================== +# ============================================================================== + +# ==== Exit code returned by the software. You can use them to improve scripting behaviours. +# 0: Success, and subtitles downloaded +# 1: Success, but no subtitles found or downloaded +# 2: Failure + +ExitCode = 2 + +# ==== File and language lists initialization + +videoPathList = [] +languageList = [] + +currentVideoPath = u"" +currentLanguage = u"" + +# ==== Environment parsing + +if osd_username == '': + osd_username = os.getenv('OSD_ENV_USERNAME', '') + +if osd_password == '': + osd_password = os.getenv('OSD_ENV_PASSWORD', '') + +# ==== Argument parsing + +# Get OpenSubtitlesDownload.py script absolute path +if os.path.isabs(sys.argv[0]): + scriptPath = sys.argv[0] +else: + scriptPath = os.getcwd() + "/" + str(sys.argv[0]) + +# Setup ArgumentParser +parser = argparse.ArgumentParser(prog='OpenSubtitlesDownload.py', + description='Automatically find and download the right subtitles for your favorite videos!', + formatter_class=argparse.RawTextHelpFormatter) + +parser.add_argument('--cli', help="Force CLI mode", action='store_true') +parser.add_argument('-g', '--gui', help="Select the GUI you want from: auto, kde, gnome, cli (default: auto)") +parser.add_argument('-u', '--username', help="Set opensubtitles.com account username") +parser.add_argument('-p', '--password', help="Set opensubtitles.com account password") +parser.add_argument('-l', '--lang', help="Specify the language in which the subtitles should be downloaded (default: en).\nSyntax:\n-l en,fr: search in both language") +parser.add_argument('-s', '--search', help="Search mode: hash, filename, hash_then_filename, hash_and_filename (default: hash_then_filename)") +parser.add_argument('-t', '--select', help="Selection mode: manual, default, auto") +parser.add_argument('-a', '--auto', help="Force automatic selection and download of the best subtitles found", action='store_true') +parser.add_argument('-i', '--skip', help="Skip search if an existing subtitles file is detected", action='store_true') +parser.add_argument('-o', '--output', help="Override subtitles download path, instead of next to their video file") +parser.add_argument('-x', '--suffix', help="Force language code file suffix", action='store_true') +parser.add_argument('--noai', help="Ignore AI or machine translated subtitles", action='store_true') +parser.add_argument('--nohi', help="Ignore HI (hearing impaired) subtitles", action='store_true') +parser.add_argument('searchPathList', help="The video file(s) or folder(s) for which subtitles should be searched and downloaded", nargs='+') +arguments = parser.parse_args() + +# Handle arguments +if arguments.cli: + opt_gui = 'cli' +if arguments.gui: + opt_gui = arguments.gui +if arguments.username and arguments.password: + osd_username = arguments.username + osd_password = arguments.password +if arguments.lang: + opt_languages = arguments.lang +if arguments.search: + opt_search_mode = arguments.search +if arguments.skip: + opt_search_overwrite = False +if arguments.select: + opt_selection_mode = arguments.select +if arguments.auto: + opt_selection_mode = 'auto' +if arguments.output: + opt_output_path = arguments.output +if arguments.suffix: + opt_language_suffix = 'on' +if arguments.noai: + opt_ignore_ai_translated = True +if arguments.nohi: + opt_ignore_hi = True + +# GUI auto detection +if opt_gui == 'auto': + # Note: "ps cax" only output the first 15 characters of the executable's names + ps = str(subprocess.Popen(['ps', 'cax'], stdout=subprocess.PIPE).communicate()[0]).split('\n') + for line in ps: + if ('gnome-session' in line) or ('cinnamon-sessio' in line) or ('mate-session' in line) or ('xfce4-session' in line): + opt_gui = 'gnome' + break + elif 'ksmserver' in line: + opt_gui = 'kde' + break + +# Sanitize some settings +if opt_gui not in ['gnome', 'kde', 'cli']: + opt_gui = 'cli' + opt_search_mode = 'hash_then_filename' + opt_selection_mode = 'auto' + print("Unknown GUI, falling back to an automatic CLI mode") + +if opt_search_mode not in ['hash', 'filename', 'hash_then_filename', 'hash_and_filename']: + opt_search_mode = 'hash_then_filename' + +if opt_selection_mode not in ['manual', 'default', 'auto']: + opt_selection_mode = 'default' + +# ==== Various checks + +# Check for Python 3.6 +if pythonChecker() is False: + sys.exit(2) + +# Check for the necessary tools (must be done after GUI auto detection) +if dependencyChecker() is False: + sys.exit(2) + +# Check for OSD credentials +if not osd_username or not osd_password: + superPrint("warning", "OpenSubtitles.com account required!", "A valid account from OpenSubtitles.com is <b>REQUIRED</b>, please register on the website!") + sys.exit(2) + +# ==== Count languages selected for this search + +if isinstance(opt_languages, list): + languageList = opt_languages +else: + languageList = opt_languages.split(',') + +languageCount_search = len(languageList) + +# ==== Get video paths, validate them, and if needed check if subtitles already exists + +for i in arguments.searchPathList: + path = os.path.abspath(i) + if os.path.isdir(path): # if it's a folder + if opt_gui == 'cli': # check all of the folder's (recursively) + for root, _, items in os.walk(path): + for item in items: + localPath = os.path.join(root, item) + if checkFileValidity(localPath): + if opt_search_overwrite or (not opt_search_overwrite and not checkSubtitlesExists(localPath)): + videoPathList.append(localPath) + else: # check all of the folder's files + for item in os.listdir(path): + localPath = os.path.join(path, item) + if checkFileValidity(localPath): + if opt_search_overwrite or (not opt_search_overwrite and not checkSubtitlesExists(localPath)): + videoPathList.append(localPath) + elif checkFileValidity(path): # if it is a file + if opt_search_overwrite or (not opt_search_overwrite and not checkSubtitlesExists(path)): + videoPathList.append(path) + +# If videoPathList is empty, abort! +if not videoPathList: + sys.exit(1) + +# ==== Instances dispatcher ==================================================== + +# The first video file will be processed by this instance +currentVideoPath = videoPathList[0] +videoPathList.pop(0) + +# The remaining file(s) are dispatched to new instance(s) of this script +for videoPathDispatch in videoPathList: + + # Pass settings + command = [ sys.executable, scriptPath, + "-g", opt_gui, "-s", opt_search_mode, "-t", opt_selection_mode, "-l", opt_languages ] + + if not opt_search_overwrite: + command.append("-i") + + if opt_language_suffix == 'on': + command.append("-x") + + if opt_output_path: + command.append("-o") + command.append(opt_output_path) + + if arguments.username and arguments.password: + command.append("-u") + command.append(arguments.username) + command.append("-p") + command.append(arguments.password) + + # Pass video file + command.append(videoPathDispatch) + + # Do not spawn too many instances at once, avoid error '429 Too Many Requests' + time.sleep(2) + + if opt_gui == 'cli' and opt_selection_mode != 'auto': + # Synchronous call + process_videoDispatched = subprocess.call(command) + else: + # Asynchronous call + process_videoDispatched = subprocess.Popen(command) + +# ==== Search and download subtitles =========================================== + +try: + USER_TOKEN = [] + subtitlesResultList = [] + languageCount_results = 0 + + ## Get file hash, size and name + videoTitle = u'' + videoHash = hashFile(currentVideoPath) + videoSize = os.path.getsize(currentVideoPath) + videoFileName = os.path.basename(currentVideoPath) + + ## Search for subtitles + try: + if (opt_search_mode == 'hash_and_filename'): + subtitlesResultList = searchSubtitles(moviehash=videoHash, query=videoFileName, languages=opt_languages) + #print(f"SEARCH BY HASH AND NAME >>>>> length {len(subtitlesResultList['data'])} >>>>> {subtitlesResultList['data']}") + else: + if any(mode in opt_search_mode for mode in ['hash_then_filename', 'hash']): + subtitlesResultList = searchSubtitles(moviehash=videoHash, languages=opt_languages) + #print(f"SEARCH BY HASH >>>>> length {len(subtitlesResultList['data'])} >>>>> {subtitlesResultList['data']}") + if ((opt_search_mode == 'filename') or + (opt_search_mode == 'hash_then_filename' and len(subtitlesResultList['data']) == 0)): + subtitlesResultList = searchSubtitles(query=videoFileName, languages=opt_languages) + #print(f"SEARCH BY NAME >>>>> length {len(subtitlesResultList['data'])} >>>>> {subtitlesResultList['data']}") + + except Exception: + superPrint("error", "Search error!", "Unable to reach opensubtitles.com servers!<br><b>Search error</b>") + sys.exit(2) + + ## Parse the results of the search query + if subtitlesResultList and 'data' in subtitlesResultList and len(subtitlesResultList['data']) > 0: + # Mark search as successful + languageCount_results += 1 + + subName = u'' + subIndex = 0 + + # If there is only one subtitles (matched by file hash), auto-select it (except in CLI mode) + if (len(subtitlesResultList['data']) == 1) and (subtitlesResultList['data'][0]['attributes'].get('moviehash_match', False) == True): + if opt_selection_mode != 'manual': + subName = subtitlesResultList['data'][0]['attributes']['files'][0]['file_id'] + + # Check if we have a valid title, found by hash + for item in subtitlesResultList['data']: + if item['attributes'].get('moviehash_match', False) == True: + videoTitle = item['attributes']['feature_details']['movie_name'] + break + + # If there is more than one subtitles and opt_selection_mode != 'auto', + # then let the user decide which one will be downloaded + if not subName: + if opt_selection_mode == 'auto': + # Automatic subtitles selection + (subName, subIndex) = selectionAuto(subtitlesResultList, languageList) + else: + # Go through the list of subtitles and handle 'auto' settings activation + for item in subtitlesResultList['data']: + if opt_selection_match == 'auto': + if (opt_search_mode == 'hash_and_filename' or opt_search_mode == 'hash_then_filename'): + if item['attributes'].get('moviehash_match', False) == False: + opt_selection_match = 'on' + if opt_selection_language == 'auto' and languageCount_search > 1: + opt_selection_language = 'on' + if opt_selection_hi == 'auto' and item['attributes'].get('hearing_impaired', False) == True: + opt_selection_hi = 'on' + if opt_selection_rating == 'auto' and item['attributes']['ratings'] != '0.0': + opt_selection_rating = 'on' + if opt_selection_count == 'auto': + opt_selection_count = 'on' + if opt_selection_fps == 'auto' and item['attributes'].get('fps', '0.0') != '0.0': + opt_selection_fps = 'on' + + # Spaw selection window + if opt_gui == 'gnome': + (subName, subIndex) = selectionGnome(subtitlesResultList) + elif opt_gui == 'kde': + (subName, subIndex) = selectionKDE(subtitlesResultList) + else: # CLI + (subName, subIndex) = selectionCLI(subtitlesResultList) + + ## At this point a subtitles should be selected + if subName: + # Log-in to the API + USER_TOKEN = getUserToken(username=osd_username, password=osd_password) + + # Prepare download + fileId = subtitlesResultList['data'][int(subIndex)]['attributes']['files'][0]['file_id'] + fileInfo = getSubtitlesInfo(USER_TOKEN, fileId) + + # Quote the URL to avoid characters like brackets () causing errors in wget command below + subURL = fileInfo['link'] + subSuffix = subURL.split('.')[-1].strip("'") + subLangName = subtitlesResultList['data'][int(subIndex)]['attributes']['language'] + subPath = u'' + + if opt_output_path and os.path.isdir(os.path.abspath(opt_output_path)): + # Use the output path provided by the user + subPath = os.path.abspath(opt_output_path) + "/" + currentVideoPath.rsplit('.', 1)[0].rsplit('/', 1)[1] + '.' + subSuffix + else: + # Use the path of the input video, and the suffix of the subtitles file + subPath = currentVideoPath.rsplit('.', 1)[0] + '.' + subSuffix + + # Write language code into the filename? + if opt_language_suffix == 'on': + subPath = subPath.rsplit('.', 1)[0] + opt_language_suffix_separator + subtitlesResultList['data'][int(subIndex)]['attributes']['language'] + '.' + subSuffix + + # Empty videoTitle? Use filename + if not videoTitle: + videoTitle = videoFileName + + ## Download and unzip the selected subtitles + if opt_gui == 'gnome': + # Escape non-alphanumeric characters from the subtitles download path for wget, and video title for zenity + subPathEscaped = escapePath_wget(subPath) + videoTitleEscaped = escapeGUI_zenity(videoTitle) + # Download with wget, piped into zenity --progress + process_subtitlesDownload = subprocess.call(f'(wget -q -O "{subPathEscaped}" "{subURL}") 2>&1 ' + + '| (zenity --auto-close --progress --pulsate --title="Downloading subtitles, please wait..." ' + + f'--text="Downloading <b>{subLangName}</b> subtitles for <b>{videoTitleEscaped}</b>...")', shell=True) + else: + if opt_gui == 'cli': + print(f">> Downloading '{subLangName}' subtitles for '{videoTitle}'") + process_subtitlesDownload = downloadSubtitles(USER_TOKEN, fileInfo['link'], subPath) + + # If an error occurs, say so + if process_subtitlesDownload != 0: + superPrint("error", "Subtitling error!", f"An error occurred while downloading or writing <b>{subLangName}</b> subtitles for <b>{videoTitle}</b>.") + sys.exit(2) + + ## HOOK # Use a secondary tool on the subtitles file after a successful download? + if process_subtitlesDownload == 0 and len(custom_command) > 0: + subPathEscaped = escapePath_wget(subPath) + process_subtitlesDownload = subprocess.call(f'{custom_command} "{subPathEscaped}"', shell=True) + + ## Print a message if no subtitles have been found, for any of the languages + if languageCount_results == 0: + superPrint("info", "No subtitles available :-(", f"<b>No subtitles found</b> for this video:<br><i>{videoFileName}</i>") + ExitCode = 1 + else: + ExitCode = 0 + +except KeyboardInterrupt: + sys.exit(1) + +except urllib.error.HTTPError as e: + superPrint("error", "Network error", "Network error: " + e.reason) + +except (OSError, IOError, RuntimeError, AttributeError, TypeError, NameError, KeyError): + # An unknown error occur, let's apologize before exiting + superPrint("error", "Unexpected error!", + "OpenSubtitlesDownload encountered an <b>unknown error</b>, sorry about that...<br><br>" + \ + "Error: <b>" + str(sys.exc_info()[0]).replace('<', '[').replace('>', ']') + "</b><br>" + \ + "Line: <b>" + str(sys.exc_info()[-1].tb_lineno) + "</b><br><br>" + \ + "Just to be safe, please check:<br>" + \ + "- Your Internet connection status<br>" + \ + "- www.opensubtitles.com availability<br>" + \ + "- Your download limits (10 subtitles per 24h for non VIP users)<br>" + \ + "- That are using the latest version of this software ;-)") + +except Exception: + # Catch unhandled exceptions but do not spawn an error window + print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0])) diff --git a/.local/bin/scripts/fsubs b/.local/bin/scripts/fsubs @@ -0,0 +1,3 @@ +#!/bin/sh + +OpenSubtitlesDownload.py -u $(pass osd_user-xyz) -p $(pass osd_pass-xyz)