Шифрованный SOCKS5-прокси на своем сервере

Я уже писал как поднять шифрованный прокси средствами ssh, и до сих пор я сам использовал именно этот метод. Однако при всех его плюсах, таких как проверенность протоколов и механизмов, и «изкоробочность» (при условии, конечно, наличия под рукой своего сервера), есть и довольно большой минус: задача проксирования для ssh не первоочередная и справляется он с ней не лучшим образом.

Поэтому я в очередной раз загуглил и нашел более адаптированное решение предназначенное именно для проксирования с шифрованием — shadowsocks.

Это клиент-серверная штука, изначально написанная на питоне, но существующая и в переписанном, если память не изменяет, на C варианте.

Установка сервера (debian/ubuntu)

Ставим софт

 
 
  1. apt install shadowsocks-libev rng-tools

Настраиваем

/etc/default/rng-tools
 
  1. HRNGDEVICE=/dev/urandom
/etc/shadowsocks-libev/config.json
 
  1. {
  2. "server":"SERVER_IP",
  3. "server_port":PORT,
  4. "local_port":1080,
  5. "password":"PA$$W0RD",
  6. "timeout":60,
  7. "method":"aes-128-gcm"
  8. }

Установка клиента

Клиенты для всяческих платформ указаны на официальном сайте. Лично я для винды использовал shadowsocks-windows (портативная версия), а для линукса — shadowsocks-qt5, доступный в штатном репозитории дебиана.

Настраиваются клиенты максимально похоже — все что нужно указать: ip и порт сервера, алгоритм шифрования и пароль, указанные при настройке сервера; а также локальный порт на клиенте, к которому будут подключаться наши приложения (браузер и т.д.)

В самом приложении указываем настройки прокси:

SOCKS5
IP: 127.0.0.1
PORT: 3128 (ну или какой вы укажите при настройке клиента)

Например в файрфоксе это будет выглядеть так:

Ну и все, должно работать.

Безопасность сервера (настройка fail2ban)

Само собой мы защитили подключение паролем, но банить по айпи всяких пытающихся пролезть — лишним не будет. Докидываем 2 файла в конфиг fail2ban (если он у вас установлен)

/etc/fail2ban/filter.d/shadowsocks-libev.conf
 
  1. [INCLUDES]
  2. before = common.conf
  3. [Definition]
  4. _daemon = ss-server
  5. failregex = ^\w+\s+\d+ \d+:\d+:\d+\s+%(__prefix_line)sERROR:\s+failed to handshake with <HOST>: authentication error$
  6. ignoreregex =
  7. datepattern = %%Y-%%m-%%d %%H:%%M:%%S

и

/etc/fail2ban/jail.d/shadowsocks-libev.conf
 
  1. [shadowsocks-libev]
  2. enabled = true
  3. filter = shadowsocks-libev
  4. port = PORT
  5. logpath = /var/log/syslog
  6. maxretry = 3
  7. findtime = 3600
  8. bantime = 3600

(PORT — порт подключения к серверу, указанный нами в конфиге сервера)

Таким образом буквально минут за 5-10 можно сделать для себя адекватный прокси с шифрованным каналом.

Scorpion’s Revenge

Warner Bros. решили не дожидаться новой матрицы и запхали ангентский додж в новый Mortal Kombat Legends: Scorpion’s Revenge (название коротковато ящитаю)

Пара слов о.

К сожалению, даже у ворнеров не получилось сделать МК моей мечты. Отдельные моменты вроде и неплохие, но персонажи не цепляют, а история в очередной раз повторяет первый МК с легкими переработками. Название вроде как обещает историю Скорпиона, но по факту она тут нифига не доминирует и это печально. Саму суть турнира опять слили после 2й битвы, сведя все к какому-то неорганизованному махычу, который по идее не вписывается в правила никаким боком и должен быть быть отслежен и остановлен (но всем пох). Понравился Горо — единственный персонаж, как по мне, в котором ощущается адовая брутальность. То как его слили — тоже вызывает много вопросов (но всем пох). В общем проходнячок.

Фикс нового дизайна ютуба (придал компактность)

Так как ютуб решил полностью отказаться от поддержки своего старого дизайна, пришлось пилить свой собственный юзерстиль под дизайн новый. Не могу смотреть на эти жирные кнопки, этот «воздух» между элементами — все это занимает слишком много места. Конечно? вернуть все как было уже не получится, но чутка скомпактить — вполне. На данный момент стиль не на 100% готов, но, скажем так, доведен до состояния, когда я его уже не правлю каждый день. Выглядит это примерно так:

Также уменьшены размеры элементов на странице видео — комментарии, описание, кнопки (подписка на канал, лайки и т.д.), расширены строки чата на почти всю ширину чата. Ну и еще какие-то мелочи, которые нет смысла перечислять. Вероятно что-то еще буду допиливать, но, как уже сказал, не каждый день =)

Стиль можно установить вот отсюда: https://userstyles.org/styles/181146/youtube-material-compact (еще он лежит на гитхабе).

Для того чтобы его заюзать — нужно расширение Stylus. Если у вас уже есть расширение для юзерскриптов (например ViolentMonkey) — с сайта userstyles.org можно установить этот стиль как скрипт.

Перенастройка DFS для использования DNS-резолвинга

По умолчанию сервис DFS использует короткие NetBIOS-имена для публикации своих неймспейсов (Namespace). Это может привести к недоступности неймспейса \\dfs.mycompany.local\dfsroot\, если вы хотите предоставить доступ кому-то за пределами вашей организации. При попытке захода в dfsroot, даже если пользователь попытается открыть его по полному имени (\\dfs.mycompany.local\dfsroot\), запрос переформулируется в \\dfs\dfsroot\ и возникнет проблема разрешения имени. При этом у вас может быть корректно настроена служба DNS между вашими сетями, и даже все шары внутри этого корня, добавленные с указанием полных имен серверов, могли бы быть доступны. Убедиться в том, что неймспейс резолвится по короткому имени, можно, выбрав его в списке Namespace в оснастке управления DFS на вкладке Namespace Servers: в графе Path будет указано \\dfs\dfsroot. Что делать в этой ситуации?

У DFS нет возможности переключить режим работы на DNS-резолвинг на лету, более того — потребуется удаление всех ранее созданных неймспейсов со всем содержимым. К счастью, текущие настройки неймспейсов можно экспортировать в xml-файл и использовать его для восстановления списка шар после пересоздания неймспейса.

Итак, представим что у нас есть сервер «dfs.mycompany.local», с корнем «dfsroot» и каким-то набором шар внутри него. Шаги для перевода сервера на лыжи DNS будут такими:

(В оснастке управления неймспейс может отображаться как \\dfs\dfsroot или как \\dfs.mycompany.local\dfsroot — это не влияет на резолвинг, но это важно при выполнении нижеприведенных команд. Я привожу их для варианта  \\dfs\dfsroot, в случае если у вас имя прописано полностью — в командах с dfsutil используйте его)

    1. Бэкапим настройки неймспейса:
       
       
      1. dfsutil /root:\\dfs\dfsroot /export:c:\dfsroot.xml
    2. Открываем файл бэкапа в блокноте, убеждаемся что он содержит описание всех шар неймспейса. Вид примерно такой:
       
       
      1. <?xml version="1.0"?>
      2. <Root Name="\\dfs\dfsroot" State="1" Timeout="300" >
      3.       <Target Server="dfs" Folder="dfsroot" State="2" />
      4.       <Link Name="Temp" State="1" Timeout="1800" >
      5.             <Target Server="fileserver" Folder="Temp" State="2" />
      6.       </Link>
      7.       <Link Name="Public" State="1" Timeout="1800" >
      8.            <Target Server="fileserver.mycompany.local" Folder="Public" State="2" />
      9.       </Link>
      10.       <Link Name="SecFiles" State="1" Timeout="1800" >
      11.            <Target Server="newfs.mycompany.local" Folder="SecFiles" State="2" />
      12.       </Link>
      13. </Root>
    3. Удаляем корень dfsroot
    4. Переводим DFS на работу с DNS:
       
       
      1. dfsutil.exe server registry dfsdnsconfig set
    5. Перезапускаем службу DFS:
       
       
      1. net stop dfs; net start dfs
  1. Заново создаем корень dfsroot, указывая в мастере полное имя сервера: dfs.mycompany.local
  2. Перезапускаем консоль управления DFS (это важно!), выбираем созданный неймспейс, и на вкладке Namespace Servers убеждаемся, что в графе Path прописан полный путь
  3. Редактируем файл бэкапа:
    1. Меняем, если надо
       
       
      1. Root Name="\\dfs\dfsroot"

      на

       
       
      1. Root Name="\\dfs.mycompany.local\dfsroot"
    2. Меняем
       
       
      1. Target Server="dfs"

      на

       
       
      1. Target Server="dfs.mycompany.local"
    3. Меняем, если надо, параметры «Target Server» для шар, если сервера прописаны короткими NetBIOS-именами (как в примере из п.2 для шары «Temp» указан сервер «fileserver» вместо «fileserver.mycompany.local»)
  4. Восстанавливаем из бэкапа корень (обратите внимание, что теперь в команде мы используем Namespace, содержащий полное имя — dfs.mycompany.local,  так как при его создании в п.6 мы прописали имя сервера полностью. Важно чтобы в файле бэкапа на текущий момент было указано оно же.)
     
     
    1. dfsutil /root:\\dfs.mycompany.local\dfsroot /import:c:\dfsroot.xml /set
  5. Проверяем в оснастке управления, что неймспейс заполнился нашими шарами.

После указанных процедур ваш сервер начать использовать DNS для резолвинга неймспейсов, вам же остается только при подключении новых шар не забывать указывать полные имена серверов их содержащих.

Статья на сайте MS: https://support.microsoft.com/en-us/help/244380/how-to-configure-dfs-to-use-fully-qualified-domain-names-in-referrals

Монтаж 146го уровня

В 1983-м году «Танго» был удостоен премии «Оскар». Технически невероятно сложный для своего времени монтажно-композиционный фильм. Для 1980 года это поразительно. Автору пришлось сделать порядка 16 000 раскадровок и с математической точностью соединить все элементы. К концу фильма количество «жильцов» достигает 36. Для создания «Танго» не использовались компьютерные технологии.

Я просто не представляю насколько титаническая работа была проделана, чтобы рассчитать все тайминги, снять все дубли, вручную все это собрать вместе. Настоящий гиковский фильм!

Гибридный сон/гибернация в Linux: два варианта

suspend icon

Гибридный режим сна/гибернации заключается в том, что если в режиме сна содержимое оперативки «замораживается» и подпитывается от сети, а при гибернации оно записывается на хард и подпитки от сети не требует, то в гибридном режиме оба процесса происходят одновременно. Таким образом гибридный режим обеспечивает моментальное восстановление работы при выходе из сна, но также страхует от сброса состояния при неожиданном отключении электроэнергии.

В линуксе есть как минимум два варианта использовать сон и гибернацию, скажем так, в смешанном режиме. Я решал эту задачу на ноутбуке c Debian (без какого-либо окружения рабочего стола, так что готовых кнопочек не нашлось).

Начну с простого

Гибридный режим

Сам по себе вызывается командой

 
 
  1. systemctl hybrid-sleep

На ноутбуке как правило хочется вызывать этот режим при закрытии крышки, для этого нужно отредактировать файл «/etc/systemd/logind.conf», изменив в секции «Login» параметр «HandleLidSwitch» с дефолтного «suspend» на «hybrid-sleep»

/etc/systemd/logind.conf
 
  1. [Login]
  2. #HandleLidSwitch=suspend
  3. HandleLidSwitch=hybrid-sleep

после этого нужно перезагрузить систему, или сервис systemd-logind (это перезапустит текущий сеанс пользователя, так что не забудьте сохранить все важное). Теперь при закрытии крышки будет активироваться гибридный режим.

Далее более сложный, но небезынтересный вариант.

Сон с отложенным переходом в гибернацию

Второй вариант смешанного режима заключается в том, что сначала система уходит в простой сон, но если сон подзатянулся, то происходит кратковременное пробуждение и переход в полноценную гибернацию. Это может иметь смысл для ноутбука, если есть потребность экономить энергию батареи во время продолжительной неактивности.

Для этого создаем файл /etc/systemd/system/suspend-sedation.service

/etc/systemd/system/suspend-sedation.service
 
  1. [Unit]
  2. Description=Hibernate after suspend
  3. Documentation=https://bbs.archlinux.org/viewtopic.php?pid=1420279#p1420279
  4. Documentation=https://bbs.archlinux.org/viewtopic.php?pid=1574125#p1574125
  5. Documentation=https://wiki.archlinux.org/index.php/Power_management
  6. Documentation=http://forums.debian.net/viewtopic.php?f=5&t=129088
  7. Documentation=https://wiki.debian.org/SystemdSuspendSedation
  8. Conflicts=hibernate.target hybrid-sleep.target
  9. Before=sleep.target
  10. StopWhenUnneeded=true
  11. [Service]
  12. Type=oneshot
  13. RemainAfterExit=yes
  14. Environment="ALARM_SEC=10800"
  15. Environment="WAKEALARM=/sys/class/rtc/rtc0/wakealarm"
  16. ExecStart=/usr/sbin/rtcwake --seconds $ALARM_SEC --auto --mode no
  17. ExecStop=/bin/sh -c '\
  18. ALARM=$(cat $WAKEALARM); \
  19. NOW=$(date +%%s); \
  20. if [ -z "$ALARM" ] || [ "$NOW" -ge "$ALARM" ]; then \
  21. echo "suspend-sedation: Woke up - no alarm set. Hibernating..."; \
  22. sleep 10; \
  23. systemctl hibernate; \
  24. else \
  25. echo "suspend-sedation: Woke up before alarm - normal wakeup."; \
  26. /usr/sbin/rtcwake --auto --mode disable; \
  27. fi \
  28. '
  29. [Install]
  30. WantedBy=sleep.target

и включаем его

 
 
  1. systemctl enable  suspend-sedation

Теперь, через 10800 секунд (3 часа) после перехода в сон, комп сам проснется и переведет себя в режим гибернации.

Решение подсмотрено в интернете и отличается дополнительной строкой с командой  «sleep 10;». Добавлена она потому, что установленный у меня syncthing крашился при моментальном переходе от сна к гибернации (лично я это связываю с работой сети, которая кратковременно нарушается и восстанавливается при выходе из спячки, но это не точно).

Малефисента vs Холодное Сердце: раунд 2

Я не знаю что за прикол такой у Диснея, но они выпустили оба (мульт-)фильма в один год и снова пришлось смотреть их друг за другом. К счастью они не потырили друг у друга важные куски сценария, как было в прошлый раз, но сравнить эти два произведения все-равно оказалось довольно любопытно.

Скажу сразу — лично мне, как и в первый раз, больше понравилась отмороженная. Малефисента 2 неплоха, на самом деле, значительно увлекательнее первой части — больше экшена, больше персонажей, интриг, расследований. Но при этом складывается ощущение, что дисней не определились хотят они из этого получить сказку, или серьезное фентези. Для сказки тут слишком много боев и пафосных мотиваций «я делаю это ради королевства!», а для фентези — не хватает развития персонажей и правдоподобного финала. Устроить побоище у стен дворца и окончить его свадьбой — серьезно??

С другой стороны, Холодное Сердце 2 может показаться историей лишенной сюжета вообще. Но мне кажется дисней вообще решили отдать эту франшизу экспериментаторам: в первой части у них была необычная свежая для диснея мораль, и необычный антагонист; а во второй антагониста нет вовсе, а история вроде как хочет сосредоточиться на развитии персонажей, используя драйвером не борьбу с каким-то очевидным злом. И вот тут имхо есть небольшая проблема, потому что именно развитие персонажей — а за него я готов простить и вовсе слабую историю как таковую — здесь вовсе не такое выдающееся как хотелось бы.

Оно не такое буквально никакое, как в Малефисенте (где доча побубнила на маму но потом решила что погорячилась), но постоянно топчется вокруг истеричной Анны, которая хочет помогать Эльзе прям всегда-всегда, и Эльзы, которая постоянно думает что угробит Анну. И, в общем-то, в конце они вроде и приходят к какому-то балансу… наверное… я так думаю… потому что это выходит ТАК незаметно, и вообще не то чтобы подчеркивается сюжетом, хотя сама эта проблема озвучивается несколько раз за мульт. И получается что самое заметное и оформленное развитие персонажей заключается в том, что Кристофф сделал Анне предложение — ну молодец, блин, очень за него рады.

Но при этом история в целом — с персонажами, самой историей, приключениями и т.д. — в отмороженной выглядит более ровной и уверенно рассказанной. Это однозначная сказка, в которой все еще интересно следить за персонажами, в которой есть определенная мораль, которая, как мне показалось, сводится к тому что жизнь — сложная штука, и когда ты взрослеешь — будь готов узнавать не самые приятные вещи, которых ты не ожидал узнать. Это однозначно хороший, красиво нарисованный, мультфильм для детей, который и взрослым будет приятно заценить. В конце концов могу отметить пару достойных песен в нем (могло бы быть и побольше, да)

 

На фоне отмороженной, Малефисента, к сожалению, кажется более сумбурной и неразобравшейся в себе. Конфликт между самой Малефисентой и ее приемной дочей кажется незначительным и больше случайным, сказочные существа — слишком уязвимыми и наивными чтобы жить, сюжет в целом полон необъяснимых косяков, а злодейка могла бы быть интересной, если бы ее историю раскрыли в большей степени, а не приоткрыли и тут же захлопнули и забыли что что-то там такое намечалось. На выходе получился в целом любопытный сеттинг — с королевствами людей, с болотными жителями, с темными эльфами в «подземном царстве», но весь сюжет можно описать одним предложением «собирались свадьбу сыграть, но посрались и получилась война, но потом злодея раскрыли и все хорошо». И это даже не то чтобы сильно выжимка, за пределами этого описания практически нет какого-то существенного сюжета, только представление персонажей. Возможно диснею стоило бы сделать сериал в этой вселенной, а фильм вышел ну такой себе. Под конец хочется еще отметить довольно посредственно нарисованную Малефисенту — всю дорогу не мог отделаться от мысли, что ее крылья искусственные и держатся на веревочке.

Итого, Холодное Сердце 2 получает от меня все еще 8 из 10 (хотя эти 8 чуть менее 8 чем 8 за первую часть), Малефисента — 7 из 10 (первую я не оценивал, но она скорее к 6ти).

Как автоматически скачивать сериалы и субтитры к ним

Довольно долгое время искал какие-то решения, позволяющие «подписаться» на торренты с интересными сериалами, но так как для меня важно наличие русский сабов (а их буржуи не раздают, как это ни странно), то часто реально оказывалось проще дождаться раздачи на наших сайтах, где все уже подбито. Но наконец, закинув в очередной раз невод в море, я выудил нечто похожее на золотую рыбку. Состоит рыбка из трех компонентов…

Торрент-клиент

В основе сервера будет transmission-daemon — идеальный для этого случая клиент. Примерный конфиг для него будет такой:

settings.json
 
  1. {
  2. "alt-speed-down": 50,
  3. "alt-speed-enabled": false,
  4. "alt-speed-time-begin": 540,
  5. "alt-speed-time-day": 127,
  6. "alt-speed-time-enabled": false,
  7. "alt-speed-time-end": 1020,
  8. "alt-speed-up": 50,
  9. "bind-address-ipv4": "0.0.0.0",
  10. "bind-address-ipv6": "::",
  11. "blocklist-enabled": true,
  12. "blocklist-url": "http://list.iblocklist.com/?list=bt_level1&fileformat=p2p&archiveformat=gz",
  13. "cache-size-mb": 4,
  14. "dht-enabled": true,
  15. "download-dir": "/share/torrents",
  16. "download-queue-enabled": true,
  17. "download-queue-size": 5,
  18. "encryption": 2,
  19. "idle-seeding-limit": 30,
  20. "idle-seeding-limit-enabled": false,
  21. "incomplete-dir": "/share/torrents/inc",
  22. "incomplete-dir-enabled": true,
  23. "lpd-enabled": true,
  24. "message-level": 1,
  25. "peer-congestion-algorithm": "",
  26. "peer-id-ttl-hours": 6,
  27. "peer-limit-global": 500,
  28. "peer-limit-per-torrent": 20,
  29. "peer-port": 59648,
  30. "peer-port-random-high": 65535,
  31. "peer-port-random-low": 49152,
  32. "peer-port-random-on-start": false,
  33. "peer-socket-tos": "default",
  34. "pex-enabled": true,
  35. "port-forwarding-enabled": true,
  36. "preallocation": 0,
  37. "prefetch-enabled": true,
  38. "queue-stalled-enabled": true,
  39. "queue-stalled-minutes": 30,
  40. "ratio-limit": 2,
  41. "ratio-limit-enabled": false,
  42. "rename-partial-files": true,
  43. "rpc-authentication-required": false,
  44. "rpc-bind-address": "0.0.0.0",
  45. "rpc-enabled": true,
  46. "rpc-host-whitelist": "",
  47. "rpc-host-whitelist-enabled": false,
  48. "rpc-password": "",
  49. "rpc-port": 9091,
  50. "rpc-url": "/transmission/",
  51. "rpc-username": "admin",
  52. "rpc-whitelist": "127.0.0.1",
  53. "rpc-whitelist-enabled": false,
  54. "scrape-paused-torrents-enabled": true,
  55. "script-torrent-done-enabled": false,
  56. "script-torrent-done-filename": "",
  57. "seed-queue-enabled": false,
  58. "seed-queue-size": 10,
  59. "speed-limit-down": 100,
  60. "speed-limit-down-enabled": false,
  61. "speed-limit-up": 100,
  62. "speed-limit-up-enabled": false,
  63. "start-added-torrents": true,
  64. "trash-original-torrent-files": false,
  65. "umask": 0,
  66. "upload-slots-per-torrent": 14,
  67. "utp-enabled": true,
  68. "watch-dir": "/share/torrents/watch",
  69. "watch-dir-enabled": true
  70. }

Этот конфиг подразумевает что у нас есть каталог ​/share/torrents, в который будут падать торренты, а также два подкаталога — inc и watch. Первый  для размещения файлов в процессе скачивания, второй для скачивания торрентов, вручную кинутых в этот каталог.

Вебморда: http://IP:9091/transmission/web/, логин admin без пароля

Граббилка торрентов

Шикарный проект torrentwatch-xa,  который мониторит RSS-фиды различных трекеров (есть набор дефолтных и возможность добавить свои), выцепляет названия, интересующие нас. и добавляет их на скачивание. Как правило сериалы выкладываются по сериям, так что свежие всегда будут появляться у нас как только так сразу.

Установка описана на гитхабе, так что сразу к настройкам. Прописываем настройки подключения к торрент-клиенту — он может быть как локальным, так и удаленным. Указываем корневую папку в которую будут скачиваться сериалы.

Указываем чтобы сериалы качались каждый в свою папку по названию сериала, и выставляем лимит раздачи (в данном случае 20 к 1, то есть гиг скачали — 20 раздали и остановились)

Вкладка Favorites отвечает за настройки мониторинга тех сериалов, которые мы захотим скачать. Использовать регулярные выражения для вычленения имени сериала, искать во всех фидах, качать только торренты с номерами сезона и эпизода в названии, скачивать только новые эпизоды (об этом ниже)

Теперь о том как выглядит наше избранное и как добавить сериал. Ну, примерно так

Разберем на примере доктора кто, имя торрента с его серией будет примерно таким: doctor.who.2005.s12e07.720p.hdtv.x264-mtb[eztv]

Имя — это просто имя, может быть произвольным; фильтр — как правило совпадает с именем указанным в имени торрента, но игнорирует точки; quality — качество, которое кодируется или как разрешение (720p), или как тип рипа (webrip/hdtv и т.д.), можно указывать или так или эдак; Last Downloaded — последний добавленный в скачивание эпизод (это поле обновляется автоматически, но его можно поменять и вручную, если часть эпизодов у нас уже есть и мы хотим качать только новое), при добавлении нового сериала это поле заполняется в формате SSxEE (SS — номер сезона, EE — номер эпизода, напр. 02×08)

Скачиватель субтитров

Как правило, для большинства сериалов субтитры рано или поздно находятся на opensubtitles.org, и было бы логично искать их там. Но хотелось бы делать это автоматически. И есть такой скрипт: https://github.com/emericg/OpenSubtitlesDownload

/opt/OpenSubtitlesDownload.py
 
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # OpenSubtitlesDownload.py / Version 4.1
  4. # This software is designed to help you find and download subtitles for your favorite videos!
  5. # You can browse the project's GitHub page:
  6. # https://github.com/emericg/OpenSubtitlesDownload
  7. # Learn much more about OpenSubtitlesDownload.py on its wiki:
  8. # https://github.com/emericg/OpenSubtitlesDownload/wiki
  9. # You can also browse the official website:
  10. # https://emeric.io/OpenSubtitlesDownload
  11. # Copyright (c) 2020 by Emeric GRANGE <emeric.grange@gmail.com>
  12. #
  13. # This program is free software: you can redistribute it and/or modify
  14. # it under the terms of the GNU General Public License as published by
  15. # the Free Software Foundation, either version 3 of the License, or
  16. # (at your option) any later version.
  17. #
  18. # This program is distributed in the hope that it will be useful,
  19. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  21. # GNU General Public License for more details.
  22. #
  23. # You should have received a copy of the GNU General Public License
  24. # along with this program.  If not, see <https://www.gnu.org/licenses/>.
  25. # Contributors / special thanks:
  26. # Thiago Alvarenga Lechuga <thiagoalz@gmail.com> for his work on the 'Windows CLI' and the 'folder search'
  27. # jeroenvdw for his work on the 'subtitles automatic selection' and the 'search by filename'
  28. # Gui13 for his work on the arguments parsing
  29. # Tomáš Hnyk <tomashnyk@gmail.com> for his work on the 'multiple language' feature
  30. # Carlos Acedo <carlos@linux-labs.net> for his work on the original script
  31. import os
  32. import re
  33. import sys
  34. import time
  35. import gzip
  36. import struct
  37. import argparse
  38. import mimetypes
  39. import subprocess
  40. if sys.version_info >= (3, 0):
  41.     import shutil
  42.     import urllib.request
  43.     from xmlrpc.client import ServerProxy, Error
  44. else: # python2
  45.     import urllib
  46.     from xmlrpclib import ServerProxy, Error
  47. # ==== Opensubtitles.org server settings =======================================
  48. # XML-RPC server domain for opensubtitles.org:
  49. osd_server = ServerProxy('https://api.opensubtitles.org/xml-rpc')
  50. # You can use your opensubtitles.org account to avoid "in-subtitles" advertisment
  51. # and bypass download limits. Be careful about your password security, it will be
  52. # stored right here in plain text... You can also change opensubtitles.org language,
  53. # it will be used for error codes and stuff.
  54. osd_username = ''
  55. osd_password = ''
  56. osd_language = 'en'
  57. # ==== Language settings =======================================================
  58. # 1/ Change the search language by using any supported 3-letter (ISO 639-2) language codes:
  59. #    > Supported ISO codes: https://www.opensubtitles.org/addons/export_languages.php
  60. # 2/ Search for subtitles in several languages at once by using multiple codes separated by a comma:
  61. #    > Exemple: opt_languages = ['eng,fre']
  62. opt_languages = ['eng']
  63. # Write 2-letter language code (ex: _en) at the end of the subtitles file. 'on', 'off' or 'auto'.
  64. # If you are regularly searching for several language at once, you sould use 'on'.
  65. opt_language_suffix = 'auto'
  66. opt_language_separator = '_'
  67. # ==== Search settings =========================================================
  68. # Subtitles search mode. Can be overridden at run time with '-s' argument.
  69. # - hash (search by hash)
  70. # - filename (search by filename)
  71. # - hash_then_filename (search by hash, then filename if no results)
  72. # - hash_and_filename (search using both methods)
  73. opt_search_mode = 'hash_then_filename'
  74. # Search and download a subtitles even if a subtitles file already exists.
  75. opt_search_overwrite = 'on'
  76. # Subtitles selection mode. Can be overridden at run time with '-t' argument.
  77. # - manual (always let you choose the subtitles you want)
  78. # - default (in case of multiple results, let you choose the subtitles you want)
  79. # - auto (automatically select the best subtitles found)
  80. opt_selection_mode = 'default'
  81. # Customize subtitles download path. Can be overridden at run time with '-o' argument.
  82. # By default, subtitles are downloaded next to their video file.
  83. opt_output_path = ''
  84. # ==== GUI settings ============================================================
  85. # Select your GUI. Can be overridden at run time with '--gui=xxx' argument.
  86. # - auto (autodetection, fallback on CLI)
  87. # - gnome (GNOME/GTK based environments, using 'zenity' backend)
  88. # - kde (KDE/Qt based environments, using 'kdialog' backend)
  89. # - cli (Command Line Interface)
  90. opt_gui = 'auto'
  91. # Change the subtitles selection GUI size:
  92. opt_gui_width  = 720
  93. opt_gui_height = 320
  94. # Various GUI options. You can set them to 'on', 'off' or 'auto'.
  95. opt_selection_hi       = 'auto'
  96. opt_selection_language = 'auto'
  97. opt_selection_match    = 'auto'
  98. opt_selection_rating   = 'off'
  99. opt_selection_count    = 'off'
  100. # ==== Exit codes ==============================================================
  101. # Exit code returned by the software. You can use them to improve scripting behaviours.
  102. # 0: Success, and subtitles downloaded
  103. # 1: Success, but no subtitles found
  104. # 2: Failure
  105. # ==== Super Print =============================================================
  106. # priority: info, warning, error
  107. # title: only for zenity and kdialog messages
  108. # message: full text, with tags and breaks (tags will be cleaned up for CLI)
  109. def superPrint(priority, title, message):
  110.     """Print messages through terminal, zenity or kdialog"""
  111.     if opt_gui == 'gnome':
  112.         subprocess.call(['zenity', '--width=' + str(opt_gui_width), '--' + priority, '--title=' + title, '--text=' + message])
  113.     elif opt_gui == 'kde':
  114.         # Adapt to kdialog
  115.         message = message.replace("\n", "<br>")
  116.         message = message.replace('\\"', '"')
  117.         if priority == 'warning':
  118.             priority = 'sorry'
  119.         elif priority == 'info':
  120.             priority = 'msgbox'
  121.         subprocess.call(['kdialog', '--geometry=' + str(opt_gui_width) + 'x' + str(opt_gui_height), '--title=' + title, '--' + priority + '=' + message])
  122.     else:
  123.         # Clean up formating tags from the zenity messages
  124.         message = message.replace("\n\n", "\n")
  125.         message = message.replace("<i>", "")
  126.         message = message.replace("</i>", "")
  127.         message = message.replace("<b>", "")
  128.         message = message.replace("</b>", "")
  129.         message = message.replace('\\"', '"')
  130.         print(">> " + message)
  131. # ==== Check file path & type ==================================================
  132. def checkFileValidity(path):
  133.     """Check mimetype and/or file extension to detect valid video file"""
  134.     if os.path.isfile(path) is False:
  135.         return False
  136.     fileMimeType, encoding = mimetypes.guess_type(path)
  137.     if fileMimeType is None:
  138.         fileExtension = path.rsplit('.', 1)
  139.         if fileExtension[1] not in ['avi', 'mp4', 'mov', 'mkv', 'mk3d', 'webm', \
  140.                                     'ts', 'mts', 'm2ts', 'ps', 'vob', 'evo', 'mpeg', 'mpg', \
  141.                                     'm1v', 'm2p', 'm2v', 'm4v', 'movhd', 'movx', 'qt', \
  142.                                     'mxf', 'ogg', 'ogm', 'ogv', 'rm', 'rmvb', 'flv', 'swf', \
  143.                                     'asf', 'wm', 'wmv', 'wmx', 'divx', 'x264', 'xvid']:
  144.             #superPrint("error", "File type error!", "This file is not a video (unknown mimetype AND invalid file extension):\n<i>" + path + "</i>")
  145.             return False
  146.     else:
  147.         fileMimeType = fileMimeType.split('/', 1)
  148.         if fileMimeType[0] != 'video':
  149.             #superPrint("error", "File type error!", "This file is not a video (unknown mimetype):\n<i>" + path + "</i>")
  150.             return False
  151.     return True
  152. # ==== Check for existing subtitles file =======================================
  153. def checkSubtitlesExists(path):
  154.     """Check if a subtitles already exists for the current file"""
  155.     for ext in ['srt', 'sub', 'sbv', 'smi', 'ssa', 'ass', 'usf']:
  156.         subPath = path.rsplit('.', 1)[0] + '.' + ext
  157.         if os.path.isfile(subPath) is True:
  158.             superPrint("info", "Subtitles already downloaded!", "A subtitles file already exists for this file:\n<i>" + subPath + "</i>")
  159.             return True
  160.         # With language code? Only check the first language (and probably using the wrong language suffix format)
  161.         if opt_language_suffix in ('on', 'auto'):
  162.             if len(opt_languages) == 1:
  163.                 splitted_languages_list = opt_languages[0].split(',')
  164.             else:
  165.                 splitted_languages_list = opt_languages
  166.             subPath = path.rsplit('.', 1)[0] + opt_language_separator + splitted_languages_list[0] + '.' + ext
  167.             if os.path.isfile(subPath) is True:
  168.                 superPrint("info", "Subtitles already downloaded!", "A subtitles file already exists for this file:\n<i>" + subPath + "</i>")
  169.                 return True
  170.     return False
  171. # ==== Hashing algorithm =======================================================
  172. # Info: https://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes
  173. # This particular implementation is coming from SubDownloader: https://subdownloader.net
  174. def hashFile(path):
  175.     """Produce a hash for a video file: size + 64bit chksum of the first and
  176.     last 64k (even if they overlap because the file is smaller than 128k)"""
  177.     try:
  178.         longlongformat = 'Q' # unsigned long long little endian
  179.         bytesize = struct.calcsize(longlongformat)
  180.         fmt = "<%d%s" % (65536//bytesize, longlongformat)
  181.         f = open(path, "rb")
  182.         filesize = os.fstat(f.fileno()).st_size
  183.         filehash = filesize
  184.         if filesize < 65536 * 2:
  185.             superPrint("error", "File size error!", "File size error while generating hash for this file:\n<i>" + path + "</i>")
  186.             return "SizeError"
  187.         buf = f.read(65536)
  188.         longlongs = struct.unpack(fmt, buf)
  189.         filehash += sum(longlongs)
  190.         f.seek(-65536, os.SEEK_END) # size is always > 131072
  191.         buf = f.read(65536)
  192.         longlongs = struct.unpack(fmt, buf)
  193.         filehash += sum(longlongs)
  194.         filehash &= 0xFFFFFFFFFFFFFFFF
  195.         f.close()
  196.         returnedhash = "%016x" % filehash
  197.         return returnedhash
  198.     except IOError:
  199.         superPrint("error", "I/O error!", "Input/Output error while generating hash for this file:\n<i>" + path + "</i>")
  200.         return "IOError"
  201. # ==== GNOME selection window ==================================================
  202. def selectionGnome(subtitlesList):
  203.     """GNOME subtitles selection window using zenity"""
  204.     subtitlesSelected = ''
  205.     subtitlesItems = ''
  206.     subtitlesMatchedByHash = 0
  207.     subtitlesMatchedByName = 0
  208.     columnHi = ''
  209.     columnLn = ''
  210.     columnMatch = ''
  211.     columnRate = ''
  212.     columnCount = ''
  213.     # Generate selection window content
  214.     for item in subtitlesList['data']:
  215.         if item['MatchedBy'] == 'moviehash':
  216.             subtitlesMatchedByHash += 1
  217.         else:
  218.             subtitlesMatchedByName += 1
  219.         subtitlesItems += '"' + item['SubFileName'] + '" '
  220.         if opt_selection_hi == 'on':
  221.             columnHi = '--column="HI" '
  222.             if item['SubHearingImpaired'] == '1':
  223.                 subtitlesItems += '"✔" '
  224.             else:
  225.                 subtitlesItems += '"" '
  226.         if opt_selection_language == 'on':
  227.             columnLn = '--column="Language" '
  228.             subtitlesItems += '"' + item['LanguageName'] + '" '
  229.         if opt_selection_match == 'on':
  230.             columnMatch = '--column="MatchedBy" '
  231.             if item['MatchedBy'] == 'moviehash':
  232.                 subtitlesItems += '"HASH" '
  233.             else:
  234.                 subtitlesItems += '"" '
  235.         if opt_selection_rating == 'on':
  236.             columnRate = '--column="Rating" '
  237.             subtitlesItems += '"' + item['SubRating'] + '" '
  238.         if opt_selection_count == 'on':
  239.             columnCount = '--column="Downloads" '
  240.             subtitlesItems += '"' + item['SubDownloadsCnt'] + '" '
  241.     if subtitlesMatchedByName == 0:
  242.         tilestr = ' --title="Subtitles for: ' + videoTitle + '"'
  243.         textstr = ' --text="<b>Video title:</b> ' + videoTitle + '\n<b>File name:</b> ' + videoFileName + '"'
  244.     elif subtitlesMatchedByHash == 0:
  245.         tilestr = ' --title="Subtitles for: ' + videoFileName + '"'
  246.         textstr = ' --text="Search results using file name, NOT video detection. <b>May be unreliable...</b>\n<b>File name:</b> ' + videoFileName + '" '
  247.     else: # a mix of the two
  248.         tilestr = ' --title="Subtitles for: ' + videoTitle + '"'
  249.         textstr = ' --text="Search results using file name AND video detection.\n<b>Video title:</b> ' + videoTitle + '\n<b>File name:</b> ' + videoFileName + '"'
  250.     # Spawn zenity "list" dialog
  251.     process_subtitlesSelection = subprocess.Popen('zenity --width=' + str(opt_gui_width) + ' --height=' + str(opt_gui_height) + ' --list' + tilestr + textstr \
  252.         + ' --column="Available subtitles" ' + columnHi + columnLn + columnMatch + columnRate + columnCount + subtitlesItems, shell=True, stdout=subprocess.PIPE)
  253.     # Get back the result
  254.     result_subtitlesSelection = process_subtitlesSelection.communicate()
  255.     # The results contain a subtitles?
  256.     if result_subtitlesSelection[0]:
  257.         if sys.version_info >= (3, 0):
  258.             subtitlesSelected = str(result_subtitlesSelection[0], 'utf-8').strip("\n")
  259.         else: # python2
  260.             subtitlesSelected = str(result_subtitlesSelection[0]).strip("\n")
  261.         # Hack against recent zenity version?
  262.         if len(subtitlesSelected.split("|")) > 1:
  263.             if subtitlesSelected.split("|")[0] == subtitlesSelected.split("|")[1]:
  264.                 subtitlesSelected = subtitlesSelected.split("|")[0]
  265.     else:
  266.         if process_subtitlesSelection.returncode == 0:
  267.             subtitlesSelected = subtitlesList['data'][0]['SubFileName']
  268.     # Return the result
  269.     return subtitlesSelected
  270. # ==== KDE selection window ====================================================
  271. def selectionKde(subtitlesList):
  272.     """KDE subtitles selection window using kdialog"""
  273.     subtitlesSelected = ''
  274.     subtitlesItems = ''
  275.     subtitlesMatchedByHash = 0
  276.     subtitlesMatchedByName = 0
  277.     # Generate selection window content
  278.     # TODO doesn't support additional columns
  279.     index = 0
  280.     for item in subtitlesList['data']:
  281.         if item['MatchedBy'] == 'moviehash':
  282.             subtitlesMatchedByHash += 1
  283.         else:
  284.             subtitlesMatchedByName += 1
  285.         # key + subtitles name
  286.         subtitlesItems += str(index) + ' "' + item['SubFileName'] + '" '
  287.         index += 1
  288.     if subtitlesMatchedByName == 0:
  289.         tilestr = ' --title="Subtitles for ' + videoTitle + '"'
  290.         menustr = ' --menu="<b>Video title:</b> ' + videoTitle + '<br><b>File name:</b> ' + videoFileName + '" '
  291.     elif subtitlesMatchedByHash == 0:
  292.         tilestr = ' --title="Subtitles for ' + videoFileName + '"'
  293.         menustr = ' --menu="Search results using file name, NOT video detection. <b>May be unreliable...</b><br><b>File name:</b> ' + videoFileName + '" '
  294.     else: # a mix of the two
  295.         tilestr = ' --title="Subtitles for ' + videoTitle + '" '
  296.         menustr = ' --menu="Search results using file name AND video detection.<br><b>Video title:</b> ' + videoTitle + '<br><b>File name:</b> ' + videoFileName + '" '
  297.     # Spawn kdialog "radiolist"
  298.     process_subtitlesSelection = subprocess.Popen('kdialog --geometry=' + str(opt_gui_width) + 'x' + str(opt_gui_height) + tilestr + menustr + subtitlesItems, shell=True, stdout=subprocess.PIPE)
  299.     # Get back the result
  300.     result_subtitlesSelection = process_subtitlesSelection.communicate()
  301.     # The results contain the key matching a subtitles?
  302.     if result_subtitlesSelection[0]:
  303.         if sys.version_info >= (3, 0):
  304.             keySelected = int(str(result_subtitlesSelection[0], 'utf-8').strip("\n"))
  305.         else: # python2
  306.             keySelected = int(str(result_subtitlesSelection[0]).strip("\n"))
  307.         subtitlesSelected = subtitlesList['data'][keySelected]['SubFileName']
  308.     # Return the result
  309.     return subtitlesSelected
  310. # ==== CLI selection mode ======================================================
  311. def selectionCLI(subtitlesList):
  312.     """Command Line Interface, subtitles selection inside your current terminal"""
  313.     subtitlesIndex = 0
  314.     subtitlesItem = ''
  315.     # Print video infos
  316.     print("\n>> Title: " + videoTitle)
  317.     print(">> Filename: " + videoFileName)
  318.     # Print subtitles list on the terminal
  319.     print(">> Available subtitles:")
  320.     for item in subtitlesList['data']:
  321.         subtitlesIndex += 1
  322.         subtitlesItem = '"' + item['SubFileName'] + '" '
  323.         if opt_selection_hi == 'on' and item['SubHearingImpaired'] == '1':
  324.             subtitlesItem += '> "HI" '
  325.         if opt_selection_language == 'on':
  326.             subtitlesItem += '> "Language: ' + item['LanguageName'] + '" '
  327.         if opt_selection_match == 'on':
  328.             subtitlesItem += '> "MatchedBy: ' + item['MatchedBy'] + '" '
  329.         if opt_selection_rating == 'on':
  330.             subtitlesItem += '> "SubRating: ' + item['SubRating'] + '" '
  331.         if opt_selection_count == 'on':
  332.             subtitlesItem += '> "SubDownloadsCnt: ' + item['SubDownloadsCnt'] + '" '
  333.         if item['MatchedBy'] == 'moviehash':
  334.             print("\033[92m[" + str(subtitlesIndex) + "]\033[0m " + subtitlesItem)
  335.         else:
  336.             print("\033[93m[" + str(subtitlesIndex) + "]\033[0m " + subtitlesItem)
  337.     # Ask user selection
  338.     print("\033[91m[0]\033[0m Cancel search")
  339.     sub_selection = -1
  340.     while(sub_selection < 0 or sub_selection > subtitlesIndex):
  341.         try:
  342.             if sys.version_info >= (3, 0):
  343.                 sub_selection = int(input(">> Enter your choice (0-" + str(subtitlesIndex) + "): "))
  344.             else: # python 2
  345.                 sub_selection = int(raw_input(">> Enter your choice (0-" + str(subtitlesIndex) + "): "))
  346.         except:
  347.             sub_selection = -1
  348.     # Return the result
  349.     if sub_selection == 0:
  350.         print("Cancelling search...")
  351.         return ""
  352.     return subtitlesList['data'][sub_selection-1]['SubFileName']
  353. # ==== Automatic selection mode ================================================
  354. def selectionAuto(subtitlesList):
  355.     """Automatic subtitles selection using filename match"""
  356.     if len(opt_languages) == 1:
  357.         splitted_languages_list = list(reversed(opt_languages[0].split(',')))
  358.     else:
  359.         splitted_languages_list = opt_languages
  360.     videoFileParts = videoFileName.replace('-', '.').replace(' ', '.').replace('_', '.').lower().split('.')
  361.     maxScore = -1
  362.     for subtitle in subtitlesList['data']:
  363.         score = 0
  364.         # points to respect languages priority
  365.         score += splitted_languages_list.index(subtitle['SubLanguageID']) * 100
  366.         # extra point if the sub is found by hash
  367.         if subtitle['MatchedBy'] == 'moviehash':
  368.             score += 1
  369.         # points for filename mach
  370.         subFileParts = subtitle['SubFileName'].replace('-', '.').replace(' ', '.').replace('_', '.').lower().split('.')
  371.         for subPart in subFileParts:
  372.             for filePart in videoFileParts:
  373.                 if subPart == filePart:
  374.                     score += 1
  375.         if score > maxScore:
  376.             maxScore = score
  377.             subtitlesSelected = subtitle['SubFileName']
  378.     return subtitlesSelected
  379. # ==== Check dependencies ======================================================
  380. def dependencyChecker():
  381.     """Check the availability of tools used as dependencies"""
  382.     if opt_gui != 'cli':
  383.         if sys.version_info >= (3, 3):
  384.             for tool in ['gunzip', 'wget']:
  385.                 path = shutil.which(tool)
  386.                 if path is None:
  387.                     superPrint("error", "Missing dependency!", "The <b>'" + tool + "'</b> tool is not available, please install it!")
  388.                     return False
  389.     return True
  390. # ==============================================================================
  391. # ==== Main program (execution starts here) ====================================
  392. # ==============================================================================
  393. ExitCode = 2
  394. # ==== Argument parsing
  395. # Get OpenSubtitlesDownload.py script absolute path
  396. if os.path.isabs(sys.argv[0]):
  397.     scriptPath = sys.argv[0]
  398. else:
  399.     scriptPath = os.getcwd() + "/" + str(sys.argv[0])
  400. # Setup ArgumentParser
  401. parser = argparse.ArgumentParser(prog='OpenSubtitlesDownload.py',
  402.                                  description='Automatically find and download the right subtitles for your favorite videos!',
  403.                                  formatter_class=argparse.RawTextHelpFormatter)
  404. parser.add_argument('--cli', help="Force CLI mode", action='store_true')
  405. parser.add_argument('-g', '--gui', help="Select the GUI you want from: auto, kde, gnome, cli (default: auto)")
  406. parser.add_argument('-l', '--lang', help="Specify the language in which the subtitles should be downloaded (default: eng).\nSyntax:\n-l eng,fre: search in both language\n-l eng -l fre: download both language", nargs='?', action='append')
  407. parser.add_argument('-i', '--skip', help="Skip search if an existing subtitles file is detected", action='store_true')
  408. parser.add_argument('-s', '--search', help="Search mode: hash, filename, hash_then_filename, hash_and_filename (default: hash_then_filename)")
  409. parser.add_argument('-t', '--select', help="Selection mode: manual, default, auto")
  410. parser.add_argument('-a', '--auto', help="Trigger automatic selection and download of the best subtitles found", action='store_true')
  411. parser.add_argument('-o', '--output', help="Override subtitles download path, instead of next their video file")
  412. parser.add_argument('filePathListArg', help="The video file(s) for which subtitles should be searched and downloaded", nargs='+')
  413. # Only use ArgumentParser if we have arguments...
  414. if len(sys.argv) > 1:
  415.     result = parser.parse_args()
  416.     # Handle results
  417.     if result.cli:
  418.         opt_gui = 'cli'
  419.     if result.gui:
  420.         opt_gui = result.gui
  421.     if result.search:
  422.         opt_search_mode = result.search
  423.     if result.skip:
  424.         opt_search_overwrite = 'off'
  425.     if result.select:
  426.         opt_selection_mode = result.select
  427.     if result.auto:
  428.         opt_selection_mode = 'auto'
  429.     if result.output:
  430.         opt_output_path = result.output
  431.     if result.lang:
  432.         if opt_languages != result.lang:
  433.             opt_languages = result.lang
  434.             opt_selection_language = 'on'
  435.             if opt_language_suffix != 'off':
  436.                 opt_language_suffix = 'on'
  437. # GUI auto detection
  438. if opt_gui == 'auto':
  439.     # Note: "ps cax" only output the first 15 characters of the executable's names
  440.     ps = str(subprocess.Popen(['ps', 'cax'], stdout=subprocess.PIPE).communicate()[0]).split('\n')
  441.     for line in ps:
  442.         if ('gnome-session' in line) or ('cinnamon-sessio' in line) or ('mate-session' in line) or ('xfce4-session' in line):
  443.             opt_gui = 'gnome'
  444.             break
  445.         elif 'ksmserver' in line:
  446.             opt_gui = 'kde'
  447.             break
  448. # Sanitize settings
  449. if opt_search_mode not in ['hash', 'filename', 'hash_then_filename', 'hash_and_filename']:
  450.     opt_search_mode = 'hash_then_filename'
  451. if opt_selection_mode not in ['manual', 'default', 'auto']:
  452.     opt_selection_mode = 'default'
  453. if opt_gui not in ['gnome', 'kde', 'cli']:
  454.     opt_gui = 'cli'
  455.     opt_search_mode = 'hash_then_filename'
  456.     opt_selection_mode = 'auto'
  457.     print("Unknown GUI, falling back to an automatic CLI mode")
  458. # ==== Check for the necessary tools (must be done after GUI auto detection)
  459. if dependencyChecker() is False:
  460.     sys.exit(2)
  461. # ==== Get valid video paths
  462. videoPathList = []
  463. if 'result' in locals():
  464.     # Go through the paths taken from arguments, and extract only valid video paths
  465.     for i in result.filePathListArg:
  466.         filePath = os.path.abspath(i)
  467.         if os.path.isdir(filePath):
  468.             # If it is a folder, check all of its files
  469.             for item in os.listdir(filePath):
  470.                 localPath = os.path.join(filePath, item)
  471.                 if checkFileValidity(localPath):
  472.                     videoPathList.append(localPath)
  473.         elif checkFileValidity(filePath):
  474.             # If it is a valid file, use it
  475.             videoPathList.append(filePath)
  476. else:
  477.     superPrint("error", "No file provided!", "No file provided!")
  478.     sys.exit(2)
  479. # If videoPathList is empty, abort!
  480. if not videoPathList:
  481.     parser.print_help()
  482.     sys.exit(1)
  483. # Check if the subtitles files already exists
  484. if opt_search_overwrite == 'off':
  485.     videoPathList = [path for path in videoPathList if not checkSubtitlesExists(path)]
  486.     # If videoPathList is empty, exit!
  487.     if not videoPathList:
  488.         sys.exit(1)
  489. # ==== Instances dispatcher ====================================================
  490. # The first video file will be processed by this instance
  491. videoPath = videoPathList[0]
  492. videoPathList.pop(0)
  493. # The remaining file(s) are dispatched to new instance(s) of this script
  494. for videoPathDispatch in videoPathList:
  495.     # Handle current options
  496.     command = sys.executable + " " + scriptPath + " -g " + opt_gui + " -s " + opt_search_mode + " -t " + opt_selection_mode
  497.     if not (len(opt_languages) == 1 and opt_languages[0] == 'eng'):
  498.         for resultlangs in opt_languages:
  499.             command += " -l " + resultlangs
  500.     # Split command string
  501.     command_splitted = command.split()
  502.     # The videoPath filename can contain spaces, but we do not want to split that, so add it right after the split
  503.     command_splitted.append(videoPathDispatch)
  504.     # Do not spawn too many instances at once
  505.     time.sleep(0.33)
  506.     if opt_gui == 'cli' and opt_selection_mode != 'auto':
  507.         # Synchronous call
  508.         process_videoDispatched = subprocess.call(command_splitted)
  509.     else:
  510.         # Asynchronous call
  511.         process_videoDispatched = subprocess.Popen(command_splitted)
  512. # ==== Search and download subtitles ===========================================
  513. try:
  514.     # ==== Connection to OpenSubtitlesDownload
  515.     try:
  516.         session = osd_server.LogIn(osd_username, osd_password, osd_language, 'opensubtitles-download 4.1')
  517.     except Exception:
  518.         # Retry once after a delay (could just be a momentary overloaded server?)
  519.         time.sleep(3)
  520.         try:
  521.             session = osd_server.LogIn(osd_username, osd_password, osd_language, 'opensubtitles-download 4.1')
  522.         except Exception:
  523.             superPrint("error", "Connection error!", "Unable to reach opensubtitles.org servers!\n\nPlease check:\n- Your Internet connection status\n- www.opensubtitles.org availability\n- Your downloads limit (200 subtitles per 24h)\n\nThe subtitles search and download service is powered by opensubtitles.org. Be sure to donate if you appreciate the service provided!")
  524.             sys.exit(2)
  525.     # Connection refused?
  526.     if session['status'] != '200 OK':
  527.         superPrint("error", "Connection error!", "Opensubtitles.org servers refused the connection: " + session['status'] + ".\n\nPlease check:\n- Your Internet connection status\n- www.opensubtitles.org availability\n- Your downloads limit (200 subtitles per 24h)\n\nThe subtitles search and download service is powered by opensubtitles.org. Be sure to donate if you appreciate the service provided!")
  528.         sys.exit(2)
  529.     # Count languages marked for this search
  530.     searchLanguage = 0
  531.     searchLanguageResult = 0
  532.     for SubLanguageID in opt_languages:
  533.         searchLanguage += len(SubLanguageID.split(','))
  534.     searchResultPerLanguage = [searchLanguage]
  535.     # ==== Get file hash, size and name
  536.     videoTitle = ''
  537.     videoHash = hashFile(videoPath)
  538.     videoSize = os.path.getsize(videoPath)
  539.     videoFileName = os.path.basename(videoPath)
  540.     # ==== Search for available subtitles on OpenSubtitlesDownload
  541.     for SubLanguageID in opt_languages:
  542.         searchList = []
  543.         subtitlesList = {}
  544.         if opt_search_mode in ('hash', 'hash_then_filename', 'hash_and_filename'):
  545.             searchList.append({'sublanguageid':SubLanguageID, 'moviehash':videoHash, 'moviebytesize':str(videoSize)})
  546.         if opt_search_mode in ('filename', 'hash_and_filename'):
  547.             searchList.append({'sublanguageid':SubLanguageID, 'query':videoFileName})
  548.         ## Primary search
  549.         try:
  550.             subtitlesList = osd_server.SearchSubtitles(session['token'], searchList)
  551.         except Exception:
  552.             # Retry once after a delay (we are already connected, the server may be momentary overloaded)
  553.             time.sleep(3)
  554.             try:
  555.                 subtitlesList = osd_server.SearchSubtitles(session['token'], searchList)
  556.             except Exception:
  557.                 superPrint("error", "Search error!", "Unable to reach opensubtitles.org servers!\n<b>Search error</b>")
  558.         #if (opt_search_mode == 'hash_and_filename'):
  559.         #    TODO Cleanup duplicate between moviehash and filename results
  560.         ## Fallback search
  561.         if ((opt_search_mode == 'hash_then_filename') and (('data' in subtitlesList) and (not subtitlesList['data']))):
  562.             searchList[:] = [] # searchList.clear()
  563.             searchList.append({'sublanguageid':SubLanguageID, 'query':videoFileName})
  564.             subtitlesList.clear()
  565.             try:
  566.                 subtitlesList = osd_server.SearchSubtitles(session['token'], searchList)
  567.             except Exception:
  568.                 # Retry once after a delay (we are already connected, the server may be momentary overloaded)
  569.                 time.sleep(3)
  570.                 try:
  571.                     subtitlesList = osd_server.SearchSubtitles(session['token'], searchList)
  572.                 except Exception:
  573.                     superPrint("error", "Search error!", "Unable to reach opensubtitles.org servers!\n<b>Search error</b>")
  574.         ## Parse the results of the XML-RPC query
  575.         if ('data' in subtitlesList) and (subtitlesList['data']):
  576.             # Mark search as successful
  577.             searchLanguageResult += 1
  578.             subtitlesSelected = ''
  579.             # If there is only one subtitles (matched by file hash), auto-select it (except in CLI mode)
  580.             if (len(subtitlesList['data']) == 1) and (subtitlesList['data'][0]['MatchedBy'] == 'moviehash'):
  581.                 if opt_selection_mode != 'manual':
  582.                     subtitlesSelected = subtitlesList['data'][0]['SubFileName']
  583.             # Get video title
  584.             videoTitle = subtitlesList['data'][0]['MovieName']
  585.             # Title and filename may need string sanitizing to avoid zenity/kdialog handling errors
  586.             if opt_gui != 'cli':
  587.                 videoTitle = videoTitle.replace('"', '\\"')
  588.                 videoTitle = videoTitle.replace("'", "\'")
  589.                 videoTitle = videoTitle.replace('`', '\`')
  590.                 videoTitle = videoTitle.replace("&", "&")
  591.                 videoFileName = videoFileName.replace('"', '\\"')
  592.                 videoFileName = videoFileName.replace("'", "\'")
  593.                 videoFileName = videoFileName.replace('`', '\`')
  594.                 videoFileName = videoFileName.replace("&", "&")
  595.             # If there is more than one subtitles and opt_selection_mode != 'auto',
  596.             # then let the user decide which one will be downloaded
  597.             if not subtitlesSelected:
  598.                 # Automatic subtitles selection?
  599.                 if opt_selection_mode == 'auto':
  600.                     subtitlesSelected = selectionAuto(subtitlesList)
  601.                 else:
  602.                     # Go through the list of subtitles and handle 'auto' settings activation
  603.                     for item in subtitlesList['data']:
  604.                         if opt_selection_match == 'auto':
  605.                             if opt_search_mode == 'hash_and_filename':
  606.                                 opt_selection_match = 'on'
  607.                         if opt_selection_language == 'auto':
  608.                             if searchLanguage > 1:
  609.                                 opt_selection_language = 'on'
  610.                         if opt_selection_hi == 'auto':
  611.                             if item['SubHearingImpaired'] == '1':
  612.                                 opt_selection_hi = 'on'
  613.                         if opt_selection_rating == 'auto':
  614.                             if item['SubRating'] != '0.0':
  615.                                 opt_selection_rating = 'on'
  616.                         if opt_selection_count == 'auto':
  617.                             opt_selection_count = 'on'
  618.                     # Spaw selection window
  619.                     if opt_gui == 'gnome':
  620.                         subtitlesSelected = selectionGnome(subtitlesList)
  621.                     elif opt_gui == 'kde':
  622.                         subtitlesSelected = selectionKde(subtitlesList)
  623.                     else: # CLI
  624.                         subtitlesSelected = selectionCLI(subtitlesList)
  625.             # If a subtitles has been selected at this point, download it!
  626.             if subtitlesSelected:
  627.                 subIndex = 0
  628.                 subIndexTemp = 0
  629.                 # Select the subtitles file to download
  630.                 for item in subtitlesList['data']:
  631.                     if item['SubFileName'] == subtitlesSelected:
  632.                         subIndex = subIndexTemp
  633.                         break
  634.                     else:
  635.                         subIndexTemp += 1
  636.                 subLangId = opt_language_separator  + subtitlesList['data'][subIndex]['ISO639']
  637.                 subLangName = subtitlesList['data'][subIndex]['LanguageName']
  638.                 subURL = subtitlesList['data'][subIndex]['SubDownloadLink']
  639.                 subEncoding = subtitlesList['data'][subIndex]['SubEncoding']
  640.                 subPath = videoPath.rsplit('.', 1)[0] + '.' + subtitlesList['data'][subIndex]['SubFormat']
  641.                 if opt_output_path and os.path.isdir(os.path.abspath(opt_output_path)):
  642.                     subPath = os.path.abspath(opt_output_path) + "/" + subPath.rsplit('/', 1)[1]
  643.                 # Write language code into the filename?
  644.                 if ((opt_language_suffix == 'on') or (opt_language_suffix == 'auto' and searchLanguageResult > 1)):
  645.                     subPath = videoPath.rsplit('.', 1)[0] + subLangId + '.' + subtitlesList['data'][subIndex]['SubFormat']
  646.                 # Escape non-alphanumeric characters from the subtitles path
  647.                 if opt_gui != 'cli':
  648.                     subPath = re.escape(subPath)
  649.                 # Make sure we are downloading an UTF8 encoded file
  650.                 downloadPos = subURL.find("download/")
  651.                 if downloadPos > 0:
  652.                     subURL = subURL[:downloadPos+9] + "subencoding-utf8/" + subURL[downloadPos+9:]
  653.                 ## Download and unzip the selected subtitles (with progressbar)
  654.                 if opt_gui == 'gnome':
  655.                     process_subtitlesDownload = subprocess.call("(wget -q -O - " + subURL + " | gunzip > " + subPath + ") 2>&1" + ' | (zenity --auto-close --progress --pulsate --title="Downloading subtitles, please wait..." --text="Downloading <b>' + subtitlesList['data'][subIndex]['LanguageName'] + '</b> subtitles for <b>' + videoTitle + '</b>...")', shell=True)
  656.                 elif opt_gui == 'kde':
  657.                     process_subtitlesDownload = subprocess.call("(wget -q -O - " + subURL + " | gunzip > " + subPath + ") 2>&1", shell=True)
  658.                 else: # CLI
  659.                     print(">> Downloading '" + subtitlesList['data'][subIndex]['LanguageName'] + "' subtitles for '" + videoTitle + "'")
  660.                     if sys.version_info >= (3, 0):
  661.                         tmpFile1, headers = urllib.request.urlretrieve(subURL)
  662.                         tmpFile2 = gzip.GzipFile(tmpFile1)
  663.                         byteswritten = open(subPath, 'wb').write(tmpFile2.read())
  664.                         if byteswritten > 0:
  665.                             process_subtitlesDownload = 0
  666.                         else:
  667.                             process_subtitlesDownload = 1
  668.                     else: # python 2
  669.                         tmpFile1, headers = urllib.urlretrieve(subURL)
  670.                         tmpFile2 = gzip.GzipFile(tmpFile1)
  671.                         open(subPath, 'wb').write(tmpFile2.read())
  672.                         process_subtitlesDownload = 0
  673.                 # If an error occurs, say so
  674.                 if process_subtitlesDownload != 0:
  675.                     superPrint("error", "Subtitling error!", "An error occurred while downloading or writing <b>" + subtitlesList['data'][subIndex]['LanguageName'] + "</b> subtitles for <b>" + videoTitle + "</b>.")
  676.                     osd_server.LogOut(session['token'])
  677.                     sys.exit(2)
  678.     ## Print a message if no subtitles have been found, for any of the languages
  679.     if searchLanguageResult == 0:
  680.         superPrint("info", "No subtitles available :-(", '<b>No subtitles found</b> for this video:\n<i>' + videoFileName + '</i>')
  681.         ExitCode = 1
  682.     else:
  683.         ExitCode = 0
  684. except (OSError, IOError, RuntimeError, TypeError, NameError, KeyError):
  685.     # Do not warn about remote disconnection # bug/feature of python 3.5?
  686.     if "http.client.RemoteDisconnected" in str(sys.exc_info()[0]):
  687.         sys.exit(ExitCode)
  688.     # An unknown error occur, let's apologize before exiting
  689.     superPrint("error", "Unexpected error!", "OpenSubtitlesDownload encountered an <b>unknown error</b>, sorry about that...\n\n" + \
  690.                "Error: <b>" + str(sys.exc_info()[0]).replace('<', '[').replace('>', ']') + "</b>\n" + \
  691.                "Line: <b>" + str(sys.exc_info()[-1].tb_lineno) + "</b>\n\n" + \
  692.                "Just to be safe, please check:\n- www.opensubtitles.org availability\n- Your downloads limit (200 subtitles per 24h)\n- Your Internet connection status\n- That are using the latest version of this software ;-)")
  693. except Exception:
  694.     # Catch unhandled exceptions but do not spawn an error window
  695.     print("Unexpected error (line " + str(sys.exc_info()[-1].tb_lineno) + "): " + str(sys.exc_info()[0]))
  696. # Disconnect from opensubtitles.org server, then exit
  697. if session and session['token']:
  698.     osd_server.LogOut(session['token'])
  699. sys.exit(ExitCode)

Все что ему нужно — указать файл для которого мы хотим найти сабы, и пару параметров отвечающих за сами сабы (язык, обновлять не обновлять и т.д.). Так как мы исходим из того, что торрент-файлы у нас качаются автоматически, то и этот скрипт применять к файлам лучше скриптом, добавленным в крон. Скрипт простецкий:

/etc/cron.daily/download-subtitle
 
  1. #!/bin/bash
  2. path="/share/torrents/xa/"
  3. download_sub="/opt/OpenSubtitlesDownload.py --cli --lang eng --lang rus --skip  --auto "
  4. rmd="rm -rf "
  5. find "${path}" -size  50M -type f  -exec ${download_sub} {} \;
  6. find "${path}" -empty -type d  -exec ${rmd} {} \;

Этот скрипт проверяет все файлы в нашем каталоге /share/torrents/xa/, находит файлы больше 50 мегабайт (потому что иногда в торрентах содержится не только видеофайл, но и какой-нибудь сопровождающий файл с описанием релиза, да и сами субтитры, которые скачались в прошлый раз, нас не интересуют) и натравливает на каждый их них скрипт поиска субтитров. Если субтитры указанных языков (русский английский) найдены — они скачиваются. Также скрипт удаляет пустые каталоги, которые иногда образуются лично у меня после переноса новых серий на постоянное место жительства.

В итоге

Мы получаем уютный сервачок, который сам по себе живет и поставляет к нашему столу свежие серии. Работает исправно на все 95%, осечки случаются, но как правило это связано с некорректным названием торрентов (рукожопы случаются) или отсутствием субтитров на opensubtitles (если я нахожу сабы на стороне, стараюсь их добавить и туда).

Элитка

В очередной раз убедился, что под музло любая фигня выглядит пафоснее ))

Элитка, конечно, умеет дарить потрясающее чувство одиночества и бить по голове ощущением масштабности пустоты. Когда перед тобой планета разворачивается из точки в огромную пустыню, со всеми не-условными расстояниями и не-условными скоростями, с которыми ты можешь перемещаться непосредственно на поверхности. Только что ты ломился со скоростью света и все вокруг было микроскопическим, а спустя пару минут ты внизу, плетешься черепашкой среди льда и камней, и часами пересекаешь какой-нибудь кратер, который сверху казался не больше монеты. На таком контрасте игра очень сильно ловит и заставляет ощутить себя в настоящем социальном вакууме, очень круто.