├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── PKGBUILD ├── README.md ├── dev_requirements.txt ├── docs ├── _stdopts.md ├── clients.md ├── confetti.md ├── config.md ├── emconfetti-demo.md ├── emconfetti-tghi.md ├── emdiff.md ├── emfile.md ├── emfind.md ├── emload.md ├── emrm.md ├── emupdate.md ├── hooks.md ├── index.md ├── rTorrent-XMLRPC-Reference.md ├── rTorrent-system_multicall.md ├── theme │ └── partials │ │ └── language.html └── trackers.md ├── emonoda ├── __init__.py ├── apps │ ├── __init__.py │ ├── emconfetti_demo.py │ ├── emconfetti_tghi.py │ ├── emdiff.py │ ├── emfile.py │ ├── emfind.py │ ├── emload.py │ ├── emrm.py │ ├── emstat.py │ ├── emupdate.py │ └── hooks │ │ ├── __init__.py │ │ ├── rtorrent │ │ ├── __init__.py │ │ ├── collectd_stat.py │ │ └── manage_trackers.py │ │ └── transmission │ │ ├── __init__.py │ │ └── redownload.py ├── cli.py ├── fmt.py ├── helpers │ ├── __init__.py │ ├── datacache.py │ ├── surprise.py │ └── tcollection.py ├── optconf │ ├── __init__.py │ ├── converters.py │ ├── dumper.py │ └── loader.py ├── plugins │ ├── __init__.py │ ├── clients │ │ ├── __init__.py │ │ ├── ktorrent.py │ │ ├── qbittorrent.py │ │ ├── qbittorrent2.py │ │ ├── rtorrent.py │ │ └── transmission.py │ ├── confetti │ │ ├── __init__.py │ │ ├── atom.py │ │ ├── email.py │ │ ├── pushover.py │ │ ├── telegrem.py │ │ └── templates │ │ │ ├── atom.html.emupdate.mako │ │ │ ├── atom.plain.emupdate.mako │ │ │ ├── email.html.emupdate.mako │ │ │ ├── email.plain.emupdate.mako │ │ │ ├── pushover.emupdate.mako │ │ │ └── telegram.emupdate.mako │ └── trackers │ │ ├── __init__.py │ │ ├── booktracker_org.py │ │ ├── ipv6_nnm_club_name.py │ │ ├── kinozal_tv.py │ │ ├── nnm_club_me.py │ │ ├── pornolab_net.py │ │ ├── pravtor_ru.py │ │ ├── rutor_info.py │ │ ├── rutracker_org.py │ │ ├── tr_anidub_com.py │ │ └── trec_to.py ├── tfile.py ├── thirdparty │ ├── __init__.py │ ├── bencoder.pyx │ └── socks.py ├── tools.py └── web │ ├── __init__.py │ ├── gziphandler.py │ └── sockshandler.py ├── flake8.ini ├── mkdocs.yml ├── mypy.ini ├── pylintrc ├── requirements.txt ├── setup.py ├── tox.ini └── trackers ├── booktracker.org.json ├── ipv6.nnm-club.me.json ├── ipv6.nnm-club.name.json ├── ipv6.nnmclub.to.json ├── kinozal.tv.json ├── nnm-club.me.json ├── nnm-club.name.json ├── nnmclub.to.json ├── pornolab.net.json ├── pravtor.ru.json ├── rutor.info.json ├── rutor.org.json ├── rutracker.org.json ├── torrent.rus.ec.json ├── torrents.ru.json ├── tr.anidub.com.json └── trec.to.json /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 2.1.38 5 | parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? 6 | serialize = 7 | {major}.{minor}.{patch} 8 | 9 | [bumpversion:file:setup.py] 10 | search = version="{current_version}" 11 | replace = version="{new_version}" 12 | 13 | [bumpversion:file:PKGBUILD] 14 | search = pkgver={current_version} 15 | replace = pkgver={new_version} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg-root.arch/ 2 | /pkg/ 3 | /src/ 4 | /build/ 5 | /dist/ 6 | /site/ 7 | /emonoda.egg-info/ 8 | /emonoda/thirdparty/bencoder.c 9 | /emonoda/thirdparty/bencoder.*.so 10 | /.tox/ 11 | /.mypy_cache/ 12 | /emonoda-*.tar.gz 13 | *.pyc 14 | *.swp 15 | Earthfile 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.9" 4 | install: 5 | - sudo apt-get --yes install git 6 | - pip install Cython tox 7 | script: tox 8 | sudo: true 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: bencoder 2 | 3 | bencoder: 4 | python3 setup.py build_ext --inplace 5 | 6 | regen: regen-trackers 7 | 8 | regen-trackers: bencoder 9 | python3 -c 'from json import dumps; from emonoda.plugins import get_classes; \ 10 | [ open("trackers/{}.json".format(name), "w").write(dumps(cls._get_local_info(), sort_keys=True, indent=" " * 4)) \ 11 | for (name, cls) in get_classes("trackers").items() ]' 12 | 13 | release: 14 | make clean 15 | make tox 16 | make clean 17 | make push 18 | make bump 19 | make push 20 | # make pypi 21 | make clean 22 | make mkdocs 23 | make clean 24 | make mkdocs-release 25 | make clean 26 | 27 | tox: 28 | tox -q $(if $(E),-e $(E),-p auto) 29 | 30 | bump: 31 | bumpversion patch 32 | 33 | push: 34 | git push 35 | git push --tags 36 | 37 | mkdocs: 38 | mkdocs build 39 | 40 | mkdocs-release: 41 | mkdocs gh-deploy 42 | 43 | pypi: 44 | python3 setup.py register 45 | python3 setup.py sdist 46 | twine upload dist/* 47 | 48 | clean: 49 | rm -rf build site dist pkg src *.egg-info emonoda-*.tar.gz 50 | rm -f emonoda/thirdparty/bencoder.c emonoda/thirdparty/bencoder.*.so 51 | find -name __pycache__ | xargs rm -rf 52 | 53 | clean-all: clean 54 | rm -rf .tox .mypy_cache 55 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Contributor: Devaev Maxim 2 | # Author: Devaev Maxim 3 | 4 | 5 | pkgname=emonoda 6 | pkgver=2.1.38 7 | pkgrel=1 8 | pkgdesc="A set of tools to organize and manage your torrents" 9 | url="https://github.com/mdevaev/emonoda" 10 | license=(GPL) 11 | arch=(any) 12 | depends=( 13 | "python>=3.10" 14 | "python<3.11" 15 | python-chardet 16 | python-yaml 17 | python-colorama 18 | python-pygments 19 | python-mako 20 | python-pytz 21 | python-dateutil 22 | ) 23 | optdepends=( 24 | "python-transmissionrpc: Transmission support" 25 | "python-dbus: KTorrent support" 26 | ) 27 | makedepends=(python-setuptools cython) 28 | source=("$pkgname-$pkgver::git+$url#tag=v$pkgver") 29 | md5sums=(SKIP) 30 | 31 | 32 | build() { 33 | cd "$srcdir/$pkgname-$pkgver" 34 | python setup.py build 35 | } 36 | 37 | package() { 38 | cd "$srcdir/$pkgname-$pkgver" 39 | python setup.py install --root="$pkgdir" 40 | } 41 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | Cython 2 | mkdocs 3 | mkdocs-material 4 | markdown-include 5 | bumpversion 6 | tox 7 | -------------------------------------------------------------------------------- /docs/_stdopts.md: -------------------------------------------------------------------------------- 1 | !!! info 2 | Кроме собственных опций, указанных ниже, команда поддерживает еще и общий стандартный набор (например, `--help` и `--config`). За подробностями обратитесь на страницу [config](config). 3 | -------------------------------------------------------------------------------- /docs/clients.md: -------------------------------------------------------------------------------- 1 | !!! info 2 | **Emonoda** должна стоять на одной машине с клиентом, поскольку ее программам требуется прямой доступ к файлам. О базовых принципах настройки системы читайте на странице, [посвящённой конфигурации](config). Клиентские плагины имеют множество параметров, но в обычной ситуации достаточно значений по умолчанию. Их вы можете посмотреть с помощью `emfile -m`. 3 | 4 | 5 | *** 6 | ### rTorrent 7 | 8 | Включается параметром `core/client=rtorrent`. Соединение с клиентом выполняется по урлу, задаваемому параметром `client/url`, по умолчанию - `http://localhost/RPC2`. 9 | 10 | Для использования этого клиента вам потребуется веб-сервер с настроенным XMLRPC-шлюзом. Если вы используете [ruTorrent](https://github.com/Novik/ruTorrent), то все необходимые настройки у вас уже должны быть выполнены. В противном случае воспользуйтесь параграфом "Настройка веб-сервера" из [этого](https://wiki.archlinux.org/index.php/RuTorrent_(%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9)) руководства. 11 | 12 | Поддерживается полный набор функций, включая кастомные поля данных - атрибуты, которые позволяет сохранять **rTorrent** для каждого торрент-файла. Например, **ruTorrent** в поле `c1` хранит тег раздачи. Доступны `c1`, `c2`, `c3`, `c4` и `c5` - по аналогии с методами `d.set_custom1/d.get_custom1` [API rTorrent](rTorrent-XMLRPC-Reference). 13 | 14 | 15 | *** 16 | ### Transmission 17 | Включается параметром `core/client=transmission`. Соединение с клиентом выполняется по урлу, задаваемому параметром `client/url`, по умолчанию - `http://localhost:9091/transmission/rpc`. 18 | 19 | Для работы нужен питоновый модуль [transmissionrpc](https://bitbucket.org/blueluna/transmissionrpc) версии 0.11 или выше. Нет поддержки сохранения кастомных полей (по причине их [отсутствия](https://trac.transmissionbt.com/ticket/2175) в самом Transmission). 20 | 21 | Логин и пароль задаются параметрами `client/user` и `client/passwd` соответственно. 22 | 23 | 24 | *** 25 | ### KTorrent 26 | 27 | Включается параметром `core/client=ktorrent`. Соединение выполняется с помощью DBus. 28 | 29 | Поддержка этого клиента сильно ограничена из-за его куцего API. Кроме того, для использования вместе с **Emonoda** вам потребуется убрать в настройках **KTorrent** путь по умолчанию для сохранения скачанных данных ("Папка по умолчанию для загрузки"), иначе [emupdate](emupdate) не сможет корректно обновлять ваши раздачи. 30 | 31 | Для работы нужен питоновый модуль **dbus**. Нет поддержки кастомных полей. 32 | 33 | 34 | *** 35 | ### qBittorrent 36 | 37 | Включается параметром `core/client=qbittorrent` (для qBittorrent v3.2.0-v4.0.4) или `core/client=qbittorrent2` (для v4.1+). Для работы плагина необходимо запустить WebUI в настройках **qBittorrent**. Соединение выполняется по урлу, задаваемому параметром `client/url`, по умолчанию - `http://localhost:8080`. При необходимости аутентификации логин и пароль задаются параметрами `client/user` и `client/passwd` соответственно. 38 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | ### Опции командной строки 2 | 3 | Все программы **Emonoda** (кроме [хуков](hooks)) поддерживают общий набор базовых опций: 4 | 5 | * **`-h, --help`** 6 | * Выводит краткую справку. 7 | 8 | * **`-c, --config `** 9 | * Устанавливает путь к конфигурационному файлу, по умолчанию - `~/.config/emonoda.yaml`. 10 | 11 | * **`-o, --set-options `** 12 | * Переопределяет параметры из конфига для единичного запуска команды. Вложенные ямловые ключи разделяются слешем, значение устанавливается после знака равенства. Можно передавать несколько параметров через пробел. 13 | 14 | * **`-m, --dump-config`** 15 | * Выводит дамп конфигурации в виде YAML с комментариями. 16 | 17 | 18 | *** 19 | ### Конфигурационный файл 20 | 21 | Вся система настраивается через единственный файл `~/.config/emonoda.yaml`. Его содержимое делится на секции, в которых описываются одиночные ключи, списки или пары вида ключ-значение. Для описания параметров в рамках одной секции используется отступ из четырех или двух пробелов. Комментарий отделяется символом `#`. Например: 22 | 23 | ```yaml 24 | core: # Корневая секция 25 | client: rtorrent # Параметр client в секции core со значением rtorrent 26 | 27 | trackers: # Другая корневая секция 28 | rutor.org: # Подсекция 29 | timeout: 5 # Параметр 30 | ``` 31 | 32 | Для обозначения подобных вложенных параметров в этой документации часто будет использоваться такая запись: `core/client=rtorrent` или `trackers/rutor.org/timeout=5.0`. 33 | 34 | Этот же синтаксис используется для передачи значения параметров в опцию `-o/--set-options`: `--set-options core/client=rtorrent trackers/rutor.org/timeout=5`. Через знак равенства указываются значения по умолчанию; `null`, `[]` или `{}` обозначают отключенную функцию. 35 | 36 | 37 | *** 38 | ### Основные параметры 39 | 40 | Команды [emfile](emfile) и [emdiff](emdiff) могут работать с конфигурацией по умолчанию, однако для всех остальных утилит вам потребуется настроить [интеграцию с клиентом](clients), а также указать пути к каталогам с торрент-файлами и данными. 41 | 42 | Конфигурация делится на несколько секций. Одна их часть является общими параметрами, другия же относятся непосредственно к каждой отдельной программе. Ниже приведен минимальный пример файла `~/.config/emonoda.yaml`, содержащий простые настройки для торрент-клиента и трех трекеров. С такой конфигурацией вы сможете пользоваться всеми программами **Emonoda**. Трекеры необходимы только для [emupdate](emupdate). 43 | 44 | ```yaml 45 | core: 46 | torrents_dir: /home/user/torrents # Каталог с торрент-файлами 47 | data_root_dir: /home/user/Downloads # Каталог, куда скачиваются данные 48 | client: rtorrent # Имя плагина для клиента 49 | 50 | trackers: 51 | rutracker.org: 52 | user: yourlogin 53 | passwd: 12345 54 | 55 | nnm-club.me: 56 | user: yourlogin 57 | passwd: 12345 58 | 59 | tr.anidub.com: 60 | user: yourlogin 61 | passwd: 12345 62 | ``` 63 | 64 | !!! info 65 | Полный список всех секций и параметров (в том числе и для выбранных плагинов) вы всегда можете посмотреть с помощью команды `emfile -m`. Параметры секции `core` и `client` действуют на все программы. 66 | 67 | * **`core/client=""`** 68 | * Плагин используемого [клиента](clients). 69 | 70 | * **`core/torrents_dir=.`** 71 | * Путь к каталогу, где лежат торрент-файлы. 72 | 73 | * **`core/use_colors=true`** 74 | * Параметр разрешает использование цветов при выводе на терминал. 75 | 76 | * **`core/force_colors=false`** 77 | * При использовании `core/use_colors` программа будет раскрашивать свой вывод, если он направлен в терминал. При перенаправлении в файл раскрашивание отключается, однако при `core/force_colors=true` вывод будет раскрашиваться всегда. 78 | 79 | * **`core/data_root_dir=~/Downloads`** 80 | * Корневой каталог с данными торрентов. В его подкаталоги [emload](emload) загружает новые данные, а [emfind](emfind) индексирует их. 81 | 82 | * **`core/another_data_root_dirs=[]`** 83 | * Дополнительный список каталогов, где находятся уже загруженные ранее торренты. Этот параметр полезен, если до установки **Emonoda** и использования [emload](emload) данные качались по разным путям. Он добавляет указанные каталоги для поиска с помощью [emfind](emfind). 84 | 85 | | Секция | Описание | 86 | |--------|----------| 87 | | `core` | Общие параметры системы - имя плагина торрент-клиента, пути к каталогам, настройки вывода на терминал. | 88 | | `client` | Специфические параметры клиента. Содержимое секции зависит от используемого плагина, указанного в `core/client`. Подробнее смотрите на странице [clients](clients). | 89 | | `trackers` | Список трекеров с логинами и паролями, использующийся в [emupdate](emupdate), чтобы обновлять торренты. Подробнее смотрите на странице [trackers](trackers). | 90 | | `confetti` | Список плагинов для отправки оповещений о результатах работы [emupdate](emupdate) (например, по почте). | 91 | | `emupdate` | Специфические параметры [emupdate](emupdate). | 92 | | `emfile` | Специфические параметры [emfile](emfile). | 93 | | `emfind` | Специфические параметры [emfind](emfind). | 94 | | `emload` | Специфические параметры [emload](emload). | 95 | -------------------------------------------------------------------------------- /docs/emconfetti-demo.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emconfetti-demo** - команда для тестирования оповещений об обновлениях торрентов, настроенных для [emupdate](emupdate) в [конфигурационной секции ёconfetti](confetti). Запущенная без опций, она отсылает демо-сообщения с помощью всех доступных плагинов. 4 | 5 | 6 | *** 7 | ### Опции 8 | 9 | {!_stdopts.md!} 10 | 11 | * **`-y, --only-confetti `** 12 | * Отсылать только оповещения для указанного подмножества плагинов от всех настроенных в секции `confetti`. 13 | 14 | * **`-x, --exclude-confetti `** 15 | * Включить все плагины из `confetti`, кроме перечисленных в этой опции. 16 | -------------------------------------------------------------------------------- /docs/emconfetti-tghi.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emconfetti-tghi** - вспомогательная команда для получения идентификатора Telegram-чата для [оповещений](confetti) об обновлений торрентов от [emupdate](emupdate). Запущенная без опций, она считывает последние сообщения, отправленные вашему боту и выводит имена пользователей, обращавшихся к нему, с идентификаторами их чатов. 4 | 5 | Результатом работы команды при настроенном токене бота будет следующий вывод: 6 | 7 | ``` 8 | $ emconfetti-tghi 9 | # I: Confetti telegram is ready 10 | - Chat with user 'user1': 123 11 | - Chat with user 'user2': 456 12 | - Chat with user 'user3': 789 13 | ``` 14 | 15 | 16 | *** 17 | ### Опции 18 | 19 | {!_stdopts.md!} 20 | 21 | * **`-n, --limit `** 22 | * Количество считываемых сообщений бота, по умолчанию - `10`. 23 | -------------------------------------------------------------------------------- /docs/emdiff.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emdiff** - команда для сравнения содержимого двух торрентов по спискам предоставляемых ими файлов. Выводит список затронутых изменениями файлов во втором торренте по сравнению с первым. Сравнение файлов происходит поименно. Например: 4 | 5 | ``` 6 | $ emdiff t1.torrent t2.torrent 7 | + directory/file1 8 | - directory/file2 9 | ~ directory/changed_file 10 | ? directory/changed_type 11 | ``` 12 | 13 | Ниже представлены расшифровки кодов диффа: 14 | 15 | | Код | Расшифровка | 16 | |-----|-------------| 17 | | **`+`** | В торренте `t2` находится файл, которого нет в `t1`. | 18 | | **`-`** | В торренте `t2` нет файла, находящегося в `t1`. | 19 | | **`~`** | В обоих торрентах найден указанный файл, однако его содержимое различно. **Эта метка ненадежна! Пожалуйста, внимательно прочтите описание ниже!** | 20 | | **`?`** | Указанный путь имеет разный тип в торрентах. Например, в одном торренте этим именем назывался файл, а в другом торренте это каталог. | 21 | 22 | !!! warning "Замечание относительно ~" 23 | В силу особенностей формата, нельзя точно определить, различается ли содержимое файлов, входящих в два торрента. Файлы представлены единым потоком байтов, который режется на чанки фиксированного размера. Торрент так же несет в себе индекс со смещениями в данных, обозначающие границы файлов в раздаче. Границы чанков не совпадают с границами файлов, более того, в один чанк может входить несколько файлов. При определении различий содержимого используется сравнение размеров - это единственная информация о файле, которая хранится в торренте. Некоторые клиенты записывают в торрент [поле](https://wiki.theory.org/index.php/BitTorrentSpecification) `md5sum` для каждого из файлов, но на практике таких раздач я не встречал. 24 | 25 | Таким образом, если в диффе присутствует символ `~`, то указанные файлы различаются абсолютно точно, однако его отсутствие не говорит о том, что файлы не менялись. С другой стороны, символы `+`, `-` и `?` являются надежными и вы можете на них полагаться. Если два файла будут различаться по содержимому, но их размер совпадет с точностью до байта, то **emdiff** не увидит между ними разницы. 26 | 27 | 28 | *** 29 | ### Опции 30 | 31 | {!_stdopts.md!} 32 | 33 | * **`-v, --verbose`** 34 | * Включает отладочные сообщения, направляемые в stderr. 35 | 36 | 37 | *** 38 | ### Примеры использования 39 | 40 | Чем отличаются два файла в бекапах [emupdate](emupdate)? 41 | 42 | ``` 43 | $ emdiff backup/attack_on_titan.torrent.1380475600.bak backup/attack_on_titan.torrent.1380873522.bak 44 | + Shingeki no Kyojin [KANSAI]/[KANSAI] Shingeki no Kyojin - 25 1280x720.mp4 45 | ``` 46 | 47 | Чем отличается старый бекап от торрента, загруженного в клиенте? 48 | 49 | ``` 50 | $ emdiff backup/attack_on_titan.torrent.1380475600.bak 8237951c7e1abdfbbefce7e7996ed792d8bf2c5d 51 | + Shingeki no Kyojin [KANSAI]/[KANSAI] Shingeki no Kyojin - 25 1280x720.mp4 52 | ~ Shingeki no Kyojin [KANSAI]/[KANSAI] Shingeki no Kyojin - 23 1280x720.mp4 53 | ~ Shingeki no Kyojin [KANSAI]/[KANSAI] Shingeki no Kyojin - 24 1280x720.mp4 54 | ``` 55 | 56 | Если указан относительный путь к файлу, **emdiff** попытается найти его в каталоге, указанном параметром `core/torrents_dir` в конфигурации. Если указан хеш, то будет использован список файлов раздачи, взятый из клиента. Можно указывать два хеша, и тогда **emdiff** сравнит два списка файлов из клиента. Для использования этой возможности вы должны [настроить](config) [интеграцию с клиентом](clients) в `~/.config/emonoda.yaml`. 57 | 58 | ```yaml 59 | core: 60 | client: rtorrent 61 | ``` 62 | 63 | Подробнее об этом и о порядке поиска торрент-файлов смотрите на странице [config](config). 64 | -------------------------------------------------------------------------------- /docs/emfile.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emfile** - команда для извлечения метаданных из торрент-файла. Запущенная без опций, она выводит список всех полей в человекочитаемом формате, а с опциями - только конкретное поле без пояснений, что очень удобно для использования в скриптах. 4 | 5 | *** 6 | 7 | ### Опции 8 | 9 | {!_stdopts.md!} 10 | 11 | * **`-v, --verbose`** 12 | * Включает отладочные сообщения, направляемые в stderr. 13 | 14 | * **`--path`** 15 | * Вывести путь к файлу. 16 | 17 | * **`--hash`** 18 | * Вывести имя раздачи. 19 | 20 | * **`--comment`** 21 | * Вывести комментарий о раздаче. Если такого поля в торренте нет, будет выведена пустая строка. Обычно в комментариях указывают ссылку на страницу, откуда был скачан торрент. 22 | 23 | * **`--size`** 24 | * Вывести размер данных торрента в байтах. 25 | 26 | * **`--size-pretty`** 27 | * Вывести размер данных в читаемом виде (например - 10 Gb). 28 | 29 | * **`--announce`** 30 | * Вывести адрес трекера. 31 | 32 | *** 33 | 34 | * **`--announce-list`** 35 | * Вывести список дополнительных трекеров, по одному на каждую строку. 36 | 37 | * **`--announce-list-pretty`** 38 | * Вывести список трекеров, разделенный пробелами, в одну строку. 39 | 40 | * **`--creation-date`** 41 | * Вывести время создания торрент-файла в секундах unixtime. 42 | 43 | * **`--creation-date-pretty`** 44 | * Вывести время создания торрент-файла в читаемом формате. 45 | 46 | * **`--created-by`** 47 | * Вывести название и версию программы, в которой был создан торрент. 48 | 49 | !!! info 50 | Не все торрент-файлы имеют поля метаданных, требующиеся опциям `--announce-list*`, `--creation-date*` и `--created-by`. В случае, если их нет, **emfile** выведет пустую строку (с хедером или без него) 51 | 52 | *** 53 | 54 | * **`--provides`** 55 | * Вывести список файлов в торренте. 56 | 57 | * **`--is-private`** 58 | * Вывести `1`, если для торрента запрещен DHT, иначе вывести `0`. 59 | 60 | * **`--is-private-pretty`** 61 | * Вывести `yes`, если для торрента запрещен DHT, иначе вывести `no`. 62 | 63 | *** 64 | 65 | * **`--client-path`** 66 | * Вывести полный путь на файловой системе к данным торрента, включая имя файла или каталога, предоставленного им. Торрент должен быть добавлен в ваш клиент. 67 | 68 | * **`--client-prefix`** 69 | * Вывести префикс к данным торрента. 70 | 71 | * **`--client-customs`** 72 | * Вывести кастомные поля с информацией, назнеойченн на торрент. Требуемые поля определяются параметром `emfile/show_customs` (об этом ниже). Ключ и значение отделяются знаком равенства, и значение может автоматически экранироваться так, чтобы было удобно помещать его в переменную в шелле. 73 | 74 | 75 | !!! info 76 | Поля опций `--client-*` выводятся для тех торрентов, которые были загружены в клиент. Кроме того, для использования этой возможности вы должны [настроить](config) [интеграцию с клиентом](clients). 77 | 78 | 79 | *** 80 | 81 | * **`--make-magnet`** 82 | * Сделать из торрента magnet-ссылку. 83 | 84 | * **`--magnet-fields `** 85 | * Употребляется вместе с предыдущей опцией. Указывает список полей (через пробел) с дополнительной информацией, в которые можно включить в ссылку. Доступны следующие поля: 86 | * `name` - имя торрента; 87 | * `trackers` - адреса трекеров; 88 | * `size` - размер данных торрента. 89 | 90 | !!! danger 91 | Многие популярные трекеры не являются анонимными. В их адреса включается [пасскей](http://rutracker.org/forum/viewtopic.php?t=3396341), позволяющий идентифицировать вас. Публикация торрент-файла или magnet-ссылки с трекером **может вас деанонимизировать**. Кроме того, злоумышленник может использовать ваш пасскей для накрутки собственного рейтинга. Включайте трекеры в magnet-ссылку только тогда, когда вы точно уверены, что это вам не навредит. 92 | 93 | * **`--without-headers`** 94 | * Если указана вместе с любой из опций вывода полей, то **emfile** выведет только значение поля без его названия, что удобно для скриптов. 95 | 96 | !!! info 97 | Порядок выведения полей соответствует порядку указания опций вывода, причем не важно, указана опция `--without-headers`, или нет. 98 | 99 | 100 | *** 101 | ### Конфигурационные параметры 102 | 103 | Общие параметры и способ настройки описаны на странице [config](config), здесь же приведены специфические параметры программы. 104 | 105 | * **`emfile/show_customs=[]`** 106 | * Список кастомных полей, которые должны быть выведены в графе `Client customs`, если настроена [интеграция с клиентом](clients). 107 | 108 | 109 | *** 110 | ### Примеры использования 111 | 112 | Просмотреть все данные о торренте: 113 | 114 | ``` 115 | $ emfile archlinux-2013.10.01-dual.iso.torrent 116 | Path: archlinux-2013.10.01-dual.iso.torrent 117 | Name: archlinux-2013.10.01-dual.iso 118 | Hash: 4343552155062db9a7a05a10904f5c68f98b1216 119 | Size: 529.0 MB 120 | Announce: http://tracker.archlinux.org:6969/announce 121 | Announce list: 122 | Creation date: 2013-10-01 04:07:29 123 | Created by: mktorrent 1.0 124 | Private: no 125 | Comment: Arch Linux 2013.10.01 (www.archlinux.org) 126 | Provides: archlinux-2013.10.01-dual.iso 127 | ``` 128 | 129 | Только выбранные поля для нескольких торрентов: 130 | 131 | ``` 132 | $ emfile torrents/archlinux-2013.0* --name --hash --comment 133 | name: archlinux-2013.05.01-dual.iso 134 | hash: c334d4bc0486692fca78c663015864950a2c21f0 135 | comment: Arch Linux 2013.05.01 (www.archlinux.org) 136 | 137 | name: archlinux-2013.09.01-dual.iso 138 | hash: 311c45eecc10639954d80f666c7404309b962a92 139 | comment: Arch Linux 2013.09.01 (www.archlinux.org) 140 | ``` 141 | 142 | Хеши торрентов, без заголовков полей: 143 | 144 | ``` 145 | $ emfile --hash --without-headers torrents/archlinux-2013.0* 146 | c334d4bc0486692fca78c663015864950a2c21f0 147 | 148 | 311c45eecc10639954d80f666c7404309b962a92 149 | ``` 150 | 151 | Сделать magnet-ссылки с дополнительными полями: 152 | 153 | ``` 154 | $ emfile --make-magnet --magnet-fields name trackers -- torrents/archlinux-2013.0* 155 | make-magnet: magnet:?dn=archlinux-2013.05.01-dual.iso&tr=http%3A%2F%2Ftracker.archlinux.org%3A6969%2Fannounce&xt=urn%3Abtih%3AYM2NJPAEQZUS7STYYZRQCWDESUFCYIPQ 156 | 157 | make-magnet: magnet:?dn=archlinux-2013.09.01-dual.iso&tr=http%3A%2F%2Ftracker.archlinux.org%3A6969%2Fannounce&xt=urn%3Abtih%3AGEOEL3WMCBRZSVGYB5TGY5AEGCNZMKUS 158 | ``` 159 | 160 | Если указан относительный путь к файлу, **emfile** попытается найти его в каталоге, указанном параметром `core/torrents_dir` в конфигурации. Подробнее об этом смотрите на странице [config](config). 161 | -------------------------------------------------------------------------------- /docs/emfind.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emfind** - команда для выполнения различных поисковых запросов по данным клиента и торрент-файлам. Она позволяет содержать вашу коллекцию раздачи в чистоте (помогая удалять старые данные, искать дубликаты торрентов) и активно использует кеширование для повышение скорости работы. Имеет несколько подкоманд, передаваемых в виде аргумента: 4 | 5 | * **`not-in-client`** 6 | * Выводит список торрент-файлов из каталога, указанного [параметром](config) `core/torrents_dir`, не зарегистрированных в клиенте (такие торренты [emupdate](emupdate) помечает меткой `NOT_IN_CLIENT`). 7 | 8 | * **`missing-torrents`** 9 | * Выводит список хешей и имен торрентов, которые зарегистрированы в клиенте, но не имеют торрент-файлов в каталоге `core/torrents_dir`. 10 | 11 | * **`duplicate-torrents`** 12 | * Выводит список торрент-файлов с разными именами, но одинаковым содержимым, найденных в каталоге `core/torrents_dir`. 13 | 14 | * **`orphans`** 15 | * Выводит список файлов и подкаталогов из каталога `core/data_root_dir` (и `core/another_data_root_dirs`), которые не предоставляются ни одним торрент-файлом из `core/torrents_dir`, зарегистрированном в клиенте. Такое часто случается, когда релизер переименовывает какой-нибудь файл в обновленной версии торрента, а клиент скачивает его, больше не считая файл со старым именем частью раздачи. Из-за этого может накопиться много мусора из мелких файлов, лежащих мертвым грузом (автор набрал таким образом примерно 500 гигабайт всякого хлама), но используя `emfind orphans` вы сможете избавиться от них. Первый вызов команды построит внутренний кеш (см. ниже), который в дальнейшем будет использоваться для быстрого поиска. 16 | 17 | * **`rebuild-cache`** 18 | * Форсирует перестройку кеша, по умолчанию сохраняемого в файле `~/.cache/emfind.json`. Обычно кеши перестраиваются автоматически при необходимости, однако если вы перемещаете данные торрента из одного каталога в другой, кеши нужно будет обновить вручную. 19 | 20 | 21 | *** 22 | ### Опции 23 | 24 | {!_stdopts.md!} 25 | 26 | * **`-v, --verbose`** 27 | * Включает отладочные сообщения, направляемые в stderr. 28 | 29 | 30 | *** 31 | ### Конфигурационные параметры 32 | 33 | Общие параметры и способ настройки описаны на странице [config](config), здесь же приведены специфические параметры программы. 34 | 35 | * **`emfind/cache_file=~/.cache/emfind.json`** 36 | * Кеш для `emfind orphans`, содержащий список файлов в раздачах и их метаданные. 37 | 38 | * **`emfind/name_filter='*.torrent'`** 39 | * Шаблон, которому должны соответствовать файлы из каталога `core/torrents_dir`. 40 | 41 | * **`emfind/files_from_client=false`** 42 | * Заставляет брать содержимое торрент-файла из клиента, а не из торрента. Работает медленнее, но необходимо, если вы переименовываете файлы в клиенте. 43 | 44 | * **`emfind/ignore_orphans=[]`** 45 | * Список файлов, которые следует игнорировать и на считать устаревшими. Полезно, если вы добавляете в каталог раздачи какие-то свои файлы, вроде readme.txt для заметок. 46 | 47 | 48 | *** 49 | ### Примеры использования 50 | 51 | Найти все данные, незарегистрированные в клиенте: 52 | 53 | ``` 54 | $ emfind orphans 55 | # I: Client rtorrent is ready 56 | # I: Reading the cache from /home/mdevaev/.cache/emfind.json ... 57 | # I: Fetching all hashes from client ... 58 | # I: Validating the cache ... 59 | # I: Removed 1 obsolete hashes from cache 60 | # I: Loaded 555 torrents from /home/mdevaev/torrents/*.torrent 61 | # I: Added 3 new hashes from client 62 | # I: Writing the cache to /home/mdevaev/.cache/emfind.json ... 63 | # I: Scanning directory /srv/storage/torrents ... 64 | # I: Transposing the cache: by-hashes -> files ... 65 | # I: Orhpaned files: 66 | F /srv/storage/torrents/o/one_punch_man.torrent.data/[AniDub]_One-Punch_Man_[720p]_[JAM]/[AniDub]_One-Punch_Man_OVA_[720p_x264_AAC]_[JAM].mp4 67 | F /srv/storage/torrents/r/real_drive__hdtvrip_720p.torrent.data/Real Drive/Real Drive - 10 (D-NTV 1280x720 x264 AAC) [AU-Raws].utf8.ass 68 | F /srv/storage/torrents/r/real_drive__hdtvrip_720p.torrent.data/Real Drive/Real Drive - 12 (D-NTV 1280x720 x264 AAC) [AU-Raws].utf8.ass 69 | F /srv/storage/torrents/r/real_drive__hdtvrip_720p.torrent.data/Real Drive/Real Drive - 20 (D-NTV 1280x720 x264 AAC) [AU-Raws].utf8.ass 70 | F /srv/storage/torrents/r/real_drive__hdtvrip_720p.torrent.data/Real Drive/Real Drive - 20 (D-NTV 1280x720 x264 AAC) [Negi-Raws].utf8.ass 71 | F /srv/storage/torrents/r/real_drive__hdtvrip_720p.torrent.data/Real Drive/Real Drive - 25 (D-NTV 1280x720 x264 AAC) [Negi-Raws].utf8.ass 72 | F /srv/storage/torrents/s/serenity_comics.torrent.data/Serenity/readme 73 | # I: Found 7 orphaned files = 311.7 MB 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/emload.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emload** - команда для добавления торрента в клиент и автоматической каталогизации с использованием так называемой ссылочной схемы, когда данные хранятся в специально отведенном каталоге, а коллекция составляется из папок и символических ссылок на данные. Как именно это работает, проще всего объяснить на примере любого торрент-файла. 4 | 5 | Скажем, если у вас имеется торрент `revolution_os.torrent`, содержащий файл `Revolution OS.mkv`, а в качестве клиента используется [rTorrent](clients#rtorrent), вы можете загрузить свой торрент-файл следующим образом: 6 | 7 | ```bash 8 | $ emload revolution_os.torrent --link-to /srv/collection/Фильмы/Документальные/Революционная\ ОС --set-customs c1=documentary 9 | ``` 10 | 11 | В этом случае **emload** выполнит следующие действия: 12 | 13 | 1. Создаст подкаталог в `core/data_root_dir`, содержащий в названии имя торрент-файла. Если этот [параметр](config) равен `/srv/torrents`, то будет создан такой каталог: `/srv/torrents/r/revolution_os.torrent.data`. 14 | 2. Зная, что торрент предоставляет файл `Revolution OS.mkv`, программа создаст каталог `/srv/collection/Фильмы/Документальные/Революционная ОС` и разместит в нем ссылку `Revolution OS.mkv`, указывающую на `/srv/torrents/r/revolution_os.torrent.data/Revolution OS.mkv`. 15 | 3. Добавит в клиент торрент с указанием каталога для скачки, созданного в первом пункте. 16 | 4. Назначит на торрент [метку](clients#rtorrent) `documentary`. 17 | 18 | Если указан относительный путь к торрент-файлу, **emload** попытается найти его в каталоге, указанном параметром `core/torrents_dir` в конфигурации. Подробнее об этом смотрите на странице [config](config). Кроме того, **emload** загружает торрент только по данным, т.е. клиент не будет ничего знать о расположении торрент-файла. Это очень удобно для систем, где клиент находится на виртуалке, а торренты лежат на хосте. 19 | 20 | 21 | *** 22 | ### Опции 23 | 24 | {!_stdopts.md!} 25 | 26 | * **`-v, --verbose`** 27 | * Включает отладочные сообщения, направляемые в stderr. 28 | 29 | * **`-l, --link-to `** 30 | * Создает символическую ссылку на данные торрента в указанном месте. Если торрент предоставляет файл, то будет создан полный запрошенный путь, а в последний его компонент (каталог) будет помещена ссылка с именем файла, как внутри торрента, указывающая на данные. Если же торрент предоставляет каталог, то последний компонент пути будет сам являться ссылкой на данные. Эта опция работает только если вы указываете для **emload** один торрент-файл. Если целевая ссылка уже существует, то **emload** завершится с ошибкой. 31 | 32 | * **`--set-customs ...`** 33 | * Присваивает [кастомным полям клиента](clients) для загружаемых торрентов определенные значения. 34 | 35 | 36 | *** 37 | ### Конфигурационные параметры 38 | 39 | Общие параметры и способ настройки описаны на странице [config](config), здесь же приведены специфические параметры программы. 40 | 41 | * **`emload/mkdir_mode=""`** 42 | * Восьмеричные права доступа на последний из дерева создаваемых каталогов для данных и ссылок. 43 | 44 | * **`emload/set_customs={}`** 45 | * Назначает загруженным торрентам значения кастомных полей. Перезаписывается соответствующей опцией. Поддерживает питоновые [форматтеры времени](http://docs.python.org/3/library/datetime.html#strftime-strptime-behavior). Например, следующая конфигурация заставит **emload** при использовании **ruTorrent** назначить метку `new` на все новые загрузки: 46 | ```yaml 47 | emload: 48 | set_customs: 49 | c1: new 50 | ``` 51 | 52 | 53 | *** 54 | ### Примеры использования 55 | 56 | Загрузить один торрент: 57 | 58 | ```bash 59 | $ emload foo.torrent 60 | ``` 61 | 62 | Загрузить несколько торрентов и назначить на них метку: 63 | 64 | ```bash 65 | $ emload foo.torrent bar.torrent --set-customs c1=yay 66 | ``` 67 | 68 | Загрузить торрент и создать ссылку: 69 | 70 | ```bash 71 | $ emload foo.torrent --link-to ~/Foo 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/emrm.md: -------------------------------------------------------------------------------- 1 | ### Описание 2 | 3 | **emrm** - команда для удаления торрента из клиента. Принимает в качестве аргумента имя торрент-файла (и пытается найти его в пути, указанном в конфигурации параметром `core/torrents_dir` (если задан)), полный путь к нему или хеш раздачи. **emrm** не удаляет загруженные данные и не трогает сам торрент-файл. 4 | 5 | Для использования этой команды вы должны [настроить](config) [интеграцию с клиентом](clients) и опционально указать путь к каталогу с вашими торрент-файлами в параметре `core/torrents_dir`. 6 | 7 | 8 | *** 9 | ### Опции 10 | 11 | {!_stdopts.md!} 12 | 13 | * **`-v, --verbose`** 14 | * Включает отладочные сообщения, направляемые в stderr. 15 | 16 | 17 | *** 18 | ### Примеры использования 19 | 20 | ``` 21 | $ emrm archlinux-2015.09.01-dual.iso.torrent 22 | $ emrm /var/lib/docker-daemon/rtorrent/torrents/archlinux-2015.09.01-dual.iso.torrent 23 | $ emrm 252b8cb7144fa583de1a68a0298e1ee255b7c7ac 24 | ``` 25 | 26 | Если торрент не загружен в клиент или хеш не найден, **emrm** не будет делать ничего, однако при указании опции `--verbose` напишет в stderr сообщение об ошибке: 27 | 28 | ``` 29 | $ emrm -v 252b8cb7144fa583de1a68a0298e1ee255b7c7ac 30 | # I: Client rtorrent is ready 31 | # E: No such torrent: 252b8cb7144fa583de1a68a0298e1ee255b7c7ac 32 | ``` 33 | 34 | !!! info 35 | Подробнее о том, как **emrm** и другие программы ищут торрент-файлы, смотрите на странице [config](config). 36 | -------------------------------------------------------------------------------- /docs/hooks.md: -------------------------------------------------------------------------------- 1 | В состав **Emonoda** входят несколько полезных скриптов, которые могут использоваться отдельно от нее и не требуют ни ее настроек, ни библиотек. Вызываются они через `python -m`, например так: 2 | 3 | ``` 4 | $ python -m emonoda.apps.hooks.rtorrent.manage_trackers --help 5 | ``` 6 | 7 | 8 | *** 9 | ### emonoda.apps.hooks.rtorrent.manage_trackers 10 | 11 | Это костылик для группового управления трекерами в rTorrent. Он позволяет включать и отключать трекеры одновременно для всех раздач. Он подключается к клиенту, выгружает список раздач, а затем производит над их трекерами необходимые операции. Был написан, чтобы отключить использование ретрекеров, которых часто нет в локальных сетях, а rTorrent ругается на то, что не может зарезольвить их хостнеймы или получить из них данные. 12 | 13 | 14 | #### Опции 15 | 16 | * **`--enable `** 17 | * Если что-то из указанных подстрок входит в имя трекера на раздаче - включить этот трекер. 18 | 19 | * **`--disable `** 20 | * Работает по той же логике, что и `--enable`, но отключает совпавшие трекеры. 21 | 22 | * **`-t, --timeout `** 23 | * Таймаут на все сетевые операции, по умолчанию - `5` секунд. 24 | 25 | * **`--client-url `** 26 | * URL для подключения к XMLRPC клиента, по умолчанию - `http://localhost/RPC2`. 27 | 28 | 29 | #### Примеры использования 30 | 31 | Включить трекеры на домене `rutracker.org` и отключить остальное: 32 | 33 | ``` 34 | $ python -m emonoda.apps.hooks.rtorrent.manage_trackers --enable rutracker.org --disable rutracker.net retracker.local 35 | ``` 36 | 37 | 38 | *** 39 | ### emonoda.apps.hooks.rtorrent.collectd_stat 40 | 41 | Это специальный скрипт для [Collectd](https://collectd.org), собирающий статистику с rTorrent. Он использует [текстовый протокол](https://collectd.org/wiki/index.php/Plain_text_protocol) для сброса этой информации в плагин [Exec](https://collectd.org/wiki/index.php/Plugin:Exec). По умолчанию считываются только метрики загрузки и отдачи, лимиты на скорость, а так же объем скачанных и отданных данных. Названия метрик говорят сами за себя. 42 | 43 | 44 | #### Опции 45 | 46 | * **`-t, --timeout `** 47 | * Таймаут на все сетевые операции, по умолчанию - `5` секунд. 48 | * **`--client-url `** 49 | * URL для подключения к XMLRPC клиента, по умолчанию - `http://localhost/RPC2`. 50 | * **`--with-dht`** 51 | * Добавляет метрики для DHT. 52 | * **`--with-summary`** 53 | * Добавляет метрики с общим количеством торрентов, с активной отдачей, загрузкой и с ошибками. 54 | * **`-n, --host `** 55 | * Хостнейм в идентификаторах всех метрик. По умолчанию - `localhost` или содержимое переменной окружения `COLLECTD_HOSTNAME`. 56 | * **`-i, --interval `** 57 | * Скрипт запускает внутри себя цикл получения новых метрик с указанной паузой между итерациями. По умолчанию - `60` секунд или содержимое переменной окружения `COLLECTD_INTERVAL`. 58 | 59 | 60 | #### Примеры использования 61 | 62 | Для включения плагина пропишите в `/etc/collectd.conf` что-то типа этого: 63 | 64 | ``` 65 | LoadPLugin exec 66 | 67 | Exec "data" "python" "-m" "emonoda.apps.hooks.rtorrent.collectd_stat" "--client-url=http://localhost/RPC2" "--with-summary" 68 | 69 | ``` 70 | 71 | Скрипт также можно запустить из консоли и посмотреть, какие метрики и как он выводит: 72 | 73 | ``` 74 | $ python -m emonoda.apps.hooks.rtorrent.collectd_stat --with-dht --with-summary 75 | PUTVAL localhost/rtorrent/gauge-dn_rate interval=60 N:22847 76 | PUTVAL localhost/rtorrent/gauge-dn_rate_limit interval=60 N:0 77 | PUTVAL localhost/rtorrent/bytes-dn_total interval=60 N:71241345665 78 | PUTVAL localhost/rtorrent/gauge-up_rate interval=60 N:11170199 79 | PUTVAL localhost/rtorrent/gauge-up_rate_limit interval=60 N:0 80 | PUTVAL localhost/rtorrent/bytes-up_total interval=60 N:4343293548954 81 | PUTVAL localhost/rtorrent/gauge-dht_active interval=60 N:1 82 | PUTVAL localhost/rtorrent/count-dht_nodes interval=60 N:191 83 | PUTVAL localhost/rtorrent/count-dht_cycle interval=60 N:434 84 | PUTVAL localhost/rtorrent/count-dht_torrents interval=60 N:0 85 | PUTVAL localhost/rtorrent/count-dht_buckets interval=60 N:25 86 | PUTVAL localhost/rtorrent/count-dht_replies_received interval=60 N:8708162 87 | PUTVAL localhost/rtorrent/count-dht_peers interval=60 N:0 88 | PUTVAL localhost/rtorrent/count-dht_peers_max interval=60 N:0 89 | PUTVAL localhost/rtorrent/count-dht_errors_caught interval=60 N:2054 90 | PUTVAL localhost/rtorrent/count-dht_errors_received interval=60 N:775079 91 | PUTVAL localhost/rtorrent/count-dht_queries_sent interval=60 N:17309084 92 | PUTVAL localhost/rtorrent/count-dht_queries_received interval=60 N:27524 93 | PUTVAL localhost/rtorrent/bytes-dht_bytes_written interval=60 N:1809635518 94 | PUTVAL localhost/rtorrent/bytes-dht_bytes_read interval=60 N:2285090405 95 | PUTVAL localhost/rtorrent/count-summary_total interval=60 N:546 96 | PUTVAL localhost/rtorrent/count-summary_dn interval=60 N:0 97 | PUTVAL localhost/rtorrent/count-summary_up interval=60 N:100 98 | PUTVAL localhost/rtorrent/count-summary_errors interval=60 N:219 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/rTorrent-system_multicall.md: -------------------------------------------------------------------------------- 1 | ### Introduction 2 | 3 | The syntax for using system.multicall to speed up the query of multiple variables. 4 | 5 | !!! info 6 | I've picked up this document from [gi-torrent wiki](https://code.google.com/p/gi-torrent). 7 | Copyright © Hans.Hasert@gmail.com 8 | 9 | 10 | *** 11 | ### Details 12 | 13 | The way to combine multiple queries is to use the `system.multicall` and specify which methods we want to call. 14 | The XML would look something like this: 15 | 16 | ```xml 17 | 18 | 19 | // "system.multicall" 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | // "methodName" 29 | // the 1st actual method to be called 30 | 31 | 32 | // "params" 33 | 34 | 35 | 36 | // list of parameters 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | // "methodName" 47 | // the 2nd actual method to be called 48 | 49 | 50 | // "params" 51 | 52 | 53 | 54 | // list of parameters 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ``` 68 | 69 | This will invoke multiple xmlrpc methods to be called, the response looks like this: 70 | 71 | 72 | ```xml 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 123245 84 | 85 | 86 | 87 | 88 | 89 | 90 | 12345 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | ``` 101 | -------------------------------------------------------------------------------- /docs/theme/partials/language.html: -------------------------------------------------------------------------------- 1 | {% macro t(key) %}{{ { 2 | "language": "ru", 3 | "edit.link.title": "Редактировать эту страницу", 4 | "footer.previous": "Предыдущая страница", 5 | "footer.next": "Следующая страница", 6 | "meta.comments": "Комментарии", 7 | "meta.source": "Исходный код", 8 | "search.languages": "en,ru", 9 | "search.placeholder": "Поиск", 10 | "search.result.placeholder": "Начните писать для начала поиска", 11 | "search.result.none": "Нет совпадений", 12 | "search.result.one": "Совпадений: 1", 13 | "search.result.other": "Совпадений: #", 14 | "source.link.title": "Перейти к исходному коду", 15 | "toc.title": "Содержание" 16 | }[key] }}{% endmacro %} 17 | -------------------------------------------------------------------------------- /docs/trackers.md: -------------------------------------------------------------------------------- 1 | ### Настройка трекеров 2 | 3 | Для того, чтобы активировать использование какого-либо трекера в [emupdate](emupdate), вам нужно включить его плагин в [конфиге](config) и, если потребуется, указать свой логин и пароль для сайта. Кроме того, плагины имеют ряд параметров, таких как прокси и таймауты, которые вы можете настроить индивидуально для каждого трекера. Включение плагина осуществляется добавлением его в секцию `trackers`, как было указано ранее. Параметры передаются внутри секции с именем плагина, например `trackers/rutracker.org/timeout=5.0`. 4 | 5 | Представленные ниже параметры доступны для всех трекерных плагинов; рутрекер используется лишь в качестве примера. 6 | 7 | * **`trackers/rutracker.org/check_fingerprint=true`** 8 | * Перед логином на трекер, **emupdate** идет на [гитхаб](https://github.com/mdevaev/emonoda/tree/master/trackers) и проверяет информацию о плагине. В ней содержится кодировка целевого сайта, кусочек текста с его главной страницы (fingerprint) и версия плагина в апстриме. Плагин трекера скачивает главную страницу сайта, а затем ищет в ней подстроку фингерпринта. Если таковой не находится, он делает вывод, что сайт заблокирован провайдером и отказывается работать дальше. Более надежным был бы метод проверки сертификатов, но на данный момент ни один из популярных трекеров не имеет нормально настроенного HTTPS. 9 | 10 | * **`trackers/rutracker.org/check_version=true`** 11 | * Этот параметр почти аналогичен предыдущей, но сравнивает локальную версию плагина с апстримовой. Если в апстриме версия выше, то плагин отказывается работать. Апстримовые версии повышаются только в том случае, если трекер меняет разметку страниц таким образом, что плагины перестают работать, а в апстриме уже есть исправление. Использование этого параметра подсказывает вам, что пора обновить **Emonoda**, чтобы плагин заработал вновь. 12 | 13 | * **`trackers/rutracker.org/proxy_url=""`** 14 | * Позволяет указать прокси для сайта. Поддерживаются HTTP-прокси и SOCKS4/5. Этот параметр можно использовать так: `trackers/rutracker.org/proxy_url=socks5://localhost:5000` (вместе с [SOCKS-проксированием по SSH](https://ru.wikibooks.org/wiki/SSH_%D1%82%D1%83%D0%BD%D0%BD%D0%B5%D0%BB%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)). Формат - `scheme://username:passwd@host:port`. Вместо `scheme` нужно указать `socks4`, `socks5` или `http`. 15 | 16 | * **`trackers/rutracker.org/retries=20`** 17 | * Количество повторов при обращении к трекеру при возникновении таймаутов, пятисоток или специфичных для сайта ошибок (например, 404 у рутрекера при скачке торрент-файла не является критической ошибкой, т.к. иногда сайт отдает неправильный бекенд за балансером, и программе нужно просто несколько раз повторить свой запрос). 18 | 19 | * **`trackers/rutracker.org/retries_sleep=1.0`** 20 | * Пауза между повторами при использовании предыдущего параметра. 21 | 22 | * **`trackers/rutracker.org/timeout=10.0`** 23 | * Таймаут на сетевые операции с трекером. 24 | 25 | * **`trackers/rutracker.org/user_agent=Mozilla/5.0`** 26 | * Плагины прикидываются браузером при работе с трекерами. Для некоторых плагинов значение по умолчанию для этого параметра отличается (например, `Googlebot/2.1` для http://rutor.org позволяет обходить DDoS-фильтр CloudFlare). 27 | 28 | Некоторые параметры применяются только для неанонимных трекеров, а так же для трекеров, к которым требуется выполнять SCRAPE-запрос. Например, `trackers/nnmclub.to/client_agent`. Здесь приведен полных их список с назначением, а так же таблица, где перечислены использующие их трекеры. 29 | 30 | !!! notice 31 | Плагин nnmclub.to с последних версий теперь тоже использует парсинг времени, а не SCRAPE-запросы. На данный момент ни один плагин не использует SCRAPE, а пример в этой документации оставлен для истории и понимания, что такое вообще бывает. 32 | 33 | * **`trackers/rutracker.org/user=""`** 34 | * Имя пользователя на сайте, обязательно для неанонимных трекеров. 35 | 36 | * **`trackers/rutracker.org/passwd=""`** 37 | * Имя пользователя на сайте, обязательно для неанонимных трекеров. 38 | 39 | * **`trackers/nnmclub.to/client_agent=rtorrent/0.9.2/0.13.2`** 40 | * На некоторых трекерах отсутствует хеш раздачи на странице, но при этом можно выполнить SCRAPE-запрос, чтобы проверить, зарегистрирована ли раздача. Если не зарегистрирована, то это значит, что пора ее обновить. Этот параметр определяет юзерагент для SCRAPE-запросов, чтобы плагин трекера мог прикинуться торрент-клиентом. 41 | 42 | * **`trackers/tr.anidub.com/timezone=""`** 43 | * Если не доступны ни хеш раздачи, ни SCRAPE, то последнее, что остается делать - парсить страницу на предмет времени обновления раздачи. Этот параметр задает таймзону сайта. Обычно плагины определяют ее самостоятельно, заходя под вашим логином на личную страницу с настройками сайта. Такие плагины сравнивают последнее время обновления торрент-файла с новой датой и на основе этого выносят решение о необходимости нового обновления. 44 | 45 | *** 46 | 47 | В таблице ниже дан список всех доступных плагинов и специфичных для них опций: 48 | 49 | | Плагин | user, passwd | client_agent | timezone | 50 | |--------|--------------|--------------|----------| 51 | | `rutracker.org` | Обязательно | | | 52 | | `nnm-club.me` | Обязательно | | Опционально | 53 | | `ipv6.nnm-club.name` | Обязательно | | Опционально | 54 | | `rutor.org` | | | | 55 | | `pravtor.ru` | Обязательно | | | 56 | | `tr.anidub.com` | Обязательно | | Опционально | 57 | | `pornolab.net` | Обязательно | | Опционально | 58 | | `booktracker.org` | Обязательно | | Опционально | 59 | | `trec.to` | Обязательно | | Опционально | 60 | | `kinozal.tv` | Обязательно | | Опционально | 61 | -------------------------------------------------------------------------------- /emonoda/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /emonoda/apps/emconfetti_tghi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2018 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import argparse 22 | 23 | from . import init 24 | from . import wrap_main 25 | from . import get_configured_log 26 | from . import get_configured_confetti 27 | 28 | 29 | # ===== Main ===== 30 | @wrap_main 31 | def main() -> None: 32 | (parent_parser, argv, config) = init() 33 | args_parser = argparse.ArgumentParser( 34 | prog="emconfetti-tghi", 35 | description="Telegram-bot helper", 36 | parents=[parent_parser], 37 | ) 38 | args_parser.add_argument("-n", "--limit", default=10, type=int) 39 | options = args_parser.parse_args(argv[1:]) 40 | 41 | with get_configured_log(config, False, sys.stdout) as log_stdout: 42 | with get_configured_log(config, False, sys.stdout) as log_stderr: 43 | confetti = get_configured_confetti( 44 | config=config, 45 | only=["telegram"], 46 | exclude=[], 47 | log=log_stderr, 48 | ) 49 | if len(confetti) == 0: 50 | raise RuntimeError("No configured telegram plugin") 51 | assert hasattr(confetti[0], "get_last_chats"), confetti[0] 52 | for (user, chat_id) in confetti[0].get_last_chats(options.limit): # type: ignore 53 | log_stdout.print("- Chat with user '{yellow}%s{reset}': %s", (user, chat_id)) 54 | 55 | 56 | if __name__ == "__main__": 57 | main() # Do the thing! 58 | -------------------------------------------------------------------------------- /emonoda/apps/emdiff.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import argparse 22 | 23 | from typing import Optional 24 | 25 | from ..plugins.clients import BaseClient 26 | 27 | from ..helpers import tcollection 28 | 29 | from ..tfile import Torrent 30 | from ..tfile import get_torrents_difference 31 | 32 | from .. import fmt 33 | 34 | from . import init 35 | from . import wrap_main 36 | from . import get_configured_log 37 | from . import get_configured_client 38 | 39 | 40 | # ===== Main ===== 41 | @wrap_main 42 | def main() -> None: 43 | (parent_parser, argv, config) = init() 44 | args_parser = argparse.ArgumentParser( 45 | prog="emdiff", 46 | description="Show a difference between two torrent files", 47 | parents=[parent_parser], 48 | ) 49 | args_parser.add_argument("-v", "--verbose", action="store_true") 50 | args_parser.add_argument("torrents", type=str, nargs=2, metavar="") 51 | options = args_parser.parse_args(argv[1:]) 52 | 53 | torrents = tcollection.find_torrents_or_hashes(config.core.torrents_dir, options.torrents) 54 | 55 | with get_configured_log(config, False, sys.stdout) as log_stdout: 56 | with get_configured_log(config, (not options.verbose), sys.stderr) as log_stderr: 57 | client: Optional[BaseClient] = None 58 | lists = [] 59 | for item in torrents: 60 | if isinstance(item, Torrent): 61 | lists.append(item.get_files()) 62 | else: # Hash 63 | if client is None: 64 | client = get_configured_client( 65 | config=config, 66 | required=True, 67 | with_customs=False, 68 | log=log_stderr, 69 | ) 70 | lists.append(client.get_files(item)) # type: ignore 71 | assert len(lists) == 2 72 | diff = get_torrents_difference(lists[0], lists[1]) 73 | log_stdout.print(*fmt.format_torrents_diff(diff, " ")) 74 | 75 | 76 | if __name__ == "__main__": 77 | main() # Do the thing! 78 | -------------------------------------------------------------------------------- /emonoda/apps/emload.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import os 22 | import argparse 23 | 24 | from typing import List 25 | from typing import Dict 26 | 27 | from ..plugins.clients import BaseClient 28 | from ..plugins.clients import WithCustoms 29 | 30 | from ..helpers import tcollection 31 | 32 | from ..tfile import Torrent 33 | 34 | from .. import fmt 35 | 36 | from . import init 37 | from . import wrap_main 38 | from . import validate_client_customs 39 | from . import get_configured_log 40 | from . import get_configured_client 41 | 42 | 43 | # ===== 44 | def make_path(path: str, tail_mode: int) -> None: 45 | try: 46 | os.makedirs(path) 47 | if tail_mode > 0: 48 | os.chmod(path, tail_mode) 49 | except FileExistsError: 50 | pass 51 | 52 | 53 | def link_data( 54 | torrent: Torrent, 55 | data_dir_path: str, 56 | link_to_path: str, 57 | mkdir_mode: int, 58 | ) -> None: 59 | 60 | mkdir_path = link_to_path = os.path.abspath(link_to_path) 61 | if torrent.is_single_file(): 62 | link_to_path = os.path.join(link_to_path, torrent.get_name()) 63 | else: 64 | mkdir_path = os.path.dirname(link_to_path) 65 | 66 | if os.path.exists(link_to_path): 67 | raise RuntimeError(f"{link_to_path}: link target already exists") 68 | 69 | make_path(mkdir_path, mkdir_mode) 70 | os.symlink(os.path.join(data_dir_path, torrent.get_name(surrogate_escape=True)), link_to_path) 71 | 72 | 73 | def load_torrents( # pylint: disable=too-many-positional-arguments 74 | torrents: List[Torrent], 75 | client: BaseClient, 76 | data_root_path: str, 77 | link_to_path: str, 78 | mkdir_mode: int, 79 | customs: Dict[str, str], 80 | ) -> None: 81 | 82 | for torrent in torrents: 83 | if client.has_torrent(torrent): 84 | raise RuntimeError(f"{torrent.get_path()}: already loaded") 85 | 86 | if not data_root_path: 87 | data_root_path = client.get_data_prefix_default() 88 | 89 | for torrent in torrents: 90 | dir_name = os.path.basename(torrent.get_path()) + ".data" 91 | data_dir_path = os.path.join(data_root_path, dir_name[0], dir_name) 92 | make_path(data_dir_path, mkdir_mode) 93 | 94 | if link_to_path: 95 | link_data(torrent, data_dir_path, link_to_path, mkdir_mode) 96 | 97 | client.load_torrent(torrent, data_dir_path) 98 | if WithCustoms in client.get_bases() and len(customs) != 0: 99 | client.set_customs(torrent, { # type: ignore 100 | key: fmt.format_now(value) 101 | for (key, value) in customs.items() 102 | }) 103 | 104 | 105 | def parse_customs(items: List[str]) -> Dict[str, str]: 106 | customs = {} 107 | for item in filter(None, map(str.strip, items)): 108 | (key, value) = map(str.strip, (item.split("=", 1) + [""])[:2]) 109 | customs[key] = value 110 | return customs 111 | 112 | 113 | # ===== Main ===== 114 | @wrap_main 115 | def main() -> None: 116 | (parent_parser, argv, config) = init() 117 | args_parser = argparse.ArgumentParser( 118 | prog="emload", 119 | description="Load torrent to client", 120 | parents=[parent_parser], 121 | ) 122 | args_parser.add_argument("-l", "--link-to", default="", metavar="") 123 | args_parser.add_argument("--set-customs", default=[], nargs="+", metavar="") 124 | args_parser.add_argument("-v", "--verbose", action="store_true") 125 | args_parser.add_argument("torrents", nargs="+", metavar="") 126 | options = args_parser.parse_args(argv[1:]) 127 | 128 | if len(options.torrents) > 1 and options.link_to: 129 | raise RuntimeError("Option -l/--link-to be used with only one torrent") 130 | 131 | customs = dict(config.emload.set_customs) 132 | customs.update(parse_customs(options.set_customs)) 133 | 134 | torrents = tcollection.find_torrents(config.core.torrents_dir, options.torrents) 135 | 136 | with get_configured_log(config, (not options.verbose), sys.stderr) as log_stderr: 137 | client: BaseClient = get_configured_client( # type: ignore 138 | config=config, 139 | required=True, 140 | with_customs=bool(customs), 141 | log=log_stderr, 142 | ) 143 | if customs: 144 | validate_client_customs(client, list(customs)) # type: ignore 145 | 146 | load_torrents( 147 | torrents=torrents, 148 | client=client, 149 | data_root_path=config.core.data_root_dir, 150 | link_to_path=options.link_to, 151 | mkdir_mode=config.emload.mkdir_mode, 152 | customs=customs, 153 | ) 154 | 155 | 156 | if __name__ == "__main__": 157 | main() # Do the thing! 158 | -------------------------------------------------------------------------------- /emonoda/apps/emrm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import argparse 22 | 23 | from ..plugins.clients import NoSuchTorrentError 24 | from ..plugins.clients import BaseClient 25 | 26 | from ..helpers import tcollection 27 | 28 | from ..tfile import Torrent 29 | 30 | from . import init 31 | from . import wrap_main 32 | from . import get_configured_log 33 | from . import get_configured_client 34 | 35 | 36 | # ===== Main ===== 37 | @wrap_main 38 | def main() -> None: 39 | (parent_parser, argv, config) = init() 40 | args_parser = argparse.ArgumentParser( 41 | prog="emrm", 42 | description="Remove a torrent from client", 43 | parents=[parent_parser], 44 | ) 45 | args_parser.add_argument("-v", "--verbose", action="store_true") 46 | args_parser.add_argument("torrents", type=str, nargs="+", metavar="") 47 | options = args_parser.parse_args(argv[1:]) 48 | 49 | with get_configured_log(config, (not options.verbose), sys.stdout) as log_stdout: 50 | with get_configured_log(config, (not options.verbose), sys.stderr) as log_stderr: 51 | client: BaseClient = get_configured_client( # type: ignore 52 | config=config, 53 | required=True, 54 | with_customs=False, 55 | log=log_stderr, 56 | ) 57 | 58 | hashes = [] 59 | for item in tcollection.find_torrents_or_hashes(config.core.torrents_dir, options.torrents): 60 | torrent_hash = (item.get_hash() if isinstance(item, Torrent) else item) 61 | try: 62 | hashes.append((torrent_hash, client.get_file_name(torrent_hash))) 63 | except NoSuchTorrentError: 64 | log_stderr.error("No such torrent: {yellow}%s{reset}", (torrent_hash,)) 65 | 66 | if len(hashes) != 0: 67 | log_stderr.info("Removed:") 68 | for (torrent_hash, name) in hashes: 69 | client.remove_torrent(torrent_hash) 70 | log_stdout.print("{yellow}%s{reset} -- %s", (torrent_hash, name)) 71 | 72 | 73 | if __name__ == "__main__": 74 | main() # Do the thing! 75 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/rtorrent/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/rtorrent/collectd_stat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import os 22 | import socket 23 | import xmlrpc.client 24 | import operator 25 | import math 26 | import time 27 | import argparse 28 | 29 | from typing import List 30 | from typing import Dict 31 | 32 | 33 | # ===== 34 | def get_summary(server: xmlrpc.client.ServerProxy, hashes: List[str]) -> Dict[str, int]: 35 | mapping = ( 36 | ("peers_accounted", "leechers"), 37 | ("is_hash_checking", "is_checking"), 38 | ("completed_chunks", "completed_chunks"), 39 | ("chunks_hashed", "hashed_chunks"), 40 | ("size_chunks", "size_chunks"), 41 | ("message", "msg"), 42 | ) 43 | mc = xmlrpc.client.MultiCall(server) 44 | for torrent_hash in hashes: 45 | for (method_name, _) in mapping: 46 | getattr(mc.d, method_name)(torrent_hash) 47 | rows = list(mc()) # type: ignore 48 | rows = list( 49 | dict(zip(map(operator.itemgetter(1), mapping), rows[count:count + len(mapping)])) 50 | for count in range(0, len(rows), len(mapping)) 51 | ) 52 | 53 | summary = dict.fromkeys(["total", "dn", "up", "errors"], 0) 54 | for row in rows: 55 | if row["leechers"]: 56 | summary["up"] += 1 57 | chunks_processing = (row["completed_chunks"] if row["is_checking"] else row["hashed_chunks"]) 58 | done = math.floor(chunks_processing / row["size_chunks"] * 1000) 59 | if done != 1000: 60 | summary["dn"] += 1 61 | if len(row["msg"]) and row["msg"] != "Tracker: [Tried all trackers.]": 62 | summary["errors"] += 1 63 | summary["total"] = len(hashes) 64 | return summary 65 | 66 | 67 | def print_stat(client_url: str, host: str, interval: float, with_dht: bool, with_summary: bool) -> None: 68 | server = xmlrpc.client.ServerProxy(client_url) 69 | while True: 70 | mc = xmlrpc.client.MultiCall(server) 71 | mc.throttle.global_down.rate() # Download rate 72 | mc.throttle.global_down.max_rate() # Download rate limit 73 | mc.throttle.global_down.total() # Downloaded 74 | mc.throttle.global_up.rate() # Upload rate 75 | mc.throttle.global_up.max_rate() # Upload rate limit 76 | mc.throttle.global_up.total() # Uploaded 77 | mc.dht.statistics() 78 | mc.download_list() 79 | values = list(mc()) # type: ignore 80 | 81 | metrics = [ 82 | ("gauge-dn_rate", values[0]), 83 | ("gauge-dn_rate_limit", values[1]), 84 | ("bytes-dn_total", values[2]), 85 | ("gauge-up_rate", values[3]), 86 | ("gauge-up_rate_limit", values[4]), 87 | ("bytes-up_total", values[5]), 88 | ] 89 | if with_dht: 90 | metrics += [ 91 | ("gauge-dht_active", values[6]["active"]), 92 | ("count-dht_nodes", values[6]["nodes"]), 93 | ("count-dht_cycle", values[6]["cycle"]), 94 | ("count-dht_torrents", values[6]["torrents"]), 95 | ("count-dht_buckets", values[6]["buckets"]), 96 | ("count-dht_replies_received", values[6]["replies_received"]), 97 | ("count-dht_peers", values[6]["peers"]), 98 | ("count-dht_peers_max", values[6]["peers_max"]), 99 | ("count-dht_errors_caught", values[6]["errors_caught"]), 100 | ("count-dht_errors_received", values[6]["errors_received"]), 101 | ("count-dht_queries_sent", values[6]["queries_sent"]), 102 | ("count-dht_queries_received", values[6]["queries_received"]), 103 | ("bytes-dht_bytes_written", values[6]["bytes_written"]), 104 | ("bytes-dht_bytes_read", values[6]["bytes_read"]), 105 | ] 106 | if with_summary: 107 | summary = get_summary(server, values[7]) 108 | metrics += [ 109 | ("count-summary_total", summary["total"]), 110 | ("count-summary_dn", summary["dn"]), 111 | ("count-summary_up", summary["up"]), 112 | ("count-summary_errors", summary["errors"]), 113 | ] 114 | 115 | for (key, value) in metrics: 116 | print(f"PUTVAL {host}/rtorrent/{key} interval={interval} N:{value}", flush=True) 117 | time.sleep(interval) 118 | 119 | 120 | # ===== Main ===== 121 | def main() -> None: 122 | args_parser = argparse.ArgumentParser(description="Prints collectd stat in plaintext protocol") 123 | args_parser.add_argument("--with-dht", action="store_true") 124 | args_parser.add_argument("--with-summary", action="store_true") 125 | args_parser.add_argument("-n", "--host", default=os.getenv("COLLECTD_HOSTNAME", "localhost"), metavar="") 126 | args_parser.add_argument("-i", "--interval", default=os.getenv("COLLECTD_INTERVAL", "60"), type=float, metavar="") 127 | args_parser.add_argument("-t", "--timeout", default=5.0, type=float, metavar="") 128 | args_parser.add_argument("--client-url", default="http://localhost/RPC2", metavar="") 129 | 130 | options = args_parser.parse_args(sys.argv[1:]) 131 | socket.setdefaulttimeout(options.timeout) 132 | try: 133 | print_stat( 134 | client_url=options.client_url, 135 | host=options.host, 136 | interval=options.interval, 137 | with_dht=options.with_dht, 138 | with_summary=options.with_summary, 139 | ) 140 | except (SystemExit, KeyboardInterrupt): 141 | pass 142 | 143 | 144 | if __name__ == "__main__": 145 | main() # Do the thing! 146 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/rtorrent/manage_trackers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import sys 21 | import socket 22 | import xmlrpc.client 23 | import argparse 24 | 25 | from typing import List 26 | 27 | 28 | # ===== 29 | def manage_trackers(client_url: str, to_enable: List[str], to_disable: List[str]) -> None: 30 | server = xmlrpc.client.ServerProxy(client_url) 31 | 32 | multicall = xmlrpc.client.MultiCall(server) 33 | hashes = server.download_list() 34 | for t_hash in hashes: # type: ignore 35 | multicall.t.multicall(t_hash, "", "t.is_enabled=", "t.url=") 36 | trackers = list(multicall()) # type: ignore 37 | 38 | actions = dict.fromkeys(set(to_enable or ()), 1) 39 | actions.update(dict.fromkeys(set(to_disable or ()), 0)) 40 | 41 | multicall = xmlrpc.client.MultiCall(server) 42 | for (count, t_hash) in enumerate(hashes): # type: ignore 43 | for (index, (is_enabled, url)) in enumerate(trackers[count]): 44 | for (pattern, action) in actions.items(): 45 | if pattern in url and action != is_enabled: 46 | multicall.t.is_enabled.set(t_hash, index, action) 47 | print(url, pattern, action) 48 | continue 49 | multicall() 50 | 51 | 52 | # ===== Main ===== 53 | def main() -> None: 54 | args_parser = argparse.ArgumentParser(description="Manage trackers (rtorrent only)") 55 | args_parser.add_argument("--enable", nargs="+", metavar="") 56 | args_parser.add_argument("--disable", nargs="+", metavar="") 57 | args_parser.add_argument("-t", "--timeout", default=5.0, type=float, metavar="") 58 | args_parser.add_argument("--client-url", default="http://localhost/RPC2", metavar="") 59 | 60 | options = args_parser.parse_args(sys.argv[1:]) 61 | socket.setdefaulttimeout(options.timeout) 62 | manage_trackers( 63 | client_url=options.client_url, 64 | to_enable=options.enable, 65 | to_disable=options.disable, 66 | ) 67 | 68 | 69 | if __name__ == "__main__": 70 | main() # Do the thing! 71 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/transmission/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /emonoda/apps/hooks/transmission/redownload.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | Copyright (C) 2015 Vitaly Lipatov 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | """ 19 | 20 | 21 | import sys 22 | import os 23 | import argparse 24 | 25 | from ....plugins.clients.transmission import Plugin as TransmissionClient 26 | 27 | from ... import init 28 | from ... import get_configured_log 29 | from ... import get_configured_client 30 | 31 | 32 | # ===== Main ===== 33 | def main() -> None: 34 | (parent_parser, argv, config) = init() 35 | args_parser = argparse.ArgumentParser( 36 | prog="emhook-transmission-redownload", 37 | description="Start torrents in client", 38 | parents=[parent_parser], 39 | ) 40 | args_parser.add_argument("-v", "--verbose", action="store_true") 41 | options = args_parser.parse_args(argv[1:]) 42 | 43 | with get_configured_log(config, False, sys.stdout) as log_stdout: 44 | with get_configured_log(config, (not options.verbose), sys.stderr) as log_stderr: 45 | client: TransmissionClient = get_configured_client( # type: ignore 46 | config=config, 47 | required=True, 48 | with_customs=False, 49 | log=log_stderr, 50 | ) 51 | if "transmission" not in client.PLUGIN_NAMES: 52 | raise RuntimeError("Only for Transmission") 53 | 54 | for obj in client._client.get_torrents(arguments=[ # pylint: disable=protected-access 55 | "id", 56 | "name", 57 | "hashString", 58 | "status", 59 | "error", 60 | "errorString", 61 | "downloadDir", 62 | ]): 63 | if obj.error == 3 and obj.errorString.startswith("No data") and obj.status == "stopped": 64 | log_stdout.print("[%s] %s: status=%s; error=%s (%s)", (obj.id, obj.name, obj.status, obj.error, obj.errorString)) 65 | 66 | for (path, attr) in client.get_files(obj.hashString).items(): 67 | if not attr.is_dir: 68 | file_path = os.path.join(obj.downloadDir, path) 69 | dir_path = os.path.dirname(file_path) 70 | if not os.path.exists(dir_path): 71 | os.makedirs(dir_path) 72 | log_stdout.print(" Creating dir: %s", (dir_path,)) 73 | if not os.path.exists(file_path): 74 | log_stdout.print(" Creating file: %s", (file_path,)) 75 | open(file_path, "w").close() # pylint: disable=consider-using-with 76 | 77 | try: 78 | client._client.start_torrent(obj.id) # pylint: disable=protected-access 79 | except KeyError as err: 80 | log_stdout.print("Start error: %s", (str(err),)) 81 | else: 82 | log_stdout.print("Start verify and start torrent DONE") 83 | 84 | 85 | if __name__ == "__main__": 86 | main() # Do the thing! 87 | -------------------------------------------------------------------------------- /emonoda/fmt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import math 21 | import datetime 22 | 23 | from typing import Tuple 24 | from typing import Generator 25 | 26 | from .tfile import TorrentsDiff 27 | 28 | from . import tools 29 | 30 | 31 | # ===== 32 | _UNITS = tuple(zip( 33 | ("bytes", "kB", "MB", "GB", "TB", "PB"), 34 | (0, 0, 1, 2, 2, 2), 35 | )) 36 | 37 | 38 | # ===== 39 | def format_size(size: int) -> str: 40 | if size > 1: 41 | exponent = min(int(math.log(size, 1024)), len(_UNITS) - 1) 42 | quotient = float(size) / 1024 ** exponent 43 | (unit, decimals) = _UNITS[exponent] 44 | result = ("{:.%sf} {}" % (decimals)).format(quotient, unit) # pylint: disable=consider-using-f-string 45 | elif size == 0: 46 | result = "0 bytes" 47 | elif size == 1: 48 | result = "1 byte" 49 | else: 50 | raise ValueError("size must be >= 0") 51 | return result 52 | 53 | 54 | def format_progress(value: int, limit: int) -> Tuple[str, Tuple[int, int]]: 55 | return (("{cyan}%%%dd/{yellow}" % (len(str(limit)))) + "%d{reset}", (value, limit)) # pylint: disable=consider-using-f-string 56 | 57 | 58 | def format_progress_bar(value: int, limit: int, length: int) -> Tuple[str, Tuple[int, int]]: 59 | (progress, placeholders) = format_progress(value, limit) 60 | if value != limit: 61 | color = "red" 62 | else: 63 | color = "green" 64 | fill = int(value / (limit or 1) * length) 65 | pb = "{{cyan}}{percent:5.1f}%% {{{color}}}[{bar}{{reset}}{fill}{{{color}}}]{{reset}} {progress}".format( 66 | percent=value / (limit or 1) * 100, 67 | color=color, 68 | bar="\u2588" * fill, 69 | fill="\u00b7" * (length - fill), 70 | progress=progress, 71 | ) # https://en.wikipedia.org/wiki/Code_page_437 72 | return (pb, placeholders) 73 | 74 | 75 | def format_now(text: str) -> str: 76 | return datetime.datetime.now().strftime(text) 77 | 78 | 79 | def format_torrents_diff(diff: TorrentsDiff, prefix: str) -> Tuple[str, Tuple[str, ...]]: 80 | lines = [] 81 | placeholders: Tuple[str, ...] = () 82 | for (sign, color, items) in [ 83 | ("+", "green", diff.added), 84 | ("-", "red", diff.removed), 85 | ("~", "cyan", diff.modified), 86 | ("?", "yellow", diff.type_modified), 87 | ]: 88 | for item in tools.sorted_paths(items): 89 | lines.append("%s{" + color + "}%s{reset} %s") 90 | placeholders += (prefix, sign, item) 91 | return ("\n".join(lines), placeholders) 92 | 93 | 94 | # ===== 95 | def make_fan() -> Generator[str, None, None]: 96 | fan = 0 97 | while True: 98 | if fan < 3: 99 | fan += 1 100 | else: 101 | fan = 0 102 | yield "/-\\|"[fan] 103 | -------------------------------------------------------------------------------- /emonoda/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /emonoda/helpers/datacache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import os 21 | import pickle 22 | 23 | from typing import Dict 24 | from typing import NamedTuple 25 | 26 | from ..plugins.clients import BaseClient 27 | 28 | from ..tfile import TorrentEntryAttrs 29 | 30 | from ..cli import Log 31 | 32 | from . import tcollection 33 | 34 | 35 | # ===== 36 | class CacheEntryAttrs(NamedTuple): 37 | files: Dict[str, TorrentEntryAttrs] 38 | prefix: str 39 | 40 | 41 | class TorrentsCache(NamedTuple): 42 | version: int 43 | torrents: Dict[str, CacheEntryAttrs] 44 | 45 | 46 | def get_cache( # pylint: disable=too-many-positional-arguments 47 | cache_path: str, 48 | client: BaseClient, 49 | files_from_client: bool, 50 | force_rebuild: bool, 51 | torrents_dir_path: str, 52 | name_filter: str, 53 | log: Log, 54 | ) -> TorrentsCache: 55 | 56 | cache = _read(cache_path, force_rebuild, log) 57 | if _update(cache, client, files_from_client, torrents_dir_path, name_filter, log): 58 | _write(cache, cache_path, log) 59 | return cache 60 | 61 | 62 | # ===== 63 | def _read(path: str, force_rebuild: bool, log: Log) -> TorrentsCache: 64 | fallback = TorrentsCache( 65 | version=0, 66 | torrents={}, 67 | ) 68 | if force_rebuild or not os.path.exists(path): 69 | return fallback 70 | 71 | log.info("Reading the cache from {cyan}%s{reset} ...", (path,)) 72 | with open(path, "rb") as cache_file: 73 | try: 74 | cache_low = pickle.load(cache_file) 75 | if cache_low["version"] != fallback.version: 76 | return fallback 77 | else: 78 | return TorrentsCache( 79 | version=cache_low["version"], 80 | torrents=pickle.loads(cache_low["torrents_pk"]), 81 | ) 82 | except (KeyError, ValueError, pickle.UnpicklingError): 83 | log.error("Can't unpickle cache file - ingored: {red}%s{reset}", (path,)) 84 | return fallback 85 | 86 | 87 | def _write(cache: TorrentsCache, path: str, log: Log) -> None: 88 | os.makedirs(os.path.dirname(path), exist_ok=True) 89 | log.info("Writing the cache to {cyan}%s{reset} ...", (path,)) 90 | with open(path, "wb") as cache_file: 91 | pickle.dump({ 92 | "version": cache.version, 93 | "torrents_pk": pickle.dumps(cache.torrents), 94 | }, cache_file) 95 | 96 | 97 | def _update( # pylint: disable=too-many-positional-arguments 98 | cache: TorrentsCache, 99 | client: BaseClient, 100 | files_from_client: bool, 101 | path: str, 102 | name_filter: str, 103 | log: Log, 104 | ) -> bool: 105 | 106 | log.info("Fetching all hashes from client ...") 107 | hashes = client.get_hashes() 108 | 109 | log.info("Validating the cache ...") 110 | 111 | # --- Old --- 112 | to_remove = sorted(set(cache.torrents).difference(hashes)) 113 | if len(to_remove) != 0: 114 | for torrent_hash in to_remove: 115 | cache.torrents.pop(torrent_hash) 116 | log.info("Removed {magenta}%d{reset} obsolete hashes from cache", (len(to_remove),)) 117 | 118 | # --- New --- 119 | to_add = sorted(set(hashes).difference(cache.torrents)) 120 | added = 0 121 | if len(to_add) != 0: 122 | torrents = tcollection.by_hash(tcollection.load_from_dir(path, name_filter, True, log)) 123 | 124 | if not log.isatty(): 125 | log.info("Adding files for the new {yellow}%d{reset} hashes ...", (len(to_add),)) 126 | 127 | for torrent_hash in log.progress( 128 | to_add, 129 | ("Adding files ...", ()), 130 | ("Added {magenta}%d{reset} new hashes from client", (lambda: added,)) 131 | ): 132 | torrent = torrents.get(torrent_hash) 133 | if torrent is not None: 134 | cache.torrents[torrent_hash] = CacheEntryAttrs( 135 | files=(client.get_files(torrent) if files_from_client else torrent.get_files()), 136 | prefix=client.get_data_prefix(torrent), 137 | ) 138 | added += 1 139 | else: 140 | log.error("Not cached - missing torrent for: {red}%s{reset} -- %s", 141 | (torrent_hash, client.get_file_name(torrent_hash))) 142 | 143 | if not log.isatty() and added != 0: 144 | log.info("Added {magenta}%d{reset} new hashes from client", (added,)) 145 | 146 | return bool(len(to_remove) or added) 147 | -------------------------------------------------------------------------------- /emonoda/helpers/surprise.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import traceback 21 | 22 | from typing import List 23 | 24 | from ..cli import Log 25 | 26 | from ..plugins.confetti import ResultsType 27 | from ..plugins.confetti import BaseConfetti 28 | 29 | 30 | # ===== 31 | def deploy_surprise( 32 | source: str, 33 | results: ResultsType, 34 | confetti: List[BaseConfetti], 35 | log: Log, 36 | ) -> bool: 37 | 38 | ok = True 39 | for sender in confetti: 40 | log.info("Processing confetti {blue}%s{reset} ...", (sender.PLUGIN_NAMES[0],), one_line=True) 41 | try: 42 | sender.send_results(source, results) 43 | log.info("Confetti {blue}%s{reset} {green}processed{reset}", (sender.PLUGIN_NAMES[0],)) 44 | except Exception as err: 45 | log.error("Can't process {red}%s{reset}: {red}%s{reset}(%s)", (sender.PLUGIN_NAMES[0], type(err).__name__, err)) 46 | log.print("%s", ("\n".join("\t" + row for row in traceback.format_exc().strip().split("\n")),)) 47 | ok = False 48 | if not ok: 49 | log.error("One or more confetti failed") 50 | return ok 51 | -------------------------------------------------------------------------------- /emonoda/helpers/tcollection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import os 21 | import fnmatch 22 | 23 | from typing import List 24 | from typing import Dict 25 | from typing import Optional 26 | from typing import Union 27 | 28 | from ..tfile import Torrent 29 | from ..tfile import is_torrent_hash 30 | 31 | from ..cli import Log 32 | 33 | 34 | # ===== 35 | def load_from_dir(path: str, name_filter: str, precalculate_hashes: bool, log: Log) -> Dict[str, Optional[Torrent]]: 36 | if not log.isatty(): 37 | log.info("Loading torrents from {cyan}%s/{yellow}%s{reset} ...", (path, name_filter)) 38 | 39 | torrents: Dict[str, Optional[Torrent]] = {} 40 | for name in log.progress( 41 | sorted(name for name in os.listdir(path) if fnmatch.fnmatch(name, name_filter)), 42 | ("Loading torrents from {cyan}%s/{yellow}%s{reset}", (path, name_filter)), 43 | ("Loaded {magenta}%d{reset} torrents from {cyan}%s/{yellow}%s{reset}", (lambda: len(torrents), path, name_filter)), 44 | ): 45 | file_path = os.path.abspath(os.path.join(path, name)) 46 | try: 47 | torrents[name] = Torrent(path=file_path) 48 | if precalculate_hashes: 49 | torrents[name].get_hash() # type: ignore 50 | except ValueError: 51 | log.error("Found broken torrent: {cyan}%s/{yellow}%s{reset}", (path, name)) 52 | torrents[name] = None 53 | except Exception: 54 | log.error("Can't process torrent: {cyan}%s/{yellow}%s{reset}", (path, name)) 55 | raise 56 | 57 | if not log.isatty(): 58 | log.info("Loaded {magenta}%d{reset} torrents from {cyan}%s/{yellow}%s{reset}", 59 | (len(torrents), path, name_filter)) 60 | return torrents 61 | 62 | 63 | def by_hash(torrents: Dict[str, Optional[Torrent]]) -> Dict[str, Torrent]: 64 | return { 65 | torrent.get_hash(): torrent 66 | for torrent in filter(None, torrents.values()) 67 | } 68 | 69 | 70 | def by_hash_with_dups(torrents: Dict[str, Optional[Torrent]]) -> Dict[str, List[Torrent]]: 71 | with_dups: Dict[str, List[Torrent]] = {} # noqa: E701 72 | for torrent in filter(None, torrents.values()): 73 | with_dups.setdefault(torrent.get_hash(), []) 74 | with_dups[torrent.get_hash()].append(torrent) 75 | return with_dups 76 | 77 | 78 | # ===== 79 | def find_torrents(path: str, items: str) -> List[Torrent]: 80 | return [_find_torrent_or_hash(path, item, False) for item in items] # type: ignore 81 | 82 | 83 | def find_torrents_or_hashes(path: str, items: str) -> List[Union[Torrent, str]]: 84 | return [_find_torrent_or_hash(path, item, True) for item in items] 85 | 86 | 87 | def _find_torrent_or_hash(path: str, item: str, pass_hash: bool) -> Union[Torrent, str]: 88 | if os.path.exists(item): 89 | return Torrent(path=os.path.abspath(item)) 90 | if os.path.sep not in item: 91 | full_path = os.path.join(path, item) 92 | if os.path.exists(full_path): 93 | return Torrent(path=full_path) 94 | if pass_hash and is_torrent_hash(item.strip()): 95 | return item.strip() 96 | raise RuntimeError(f"Can't find torrent: {item}") 97 | -------------------------------------------------------------------------------- /emonoda/optconf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import json 21 | 22 | from typing import Tuple 23 | from typing import List 24 | from typing import Dict 25 | from typing import Callable 26 | from typing import Optional 27 | from typing import Any 28 | 29 | 30 | # ===== 31 | def build_raw_from_options(options: List[str]) -> Dict[str, Any]: 32 | raw: Dict[str, Any] = {} 33 | for option in options: 34 | key: str 35 | (key, value) = (option.split("=", 1) + [None])[:2] # type: ignore 36 | if len(key.strip()) == 0: 37 | raise ValueError(f"Empty option key (required 'key=value' instead of {option!r})") 38 | if value is None: 39 | raise ValueError(f"No value for key {key!r}") 40 | 41 | section = raw 42 | subs = list(filter(None, map(str.strip, key.split("/")))) 43 | for sub in subs[:-1]: 44 | section.setdefault(sub, {}) 45 | section = section[sub] 46 | section[subs[-1]] = _parse_value(value) 47 | return raw 48 | 49 | 50 | def _parse_value(value: str) -> Any: 51 | value = value.strip() 52 | if ( 53 | not value.isdigit() 54 | and value not in ["true", "false", "null"] 55 | and not value.startswith(("{", "[", "\"")) 56 | ): 57 | value = f"\"{value}\"" 58 | return json.loads(value) 59 | 60 | 61 | # ===== 62 | class Section(dict): 63 | def __init__(self) -> None: 64 | dict.__init__(self) 65 | self.__meta: Dict[str, Dict[str, Any]] = {} 66 | 67 | def _set_meta(self, name: str, secret: bool, default: Any, help: str) -> None: # pylint: disable=redefined-builtin 68 | self.__meta[name] = { 69 | "secret": secret, 70 | "default": default, 71 | "help": help, 72 | } 73 | 74 | def _is_secret(self, name: str) -> bool: 75 | return self.__meta[name]["secret"] 76 | 77 | def _get_default(self, name: str) -> Any: 78 | return self.__meta[name]["default"] 79 | 80 | def _get_help(self, name: str) -> str: 81 | return self.__meta[name]["help"] 82 | 83 | def __getattribute__(self, name: str) -> Any: 84 | if name in self: 85 | return self[name] 86 | else: # For pickling 87 | return dict.__getattribute__(self, name) 88 | 89 | 90 | class Option: 91 | __type = type 92 | 93 | def __init__(self, default: Any, help: str, type: Optional[Callable[[Any], Any]]=None) -> None: # pylint: disable=redefined-builtin 94 | self.default = default 95 | self.help = help 96 | self.type: Callable[[Any], Any] = (type or (self.__type(default) if default is not None else str)) # type: ignore 97 | 98 | def __repr__(self) -> str: 99 | return "".format(self=self) 100 | 101 | 102 | class SecretOption(Option): 103 | pass 104 | 105 | 106 | # ===== 107 | def make_config(raw: Dict[str, Any], scheme: Dict[str, Any], _keys: Tuple[str, ...]=()) -> Section: 108 | if not isinstance(raw, dict): 109 | raise ValueError(f"The node '{'/'.join(_keys) or '/'}' must be a dictionary") 110 | 111 | config = Section() 112 | for (key, option) in scheme.items(): 113 | full_key = _keys + (key,) 114 | full_name = "/".join(full_key) 115 | 116 | if isinstance(option, Option): 117 | value = raw.get(key, option.default) 118 | try: 119 | value = option.type(value) 120 | except Exception: 121 | raise ValueError(f"Invalid value {value!r} for key {full_name!r}") 122 | config[key] = value 123 | config._set_meta( # pylint: disable=protected-access 124 | name=key, 125 | secret=isinstance(option, SecretOption), 126 | default=option.default, 127 | help=option.help, 128 | ) 129 | elif isinstance(option, dict): 130 | config[key] = make_config(raw.get(key, {}), option, full_key) 131 | else: 132 | raise RuntimeError(f"Incorrect scheme definition for key {full_name!r}:" 133 | f" the value is {type(option)}, not dict or [Secret]Option()") 134 | return config 135 | -------------------------------------------------------------------------------- /emonoda/optconf/converters.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typing import List 4 | from typing import Dict 5 | from typing import Sequence 6 | from typing import Union 7 | from typing import Any 8 | 9 | 10 | # ===== 11 | def as_string_list(values: Union[str, Sequence]) -> List[str]: 12 | if isinstance(values, str): 13 | values = [values] 14 | return list(map(str, values)) 15 | 16 | 17 | def as_string_list_choices(values: Union[str, Sequence], choices: List[str]) -> List[str]: 18 | values = as_string_list(values) 19 | invalid = sorted(set(values).difference(choices)) 20 | if invalid: 21 | raise ValueError(f"Incorrect values: {invalid!r}") 22 | return values 23 | 24 | 25 | def as_key_value(values: Union[str, Dict[str, Any]]) -> Dict[str, str]: 26 | if isinstance(values, dict): 27 | return values 28 | return dict( 29 | tuple(map(str.strip, (item.split("=", 1) + [""])[:2])) # type: ignore 30 | for item in as_string_list(values) 31 | if len(item.split("=", 1)[0].strip()) != 0 32 | ) 33 | 34 | 35 | def as_path(value: str) -> str: 36 | return os.path.normpath(os.path.abspath(os.path.expanduser(value))) 37 | 38 | 39 | def as_paths_list(values: Sequence[str]) -> List[str]: 40 | if isinstance(values, str): 41 | values = [values] 42 | return list(map(as_path, values)) 43 | 44 | 45 | def as_path_or_empty(value: str) -> str: 46 | if value: 47 | return as_path(value) 48 | return "" 49 | 50 | 51 | def as_8int(value: int) -> int: 52 | return int(str(value), 8) 53 | -------------------------------------------------------------------------------- /emonoda/optconf/dumper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | # pylint: skip-file 21 | # infinite recursion 22 | 23 | import operator 24 | 25 | from typing import Tuple 26 | from typing import List 27 | from typing import Any 28 | 29 | import yaml 30 | 31 | from . import Section 32 | 33 | 34 | # ===== 35 | def make_config_dump(config: Section) -> str: 36 | return "\n".join(_inner_make_dump(config)) 37 | 38 | 39 | def _inner_make_dump(config: Section, _path: Tuple[str, ...]=()) -> List[str]: 40 | lines = [] 41 | for (key, value) in sorted(config.items(), key=operator.itemgetter(0)): 42 | indent = len(_path) * " " 43 | if isinstance(value, Section): 44 | lines.append("{}{}:".format(indent, key)) 45 | lines += _inner_make_dump(value, _path + (key,)) 46 | lines.append("") 47 | else: 48 | default = config._get_default(key) # pylint: disable=protected-access 49 | comment = config._get_help(key) # pylint: disable=protected-access 50 | print_value = (None if config._is_secret(key) else value) # pylint: disable=protected-access 51 | if default == value: 52 | lines.append("{}{}: {} # {}".format(indent, key, _make_yaml(print_value), comment)) 53 | else: 54 | lines.append("{}# {}: {} # {}".format(indent, key, _make_yaml(default), comment)) 55 | if config._is_secret(key): 56 | lines.append("{}# Note: value is secret and has been hidden".format(indent)) 57 | lines.append("{}{}: {}".format(indent, key, _make_yaml(print_value))) 58 | return lines 59 | 60 | 61 | def _make_yaml(value: Any) -> str: 62 | return yaml.dump(value, allow_unicode=True).replace("\n...\n", "").strip() 63 | -------------------------------------------------------------------------------- /emonoda/optconf/loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import os 21 | 22 | from typing import IO 23 | from typing import Any 24 | 25 | import yaml 26 | import yaml.loader 27 | import yaml.nodes 28 | 29 | 30 | # ===== 31 | def load_file(file_path: str) -> Any: 32 | with open(file_path) as yaml_file: 33 | try: 34 | return yaml.load(yaml_file, _YamlLoader) 35 | except Exception: 36 | # Reraise internal exception as standard ValueError and show the incorrect file 37 | raise ValueError(f"Incorrect YAML syntax in file {file_path!r}") 38 | 39 | 40 | # ===== 41 | class _YamlLoader(yaml.loader.Loader): # pylint: disable=too-many-ancestors 42 | def __init__(self, yaml_file: IO) -> None: 43 | yaml.loader.Loader.__init__(self, yaml_file) 44 | self.__root = os.path.dirname(yaml_file.name) 45 | 46 | def include(self, node: yaml.nodes.Node) -> str: 47 | # Logger which supports include-files 48 | file_path = os.path.join(self.__root, str(self.construct_scalar(node))) # type: ignore 49 | return load_file(file_path) 50 | 51 | 52 | _YamlLoader.add_constructor("!include", _YamlLoader.include) # pylint: disable=no-member 53 | -------------------------------------------------------------------------------- /emonoda/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import importlib 21 | import functools 22 | import os 23 | 24 | from typing import Tuple 25 | from typing import List 26 | from typing import Dict 27 | from typing import Optional 28 | from typing import Type 29 | from typing import Any 30 | 31 | from ..optconf import Option 32 | 33 | 34 | # ===== 35 | class BasePlugin: 36 | PLUGIN_NAMES: List[str] = [] 37 | 38 | def __init__(self, **_: Any) -> None: 39 | pass 40 | 41 | @classmethod 42 | def get_options(cls) -> Dict[str, Option]: 43 | return {} 44 | 45 | @classmethod 46 | def get_bases(cls) -> "List[Type[BasePlugin]]": 47 | return cls.__get_bases(cls.__mro__) 48 | 49 | def _init_bases(self, **kwargs: Any) -> None: 50 | assert self.PLUGIN_NAMES 51 | for parent in self.__get_bases(self.__class__.__mro__): 52 | parent.__init__(self, **kwargs) # type: ignore # pylint: disable=unnecessary-dunder-call 53 | 54 | @classmethod 55 | def _get_merged_options(cls, params: Optional[Dict[str, Option]]=None) -> Dict[str, Option]: 56 | merged: Dict[str, Option] = {} 57 | for parent in cls.__get_bases(cls.__mro__): 58 | merged.update(parent.get_options()) 59 | merged.update(params or {}) 60 | return merged 61 | 62 | @staticmethod 63 | def __get_bases(mro: Tuple[Type, ...]) -> "List[Type[BasePlugin]]": 64 | return [ 65 | cls for cls in mro 66 | if issubclass(cls, BasePlugin) 67 | ][1:] 68 | 69 | 70 | @functools.lru_cache() 71 | def get_classes(sub: str) -> Dict[str, Type[BasePlugin]]: 72 | classes: Dict[str, Type[BasePlugin]] = {} # noqa: E701 73 | sub_path = os.path.join(os.path.dirname(__file__), sub) 74 | for file_name in os.listdir(sub_path): 75 | if not file_name.startswith("__"): 76 | if file_name.endswith(".py"): 77 | module_name = file_name[:-3] 78 | elif os.path.exists(os.path.join(sub_path, file_name, "__init__.py")): 79 | module_name = file_name 80 | else: 81 | continue 82 | module = importlib.import_module(f"emonoda.plugins.{sub}.{module_name}") 83 | plugin_class = getattr(module, "Plugin") 84 | for plugin_name in plugin_class.PLUGIN_NAMES: 85 | classes[plugin_name] = plugin_class 86 | return classes 87 | -------------------------------------------------------------------------------- /emonoda/plugins/clients/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import os 21 | 22 | from typing import Tuple 23 | from typing import List 24 | from typing import Dict 25 | from typing import Callable 26 | from typing import Union 27 | from typing import Type 28 | from typing import Any 29 | 30 | from ...tfile import TorrentEntryAttrs 31 | from ...tfile import Torrent 32 | 33 | from .. import BasePlugin 34 | from .. import get_classes 35 | 36 | 37 | # ===== 38 | class NoSuchTorrentError(Exception): 39 | pass 40 | 41 | 42 | # ===== 43 | def hash_or_torrent(method: Callable) -> Callable: 44 | def wrap(self: "BaseClient", torrent: Union[Torrent, str], *args: Any, **kwargs: Any) -> Any: 45 | torrent_hash = (torrent.get_hash() if isinstance(torrent, Torrent) else torrent) 46 | return method(self, torrent_hash, *args, **kwargs) 47 | return wrap 48 | 49 | 50 | def check_torrent_accessible(method: Callable) -> Callable: 51 | def wrap(self: "BaseClient", torrent: Torrent, prefix: str="") -> Any: 52 | path = torrent.get_path() 53 | assert path is not None, "Required Torrent() with local file" 54 | open(path, "rb").close() # Check accessible file # pylint: disable=consider-using-with 55 | if prefix: 56 | os.listdir(prefix) # Check accessible prefix 57 | return method(self, torrent, prefix) 58 | return wrap 59 | 60 | 61 | def build_files(prefix: str, flist: List[Tuple[str, int]]) -> Dict[str, TorrentEntryAttrs]: 62 | files: Dict[str, TorrentEntryAttrs] = {} 63 | for (path, size) in flist: 64 | path_list = path.split(os.path.sep) 65 | name = None 66 | for index in range(len(path_list)): 67 | name = os.path.join(prefix, os.path.sep.join(path_list[0:index + 1])) 68 | files[name] = TorrentEntryAttrs.dir() 69 | assert name is not None 70 | files[name] = TorrentEntryAttrs.file(size) 71 | return files 72 | 73 | 74 | class BaseClient(BasePlugin): 75 | def __init__(self, **_: Any) -> None: # pylint: disable=super-init-not-called 76 | pass 77 | 78 | @hash_or_torrent 79 | def start_torrent(self, torrent_hash: str) -> None: 80 | raise NotImplementedError 81 | 82 | @hash_or_torrent 83 | def stop_torrent(self, torrent_hash: str) -> None: 84 | raise NotImplementedError 85 | 86 | @check_torrent_accessible 87 | def load_torrent(self, torrent: Torrent, prefix: str) -> None: 88 | raise NotImplementedError 89 | 90 | @hash_or_torrent 91 | def remove_torrent(self, torrent_hash: str) -> None: 92 | raise NotImplementedError 93 | 94 | @hash_or_torrent 95 | def has_torrent(self, torrent_hash: str) -> bool: 96 | raise NotImplementedError 97 | 98 | def get_hashes(self) -> List[str]: 99 | raise NotImplementedError 100 | 101 | @hash_or_torrent 102 | def get_data_prefix(self, torrent_hash: str) -> str: 103 | raise NotImplementedError 104 | 105 | def get_data_prefix_default(self) -> str: 106 | raise NotImplementedError 107 | 108 | # ===== 109 | 110 | @hash_or_torrent 111 | def get_full_path(self, torrent_hash: str) -> str: 112 | raise NotImplementedError 113 | 114 | @hash_or_torrent 115 | def get_file_name(self, torrent_hash: str) -> str: 116 | raise NotImplementedError 117 | 118 | @hash_or_torrent 119 | def get_files(self, torrent_hash: str) -> Dict[str, TorrentEntryAttrs]: 120 | raise NotImplementedError 121 | 122 | 123 | class WithCustoms(BaseClient): 124 | def __init__(self, **_: Any) -> None: # pylint: disable=super-init-not-called 125 | pass 126 | 127 | @classmethod 128 | def get_custom_keys(cls) -> List[str]: 129 | raise NotImplementedError 130 | 131 | @hash_or_torrent 132 | def set_customs(self, torrent_hash: str, customs: Dict[str, str]) -> None: 133 | raise NotImplementedError 134 | 135 | @hash_or_torrent 136 | def get_customs(self, torrent_hash: str, keys: List[str]) -> Dict[str, str]: 137 | raise NotImplementedError 138 | 139 | 140 | # ===== 141 | def get_client_class(name: str) -> Type[BaseClient]: 142 | return get_classes("clients")[name] # type: ignore 143 | -------------------------------------------------------------------------------- /emonoda/plugins/clients/ktorrent.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import os 21 | 22 | from typing import List 23 | from typing import Dict 24 | from typing import Any 25 | 26 | from ...optconf import Option 27 | 28 | from ...tfile import TorrentEntryAttrs 29 | from ...tfile import Torrent 30 | 31 | from . import BaseClient 32 | from . import NoSuchTorrentError 33 | from . import hash_or_torrent 34 | from . import check_torrent_accessible 35 | from . import build_files 36 | 37 | try: 38 | import dbus # pylint: disable=import-error 39 | except ImportError: 40 | dbus = None 41 | 42 | 43 | # ===== 44 | class Plugin(BaseClient): 45 | PLUGIN_NAMES = ["ktorrent"] 46 | 47 | def __init__(self, service: str, **kwargs: Any) -> None: # pylint: disable=super-init-not-called 48 | self._init_bases(**kwargs) 49 | 50 | if dbus is None: 51 | raise RuntimeError("Required module dbus") 52 | 53 | self.__service = service 54 | 55 | self.__bus = dbus.SessionBus() 56 | self.__core = self.__bus.get_object(self.__service, "/core") 57 | self.__settings = self.__bus.get_object(self.__service, "/settings") 58 | 59 | if self.__settings.useSaveDir(): 60 | raise RuntimeError("Turn off the 'path by default' in KTorrent settings") 61 | 62 | @classmethod 63 | def get_options(cls) -> Dict[str, Option]: 64 | return cls._get_merged_options({ 65 | "service": Option(default="org.kde.ktorrent", help="D-Bus service, use 'org.ktorrent.ktorrent' for old client"), 66 | }) 67 | 68 | # ===== 69 | 70 | @hash_or_torrent 71 | def start_torrent(self, torrent_hash: str) -> None: 72 | self.__get_torrent_obj(torrent_hash) # XXX: raise NoSuchTorrentError if torrent does not exist 73 | self.__core.start(torrent_hash) 74 | 75 | @hash_or_torrent 76 | def stop_torrent(self, torrent_hash: str) -> None: 77 | self.__get_torrent_obj(torrent_hash) 78 | self.__core.stop(torrent_hash) 79 | 80 | @check_torrent_accessible 81 | def load_torrent(self, torrent: Torrent, prefix: str) -> None: 82 | self.__settings.setLastSaveDir(prefix) 83 | self.__core.loadSilently(torrent.get_path(), "") 84 | 85 | @hash_or_torrent 86 | def remove_torrent(self, torrent_hash: str) -> None: 87 | self.__get_torrent_obj(torrent_hash) 88 | self.__core.remove(torrent_hash, False) 89 | 90 | @hash_or_torrent 91 | def has_torrent(self, torrent_hash: str) -> bool: 92 | try: 93 | self.__get_torrent_obj(torrent_hash) 94 | return True 95 | except NoSuchTorrentError: 96 | return False 97 | 98 | def get_hashes(self) -> List[str]: 99 | return list(map(str.lower, self.__core.torrents())) 100 | 101 | @hash_or_torrent 102 | def get_data_prefix(self, torrent_hash: str) -> str: 103 | return str(self.__get_torrent_obj(torrent_hash).dataDir()) 104 | 105 | def get_data_prefix_default(self) -> str: 106 | return str(self.__settings.saveDir()) 107 | 108 | # ===== 109 | 110 | @hash_or_torrent 111 | def get_full_path(self, torrent_hash: str) -> str: 112 | return str(self.__get_torrent_obj(torrent_hash).pathOnDisk()) 113 | 114 | @hash_or_torrent 115 | def get_file_name(self, torrent_hash: str) -> str: 116 | return str(self.__get_torrent_obj(torrent_hash).name()) 117 | 118 | @hash_or_torrent 119 | def get_files(self, torrent_hash: str) -> Dict[str, TorrentEntryAttrs]: 120 | torrent_obj = self.__get_torrent_obj(torrent_hash) 121 | count = torrent_obj.numFiles() 122 | name = str(torrent_obj.name()) 123 | if count == 0: # Single file 124 | flist = [(name, int(torrent_obj.totalSize()))] 125 | else: 126 | flist = [ 127 | ( 128 | os.path.join(name, str(torrent_obj.filePath(dbus.UInt32(index)))), 129 | int(torrent_obj.fileSize(dbus.UInt32(index))), 130 | ) 131 | for index in range(count) 132 | ] 133 | return build_files("", flist) 134 | 135 | # ===== 136 | 137 | def __get_torrent_obj(self, torrent_hash: str) -> Any: 138 | if torrent_hash not in self.get_hashes(): 139 | raise NoSuchTorrentError("Unknown torrent hash") 140 | try: 141 | torrent_obj = self.__bus.get_object(self.__service, "/torrent/" + torrent_hash) 142 | assert str(torrent_obj.infoHash()) == torrent_hash 143 | return torrent_obj 144 | except dbus.exceptions.DBusException as err: 145 | if err.get_dbus_name() == "org.freedesktop.DBus.Error.UnknownObject": 146 | raise NoSuchTorrentError("Unknown torrent hash") 147 | raise 148 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/atom.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | atom.py -- produce atom feed file of recent torrent updates 6 | Copyright (C) 2017 Pavel Pletenev 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see . 20 | """ 21 | 22 | 23 | import os 24 | import getpass 25 | import pwd 26 | import grp 27 | import traceback 28 | import time 29 | 30 | from typing import List 31 | from typing import Dict 32 | from typing import Any 33 | 34 | import yaml 35 | 36 | from ...optconf import Option 37 | from ...optconf.converters import as_path 38 | from ...optconf.converters import as_path_or_empty 39 | 40 | from . import ResultsType 41 | from . import BaseConfetti 42 | # from . import templated 43 | 44 | 45 | # ===== 46 | def get_uid(user: str) -> int: 47 | return pwd.getpwnam(user)[2] 48 | 49 | 50 | def get_gid(group: str) -> int: 51 | return grp.getgrnam(group)[2] 52 | 53 | 54 | def get_user_groups(user: str) -> List[int]: 55 | groups = [ 56 | group.gr_name 57 | for group in grp.getgrall() 58 | if user in group.gr_mem 59 | ] 60 | gid = pwd.getpwnam(user).pw_gid 61 | groups.append(grp.getgrgid(gid).gr_name) 62 | return [grp.getgrnam(group).gr_gid for group in groups] 63 | 64 | 65 | class UserError(Exception): 66 | pass 67 | 68 | 69 | # ===== 70 | class Plugin(BaseConfetti): # pylint: disable=too-many-instance-attributes 71 | PLUGIN_NAMES = ["atom"] 72 | 73 | def __init__( # pylint: disable=super-init-not-called,too-many-positional-arguments 74 | self, 75 | history_path: str, 76 | path: str, 77 | url: str, 78 | user: str, 79 | group: str, 80 | template: str, 81 | html: bool, 82 | **kwargs: Any, 83 | ) -> None: 84 | 85 | self._init_bases(**kwargs) 86 | 87 | self.__history_path = history_path 88 | self.__path = path 89 | self.__url = url 90 | self.__uid = (get_uid(user) if user else -1) 91 | self.__gid = (get_gid(group) if group else -1) 92 | if self.__gid > -1: 93 | if self.__gid not in get_user_groups(getpass.getuser()): 94 | raise UserError( 95 | f"I wouldn't be able to edit {path} with current user if I chown it for" 96 | f" uid={self.__uid} and gid={self.__gid}" 97 | ) 98 | self.__template_path = template 99 | self.__html = html 100 | 101 | @classmethod 102 | def get_options(cls) -> Dict[str, Option]: 103 | return cls._get_merged_options({ 104 | "history_path": Option(default="emonoda_history.yaml", type=as_path, help="History path"), 105 | "path": Option(default="atom.xml", type=as_path, help="Atom path"), 106 | "url": Option(default="http://localhost/", help="Feed server url"), 107 | "user": Option(default="", help="Server user"), 108 | "group": Option(default="", help="Server user group"), 109 | "template": Option(default="", type=as_path_or_empty, help="Mako template file name"), 110 | "html": Option(default=True, help="HTML or plaintext feed") 111 | }) 112 | 113 | def send_results(self, source: str, results: ResultsType) -> None: 114 | if len(results["affected"]) != 0: 115 | results_set: List[ResultsType] = [] 116 | try: 117 | with open(self.__history_path) as history_file: 118 | results_set = yaml.safe_load(history_file) 119 | except Exception: 120 | traceback.print_exc() 121 | results["ctime"] = time.time() # type: ignore 122 | results_set.insert(0, results) 123 | results_set = results_set[:20] 124 | with open(self.__path, "w") as atom_file: 125 | atom_file.write(Plugin.templated( 126 | name=(self.__template_path if self.__template_path else "atom.{ctype}.{source}.mako").format( 127 | ctype=("html" if self.__html else "plain"), 128 | source=source, 129 | ), 130 | built_in=(not self.__template_path), 131 | source=source, 132 | results_set=results_set, 133 | settings={"url": self.__url}, 134 | )) 135 | os.chmod(self.__path, 0o664) 136 | os.chown(self.__path, self.__uid, self.__gid) 137 | with open(self.__history_path, "w") as history_file: 138 | history_file.write(yaml.dump(results_set)) 139 | del results["ctime"] 140 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/pushover.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import urllib.parse 21 | 22 | from typing import List 23 | from typing import Dict 24 | from typing import Any 25 | 26 | from ...optconf import Option 27 | from ...optconf import SecretOption 28 | from ...optconf.converters import as_string_list 29 | from ...optconf.converters import as_path_or_empty 30 | 31 | from . import STATUSES 32 | from . import ResultsType 33 | from . import WithWeb 34 | from . import WithStatuses 35 | # from . import templated 36 | 37 | 38 | # ===== 39 | class Plugin(WithWeb, WithStatuses): 40 | PLUGIN_NAMES = ["pushover"] 41 | 42 | def __init__( # pylint: disable=super-init-not-called,too-many-positional-arguments 43 | self, 44 | user_key: str, 45 | api_key: str, 46 | devices: List[str], 47 | title: str, 48 | template: str, 49 | **kwargs: Any, 50 | ) -> None: 51 | 52 | self._init_bases(**kwargs) 53 | self._init_opener() 54 | 55 | self.__user_key = user_key 56 | self.__api_key = api_key 57 | self.__devices = devices 58 | self.__title = title 59 | self.__template_path = template 60 | 61 | @classmethod 62 | def get_options(cls) -> Dict[str, Option]: 63 | return cls._get_merged_options({ 64 | "user_key": SecretOption(default="CHANGE_ME", help="User key"), 65 | "api_key": SecretOption(default="CHANGE_ME", help="API/Application key"), 66 | "devices": Option(default=[], type=as_string_list, help="Devices list (empty for all)"), 67 | "title": Option(default="Emonoda ({source})", help="Message title"), 68 | "template": Option(default="", type=as_path_or_empty, help="Mako template file name"), 69 | }) 70 | 71 | def send_results(self, source: str, results: ResultsType) -> None: 72 | for status in self._statuses: 73 | for (file_name, result) in results[status].items(): 74 | post = { 75 | "token": self.__api_key, 76 | "user": self.__user_key, 77 | "html": "1", 78 | "title": self.__title.format(source=source), 79 | "message": Plugin.templated( 80 | name=(self.__template_path if self.__template_path else "pushover.{source}.mako").format(source=source), 81 | built_in=(not self.__template_path), 82 | source=source, 83 | file_name=file_name, 84 | status=status, 85 | status_msg=STATUSES[status], 86 | result=result, 87 | ), 88 | } 89 | if self.__devices: 90 | post["device"] = ",".join(self.__devices) 91 | self._read_url( 92 | url="https://api.pushover.net/1/messages.json", 93 | data=urllib.parse.urlencode(post).encode("utf-8"), 94 | ) 95 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/telegrem.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2018 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import urllib.parse 21 | import json 22 | 23 | from typing import Tuple 24 | from typing import List 25 | from typing import Dict 26 | from typing import Any 27 | 28 | from ...optconf import Option 29 | from ...optconf import SecretOption 30 | from ...optconf.converters import as_string_list 31 | from ...optconf.converters import as_path_or_empty 32 | 33 | from . import STATUSES 34 | from . import ResultsType 35 | from . import WithWeb 36 | from . import WithStatuses 37 | # from . import templated 38 | 39 | 40 | # ===== 41 | class Plugin(WithWeb, WithStatuses): 42 | PLUGIN_NAMES = ["telegram"] 43 | 44 | _SITE_RETRY_CODES = [429, 500, 502, 503] 45 | 46 | def __init__( # pylint: disable=super-init-not-called 47 | self, 48 | token: str, 49 | chats: List[str], 50 | template: str, 51 | **kwargs: Any, 52 | ) -> None: 53 | 54 | self._init_bases(**kwargs) 55 | self._init_opener() 56 | 57 | self.__token = token 58 | self.__chats = chats 59 | self.__template_path = template 60 | 61 | @classmethod 62 | def get_options(cls) -> Dict[str, Option]: 63 | return cls._get_merged_options({ 64 | "token": SecretOption(default="CHANGE_ME", help="Bot token"), 65 | "chats": SecretOption(default=[], type=as_string_list, help="Chats ids"), 66 | "template": Option(default="", type=as_path_or_empty, help="Mako template file name"), 67 | }) 68 | 69 | def send_results(self, source: str, results: ResultsType) -> None: 70 | messages = [ 71 | Plugin.templated( 72 | name=(self.__template_path if self.__template_path else "telegram.{source}.mako").format(source=source), 73 | built_in=(not self.__template_path), 74 | source=source, 75 | file_name=file_name, 76 | status=status, 77 | status_msg=STATUSES[status], 78 | result=result, 79 | ) 80 | for status in self._statuses 81 | for (file_name, result) in results[status].items() 82 | ] 83 | for chat_id in self.__chats: 84 | for msg in messages: 85 | self._read_url( 86 | url=f"https://api.telegram.org/bot{self.__token}/sendMessage", 87 | data=urllib.parse.urlencode({ 88 | "chat_id": chat_id, 89 | "text": msg, 90 | "parse_mode": "html", 91 | "disable_web_page_preview": True, 92 | }).encode("utf-8"), 93 | ) 94 | 95 | def get_last_chats(self, limit: int) -> List[Tuple[str, str]]: # XXX: Only for emonoda.apps.emconfetti_tghi 96 | last_chats: List[Tuple[str, str]] = [] 97 | for update in json.loads(self._read_url( 98 | url=f"https://api.telegram.org/bot{self.__token}/getUpdates?limit={limit}", 99 | ).decode("utf-8"))["result"]: 100 | if "edited_message" in update: 101 | update["message"] = update["edited_message"] 102 | if "text" in update["message"]: # Only text messages 103 | user = update["message"]["from"].get("username", "") 104 | chat_id = str(update["message"]["chat"]["id"]) 105 | last_chats.append((user, chat_id)) 106 | return last_chats 107 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/atom.html.emupdate.mako: -------------------------------------------------------------------------------- 1 | 2 | <%! 3 | from html import escape as esc 4 | from datetime import datetime 5 | from emonoda.tools import sorted_paths 6 | %> 7 | 8 | Emonoda Update! 9 | Updates of torrents by emonoda! 10 | ${datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")} 11 | ${settings["url"]}feed/atom/ 12 | 13 | 14 | emonoda 15 | % for results in results_set: 16 | 17 | 18 | emonoda 19 | ${settings["url"]} 20 | 21 | <![CDATA[Emonoda update!]]> 22 | emonoda_update_${results["ctime"]} 23 | ${datetime.fromtimestamp(results["ctime"]).strftime("%Y-%m-%dT%H:%M:%SZ")} 24 | ${datetime.fromtimestamp(results["ctime"]).strftime("%Y-%m-%dT%H:%M:%SZ")} 25 |
You have ${len(results["affected"])} new torrents
26 | 27 |
28 |

• • • You have ${len(results["affected"])} new torrents:

29 | 30 | % for (file_name, result) in results["affected"].items(): 31 | 32 | 33 | 51 | 52 | % endfor 53 |
34 | ${esc(file_name)} (from ${result.tracker.PLUGIN_NAMES[0]}) 35 | 36 | % for (sign, color, field) in [ \ 37 | ("+", "green", "added"), \ 38 | ("-", "red", "removed"), \ 39 | ("~", "teal", "modified"), \ 40 | ("?", "orange", "type_modified"), \ 41 | ]: 42 | % for item in sorted_paths(getattr(result.diff, field)): 43 | 44 | 45 | 46 | 47 | % endfor 48 | % endfor 49 |
${sign}${esc(item)}
50 |
54 |

55 |

• • • Extra summary:

56 | 57 | % for (msg, field) in [ \ 58 | ("Updated", "affected"), \ 59 | ("Passed", "passed"), \ 60 | ("Not in client", "not_in_client"), \ 61 | ("Unknown", "unknown"), \ 62 | ("Invalid torrents", "invalid"), \ 63 | ("Tracker errors", "tracker_error"), \ 64 | ("Unhandled errors", "unhandled_error"), \ 65 | ]: 66 | 67 | 68 | 69 | 70 | % endfor 71 |
${msg}:${len(results[field])}
72 |
73 |
74 |
75 | % endfor 76 |
77 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/atom.plain.emupdate.mako: -------------------------------------------------------------------------------- 1 | 2 | <%! 3 | from datetime import datetime 4 | from emonoda.tools import sorted_paths 5 | %> 6 | 7 | Emonoda Update! 8 | Updates of torrents by emonoda! 9 | ${datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")} 10 | ${settings["url"]}feed/atom/ 11 | 12 | 13 | emonoda 14 | % for results in results_set: 15 | 16 | 17 | emonoda 18 | ${settings["url"]} 19 | 20 | <![CDATA[Emonoda update!]]> 21 | emonoda_update_${results["ctime"]} 22 | ${datetime.fromtimestamp(results["ctime"]).strftime("%Y-%m-%dT%H:%M:%SZ")} 23 | ${datetime.fromtimestamp(results["ctime"]).strftime("%Y-%m-%dT%H:%M:%SZ")} 24 | You have ${len(results["affected"])} new torrents 25 | 26 | % for (file_name, result) in results["affected"].items(): 27 | ${file_name} (from ${result.torrent.get_comment()}): 28 | % for (sign, field) in [ \ 29 | ("+", "added"), \ 30 | ("-", "removed"), \ 31 | ("~", "modified"), \ 32 | ("?", "type_modified"), \ 33 | ]: 34 | % for item in sorted_paths(getattr(result.diff, field)): 35 | ${sign} ${item} 36 | % endfor 37 | % endfor 38 | % endfor 39 | === Extra summary: 40 | % for (msg, field) in [ \ 41 | ("Updated: ", "affected"), \ 42 | ("Passed: ", "passed"), \ 43 | ("Not in client: ", "not_in_client"), \ 44 | ("Unknown: ", "unknown"), \ 45 | ("Invalid torrents: ", "invalid"), \ 46 | ("Tracker errors: ", "tracker_error"), \ 47 | ("Unhandled errors: ", "unhandled_error"), \ 48 | ]: 49 | ${msg} ${len(results[field])} 50 | % endfor 51 | 52 | 53 | % endfor 54 | 55 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/email.html.emupdate.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from html import escape as esc 3 | from emonoda.tools import sorted_paths 4 | from emonoda.plugins.confetti import STATUSES 5 | %> 6 | % if len(results["affected"]) != 0: 7 |

• • • You have ${len(results["affected"])} changed torrents:

8 | 9 | % for (file_name, result) in results["affected"].items(): 10 | 11 | 12 | 30 | 31 | % endfor 32 |
13 | ${esc(file_name)} (from ${result.tracker.PLUGIN_NAMES[0]}) 14 | 15 | % for (sign, color, field) in [ \ 16 | ("+", "green", "added"), \ 17 | ("-", "red", "removed"), \ 18 | ("~", "teal", "modified"), \ 19 | ("?", "orange", "type_modified"), \ 20 | ]: 21 | % for item in sorted_paths(getattr(result.diff, field)): 22 | 23 | 24 | 25 | 26 | % endfor 27 | % endfor 28 |
${sign}${item}
29 |
33 | % endif 34 | % for status in statuses: 35 | % if status != "affected" and len(results[status]) != 0: 36 |
37 |

• • • ${STATUSES[status]} (${len(results[status])})

38 | 39 | % for (file_name, result) in results[status].items(): 40 | 41 | 42 | 56 | 57 | % endfor 58 |
43 | % if status == "invalid": 44 | ${esc(file_name)} 45 | % else: 46 | ${esc(file_name)} (from ${esc(result.torrent.get_comment())}) 47 | % endif 48 | % if status in ["tracker_error", "unhandled_error"]: 49 | 50 | 51 | 52 | 53 |
${esc(result.err_name)}(${esc(result.err_msg)})
54 | % endif 55 |
59 | % endif 60 | % endfor 61 |
62 |

• • • Extra summary:

63 | 64 | % for (msg, field) in [ \ 65 | ("Updated", "affected"), \ 66 | ("Passed", "passed"), \ 67 | ("Not in client", "not_in_client"), \ 68 | ("Unknown", "unknown"), \ 69 | ("Invalid torrents", "invalid"), \ 70 | ("Tracker errors", "tracker_error"), \ 71 | ("Unhandled errors", "unhandled_error"), \ 72 | ]: 73 | 74 | 75 | 76 | 77 | % endfor 78 |
${msg}:${len(results[field])}
79 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/email.plain.emupdate.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from emonoda.tools import sorted_paths 3 | from emonoda.plugins.confetti import STATUSES 4 | %> 5 | % if len(results["affected"]) != 0: 6 | === You have ${len(results["affected"])} changed torrents: 7 | % for (file_name, result) in results["affected"].items(): 8 | ${file_name} (from ${result.torrent.get_comment()}): 9 | % for (sign, field) in [ \ 10 | ("+", "added"), \ 11 | ("-", "removed"), \ 12 | ("~", "modified"), \ 13 | ("?", "type_modified"), \ 14 | ]: 15 | % for item in sorted_paths(getattr(result.diff, field)): 16 | ${sign} ${item} 17 | % endfor 18 | % endfor 19 | % endfor 20 | % endif 21 | % for status in statuses: 22 | % if status != "affected" and len(results[status]) != 0: 23 | 24 | === ${STATUSES[status]} (${len(results[status])}): 25 | % for (file_name, result) in results[status].items(): 26 | % if status == "invalid": 27 | ${file_name} 28 | % else: 29 | ${file_name} (from ${result.torrent.get_comment()}) 30 | % endif 31 | % if status in ["tracker_error", "unhandled_error"]: 32 | ${result.err_name}(${result.err_msg}) 33 | % endif 34 | % endfor 35 | % endif 36 | % endfor 37 | 38 | === Extra summary: 39 | % for (msg, field) in [ \ 40 | ("Updated: ", "affected"), \ 41 | ("Passed: ", "passed"), \ 42 | ("Not in client: ", "not_in_client"), \ 43 | ("Unknown: ", "unknown"), \ 44 | ("Invalid torrents: ", "invalid"), \ 45 | ("Tracker errors: ", "tracker_error"), \ 46 | ("Unhandled errors: ", "unhandled_error"), \ 47 | ]: 48 | ${msg} ${len(results[field])} 49 | % endfor 50 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/pushover.emupdate.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from html import escape as esc 3 | %> 4 | % if status == "affected": 5 | ${status_msg}: ${esc(file_name)} 6 | % else: 7 | ${status_msg}: ${esc(file_name)} 8 | % endif 9 | % if status != "invalid": 10 | *** ${esc(result.torrent.get_name())} 11 | % if status == "tracker_error": 12 | ${esc(result.err_name)}(${esc(result.err_msg)}) 13 | % endif 14 | % endif 15 | -------------------------------------------------------------------------------- /emonoda/plugins/confetti/templates/telegram.emupdate.mako: -------------------------------------------------------------------------------- 1 | <%! 2 | from html import escape as esc 3 | from emonoda.tools import sorted_paths 4 | %> 5 | ${status_msg}: ${esc(file_name)} 6 | % if status != "invalid": 7 | *** ${esc(result.torrent.get_name())} 8 | % if status == "affected": 9 | 10 | % for (sign, field) in [ \ 11 | ("+", "added"), \ 12 | ("-", "removed"), \ 13 | ("~", "modified"), \ 14 | ("?", "type_modified"), \ 15 | ]: 16 | % for item in sorted_paths(getattr(result.diff, field)): 17 | ${sign} ${esc(item)} 18 | % endfor 19 | % endfor 20 | % elif status == "tracker_error": 21 | ${esc(result.err_name)}(${esc(result.err_msg)}) 22 | % endif 23 | % endif 24 | -------------------------------------------------------------------------------- /emonoda/plugins/trackers/booktracker_org.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emonoda -- A set of tools to organize and manage your torrents 3 | Copyright (C) 2015 Devaev Maxim 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | import re 21 | 22 | from datetime import datetime 23 | 24 | from typing import Dict 25 | from typing import Any 26 | 27 | from ...optconf import Option 28 | 29 | from ...tfile import Torrent 30 | 31 | from . import WithLogin 32 | from . import WithCheckTime 33 | from . import WithFetchByDownloadId 34 | from . import WithStat 35 | 36 | 37 | # ===== 38 | class Plugin(WithLogin, WithCheckTime, WithFetchByDownloadId, WithStat): 39 | PLUGIN_NAMES = ["booktracker.org"] 40 | 41 | _SITE_VERSION = 2 42 | 43 | _SITE_FINGERPRINT_URL = "https://booktracker.org" 44 | _SITE_FINGERPRINT_TEXT = "" 45 | 46 | _COMMENT_REGEXP = re.compile(r"https?://booktracker\.org(:443)?/viewtopic\.php\?p=(?P\d+)") 47 | 48 | _TIMEZONE_URL = "https://booktracker.org/profile.php?mode=editprofile" 49 | _TIMEZONE_REGEXP = re.compile(r"