├── .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 |
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 |
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 | ${sign}
45 | ${esc(item)}
46 |
47 | % endfor
48 | % endfor
49 |
50 |
51 |
52 | % endfor
53 |
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 | ${msg}:
68 | ${len(results[field])}
69 |
70 | % endfor
71 |
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 |
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 |
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 | ${sign}
24 | ${item}
25 |
26 | % endfor
27 | % endfor
28 |
29 |
30 |
31 | % endfor
32 |
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 |
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 | ${esc(result.err_name)}(${esc(result.err_msg)})
52 |
53 |
54 | % endif
55 |
56 |
57 | % endfor
58 |
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 | ${msg}:
75 | ${len(results[field])}
76 |
77 | % endfor
78 |
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"(?PGMT [+-] [\d\.]+)[\s<\(]")
50 | _TIMEZONE_PREFIX = "Etc/"
51 |
52 | _DOWNLOAD_ID_URL = "https://booktracker.org/viewtopic.php?p={torrent_id}"
53 | _DOWNLOAD_ID_REGEXP = re.compile(r"\d+)\" class=\"\">")
54 | _DOWNLOAD_URL = "https://booktracker.org/download.php?id={download_id}"
55 |
56 | _STAT_URL = _DOWNLOAD_ID_URL
57 | _STAT_OK_REGEXP = _DOWNLOAD_ID_REGEXP
58 | _STAT_SEEDERS_REGEXP = re.compile(r"Раздают:\s+(?P\d+) ·")
59 | _STAT_LEECHERS_REGEXP = re.compile(r"Качают:\s+(?P\d+) ")
60 |
61 | # =====
62 |
63 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
64 | self._init_bases(**kwargs)
65 | self._init_opener(with_cookies=True)
66 |
67 | @classmethod
68 | def get_options(cls) -> Dict[str, Option]:
69 | return cls._get_merged_options()
70 |
71 | def fetch_time(self, torrent: Torrent) -> int:
72 | torrent_id = self._assert_match(torrent)
73 | date = self._assert_logic_re_search(
74 | regexp=re.compile(r"Зарегистрирован \s*\[ "
75 | r"(\d\d\d\d-\d\d-\d\d \d\d:\d\d) ]"),
76 | text=self._decode(self._read_url(f"https://booktracker.org/viewtopic.php?p={torrent_id}")),
77 | msg="Upload date not found",
78 | ).group(1)
79 | date += " " + datetime.now(self._tzinfo).strftime("%z")
80 | upload_time = int(datetime.strptime(date, "%Y-%m-%d %H:%M %z").strftime("%s"))
81 | return upload_time
82 |
83 | def login(self) -> None:
84 | self._login_using_post(
85 | url="https://booktracker.org/login.php",
86 | post={
87 | "login_username": self._encode(self._user),
88 | "login_password": self._encode(self._passwd),
89 | "login": self._encode("Вход"),
90 | },
91 | ok_text=f"{self._user} [
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 . import nnm_club_me
23 |
24 |
25 | # =====
26 | class Plugin(nnm_club_me.Plugin):
27 | PLUGIN_NAMES = [
28 | "ipv6.nnm-club.name",
29 | "ipv6.nnm-club.me",
30 | "ipv6.nnmclub.to",
31 | ]
32 |
33 | _NNM_DOMAIN = PLUGIN_NAMES[0]
34 |
35 | _SITE_VERSION = 1
36 | _SITE_FINGERPRINT_URL = f"http://{_NNM_DOMAIN}"
37 |
38 | _COMMENT_REGEXP = re.compile(r"http://ipv6\.(nnm-club\.(me|ru|name|tv|lib)|nnmclub\.to)"
39 | r"/forum/viewtopic\.php\?p=(?P\d+)")
40 |
41 | _TORRENT_SCRAPE_URL = f"http://bt.{_NNM_DOMAIN}:2710/scrape.php?info_hash={{scrape_hash}}"
42 |
43 | _DOWNLOAD_ID_URL = f"http://{_NNM_DOMAIN}/forum/viewtopic.php?p={{torrent_id}}"
44 | _DOWNLOAD_URL = f"http://{_NNM_DOMAIN}//forum/download.php?id={{download_id}}"
45 |
46 | _STAT_URL = _DOWNLOAD_ID_URL
47 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/kinozal_tv.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 WithFetchByTorrentId
34 | from . import WithStat
35 |
36 |
37 | # =====
38 | class Plugin(WithLogin, WithCheckTime, WithFetchByTorrentId, WithStat):
39 | PLUGIN_NAMES = ["kinozal.tv"]
40 |
41 | _SITE_VERSION = 0
42 | _SITE_ENCODING = "cp1251"
43 |
44 | _SITE_FINGERPRINT_URL = "http://kinozal.tv/"
45 | _SITE_FINGERPRINT_TEXT = "Торрент трекер Кинозал.ТВ "
46 |
47 | _COMMENT_REGEXP = re.compile(r"http://kinozal\.tv/details\.php\?id=(?P\d+)")
48 |
49 | _TIMEZONE_URL = "http://kinozal.tv/my.php"
50 | _TIMEZONE_REGEXP = re.compile(r"-?\d{1,3})\" selected")
51 | _TIMEZONE_PREFIX = "Etc/GMT"
52 |
53 | _DOWNLOAD_URL = "http://dl.kinozal.tv/download.php?id={torrent_id}"
54 | _DOWNLOAD_PAYLOAD = b""
55 |
56 | _STAT_URL = "http://kinozal.tv/details.php?id={torrent_id}"
57 | _STAT_OK_REGEXP = re.compile(r" Раздают(?P\d+) ")
59 | _STAT_LEECHERS_REGEXP = re.compile(r"href=\"#\">Раздают(?P\d+) ")
60 |
61 | # =====
62 |
63 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
64 | self._init_bases(**kwargs)
65 | self._init_opener(with_cookies=True)
66 |
67 | @classmethod
68 | def get_options(cls) -> Dict[str, Option]:
69 | return cls._get_merged_options()
70 |
71 | def init_tzinfo(self) -> None:
72 | timezone = None
73 | page = self._decode(self._read_url(self._TIMEZONE_URL))
74 | delta_match = self._TIMEZONE_REGEXP.search(page)
75 | if delta_match:
76 | timezone_gmt_number = (int(delta_match.group("delta")) + 180) // 60 # Moscow Timezone GMT+3 -> 3 * 60 = 180
77 | if timezone_gmt_number > 0:
78 | timezone = self._TIMEZONE_PREFIX + "+" + str(timezone_gmt_number)
79 | else:
80 | timezone = self._TIMEZONE_PREFIX + str(timezone_gmt_number)
81 | self._tzinfo = self._select_tzinfo(timezone)
82 |
83 | def fetch_time(self, torrent: Torrent) -> int:
84 | torrent_id = self._assert_match(torrent)
85 | date_match = self._assert_logic_re_search(
86 | regexp=re.compile(r"(Обновлен|Залит)"
87 | r"(\d{1,2}) ([А-Яа-я]{3,8}) (\d{4}) в (\d{2}:\d{2}) "),
88 | text=self._decode(self._read_url(f"http://kinozal.tv/details.php?id={torrent_id}")),
89 | msg="Upload date not found",
90 | )
91 | date_str = " ".join([
92 | date_match.group(2), # Day
93 | {
94 | "января": "01",
95 | "февраля": "02",
96 | "марта": "03",
97 | "апреля": "04",
98 | "мая": "05",
99 | "июня": "06",
100 | "июля": "07",
101 | "августа": "08",
102 | "сентября": "09",
103 | "октября": "10",
104 | "ноября": "11",
105 | "декабря": "12",
106 | }[date_match.group(3)], # Month
107 | date_match.group(4), # Year
108 | date_match.group(5), # Time
109 | datetime.now(self._tzinfo).strftime("%z") # Timezone offset
110 | ])
111 | upload_time = int(datetime.strptime(date_str, "%d %m %Y %H:%M %z").strftime("%s"))
112 | return upload_time
113 |
114 | def login(self) -> None:
115 | self._login_using_post(
116 | url="http://kinozal.tv/takelogin.php",
117 | post={
118 | "username": self._encode(self._user),
119 | "password": self._encode(self._passwd),
120 | "returnto": b"",
121 | },
122 | ok_text="href=\"/my.php\"",
123 | )
124 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/nnm_club_me.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 = [
40 | "nnmclub.to",
41 | "nnm-club.me",
42 | "nnm-club.name",
43 | ]
44 |
45 | _NNM_DOMAIN = PLUGIN_NAMES[0]
46 |
47 | _SITE_VERSION = 7
48 | _SITE_ENCODING = "cp1251"
49 |
50 | _SITE_FINGERPRINT_URL = f"https://{_NNM_DOMAIN}"
51 | _SITE_FINGERPRINT_TEXT = f" "
52 |
53 | _COMMENT_REGEXP = re.compile(r"https?://(nnm-club\.(me|ru|name|tv|lib)|nnmclub\.to)"
54 | r"/forum/viewtopic\.php\?p=(?P\d+)")
55 |
56 | _TIMEZONE_URL = f"https://{_NNM_DOMAIN}/forum/profile.php?mode=editprofile"
57 | _TIMEZONE_REGEXP = re.compile(r"selected=\"selected\">(?PGMT [+-] [\d:]+)")
58 | _TIMEZONE_PREFIX = "Etc/"
59 |
60 | _DOWNLOAD_ID_URL = f"https://{_NNM_DOMAIN}/forum/viewtopic.php?p={{torrent_id}}"
61 | _DOWNLOAD_ID_REGEXP = re.compile(r"filelst.php\?attach_id=(?P[a-zA-Z0-9]+)")
62 | _DOWNLOAD_URL = f"https://{_NNM_DOMAIN}//forum/download.php?id={{download_id}}"
63 |
64 | _STAT_URL = _DOWNLOAD_ID_URL
65 | _STAT_OK_REGEXP = _DOWNLOAD_ID_REGEXP
66 | _STAT_SEEDERS_REGEXP = re.compile(r"align=\"center\">\[\s+(?P\d+)")
67 | _STAT_LEECHERS_REGEXP = re.compile(r"align=\"center\">\[\s+(?P\d+)")
68 |
69 | # =====
70 |
71 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
72 | self._init_bases(**kwargs)
73 | self._init_opener(with_cookies=True)
74 |
75 | @classmethod
76 | def get_options(cls) -> Dict[str, Option]:
77 | return cls._get_merged_options({
78 | "timeout": Option(default=20.0, help="Timeout for HTTP client"),
79 | })
80 |
81 | def fetch_time(self, torrent: Torrent) -> int:
82 | torrent_id = self._assert_match(torrent)
83 | date = self._assert_logic_re_search(
84 | regexp=re.compile(r" Зарегистрирован: "
85 | r"\s* (\d{1,2} ... \d{4} \d\d:\d\d:\d\d) "),
86 | text=self._decode(self._read_url(f"https://{self._NNM_DOMAIN}/forum/viewtopic.php?p={torrent_id}")),
87 | msg="Upload date not found",
88 | ).group(1).lower()
89 | for (m_src, m_dest) in [
90 | ("янв", "01"),
91 | ("фев", "02"),
92 | ("мар", "03"),
93 | ("апр", "04"),
94 | ("май", "05"),
95 | ("июн", "06"),
96 | ("июл", "07"),
97 | ("авг", "08"),
98 | ("сен", "09"),
99 | ("окт", "10"),
100 | ("ноя", "11"),
101 | ("дек", "12"),
102 | ]:
103 | date = date.replace(m_src, m_dest)
104 | date += " " + datetime.now(self._tzinfo).strftime("%z")
105 | upload_time = int(datetime.strptime(date, "%d %m %Y %H:%M:%S %z").strftime("%s"))
106 | return upload_time
107 |
108 | def login(self) -> None:
109 | self._login_using_post(
110 | url=f"https://{self._NNM_DOMAIN}/forum/login.php",
111 | post={
112 | "username": self._encode(self._user),
113 | "password": self._encode(self._passwd),
114 | "redirect": b"",
115 | "login": b"\xc2\xf5\xee\xe4",
116 | },
117 | ok_text=f"class=\"mainmenu\">Выход [ {self._user} ] ",
118 | )
119 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/pornolab_net.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 WithCaptcha
33 | from . import WithCheckTime
34 | from . import WithFetchByTorrentId
35 | from . import WithStat
36 |
37 |
38 | # =====
39 | class Plugin(WithLogin, WithCaptcha, WithCheckTime, WithFetchByTorrentId, WithStat):
40 | PLUGIN_NAMES = ["pornolab.net"]
41 |
42 | _SITE_VERSION = 3
43 | _SITE_ENCODING = "cp1251"
44 |
45 | _SITE_FINGERPRINT_URL = "https://pornolab.net/forum/index.php"
46 | _SITE_FINGERPRINT_TEXT = "title=\"Поиск на Pornolab.net\" href=\"//static.pornolab.net/opensearch.xml\""
47 |
48 | _COMMENT_REGEXP = re.compile(r"https?://pornolab\.net/forum/viewtopic\.php\?t=(?P\d+)")
49 |
50 | _TIMEZONE_URL = "https://pornolab.net/forum/index.php"
51 | _TIMEZONE_REGEXP = re.compile(r"Часовой пояс: (?PGMT [+-] \d{1,2})
")
52 | _TIMEZONE_PREFIX = "Etc/"
53 |
54 | _DOWNLOAD_URL = "https://pornolab.net/forum/dl.php?t={torrent_id}"
55 | _DOWNLOAD_PAYLOAD = b""
56 |
57 | _STAT_URL = "https://pornolab.net/forum/viewtopic.php?t={torrent_id}"
58 | _STAT_OK_REGEXP = re.compile(r"class=\"dl-stub dl-link\">Скачать \.torrent
")
59 | _STAT_SEEDERS_REGEXP = re.compile(r"Сиды: \s+(?P\d+) ")
60 | _STAT_LEECHERS_REGEXP = re.compile(r"Личи: \s+(?P\d+) ")
61 |
62 | # =====
63 |
64 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
65 | self._init_bases(**kwargs)
66 | self._init_opener(with_cookies=True)
67 |
68 | @classmethod
69 | def get_options(cls) -> Dict[str, Option]:
70 | return cls._get_merged_options()
71 |
72 | def fetch_time(self, torrent: Torrent) -> int:
73 | torrent_id = self._assert_match(torrent)
74 |
75 | date_match = self._assert_logic_re_search(
76 | regexp=re.compile(r"\[ (\d\d-([а-яА-Я]{3})-\d\d \d\d:\d\d:\d\d) \] "),
77 | text=self._decode(self._read_url(f"https://pornolab.net/forum/viewtopic.php?t={torrent_id}")),
78 | msg="Upload date not found",
79 | )
80 | date = date_match.group(1)
81 | date_month = date_match.group(2)
82 |
83 | date = date.replace(date_month, {
84 | month: str(number) for (number, month) in enumerate(
85 | ["Янв", "Фев", "Мар", "Апр", "Май", "Июн",
86 | "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"], 1
87 | )
88 | }[date_month]) # Send shitbeam to datetime authors
89 | date += " " + datetime.now(self._tzinfo).strftime("%z")
90 |
91 | upload_time = int(datetime.strptime(date, "%d-%m-%y %H:%M:%S %z").strftime("%s"))
92 | return upload_time
93 |
94 | def login(self) -> None:
95 | self._assert_required_user_passwd()
96 |
97 | post = {
98 | "login_username": self._encode(self._user),
99 | "login_password": self._encode(self._passwd),
100 | "login": b"\xc2\xf5\xee\xe4",
101 | }
102 | page = self.__read_login(post)
103 |
104 | cap_static_regexp = re.compile(r"\"(https?://static\.pornolab\.net/captcha/[^\"]+)\"")
105 | cap_static_match = cap_static_regexp.search(page)
106 | if cap_static_match is not None:
107 | cap_sid = self._assert_auth_re_search(
108 | regexp=re.compile(r"name=\"cap_sid\" value=\"([a-zA-Z0-9]+)\""),
109 | text=page,
110 | msg="Unknown cap_sid",
111 | ).group(1)
112 |
113 | cap_code = self._assert_auth_re_search(
114 | regexp=re.compile(r"name=\"(cap_code_[a-zA-Z0-9]+)\""),
115 | text=page,
116 | msg="Unknown cap_code",
117 | ).group(1)
118 |
119 | post[cap_code] = self._encode(self._captcha_decoder(cap_static_match.group(1)))
120 | post["cap_sid"] = self._encode(cap_sid)
121 |
122 | page = self.__read_login(post)
123 | self._assert_auth(cap_static_regexp.search(page) is None, "Invalid user, password or captcha")
124 |
125 | def __read_login(self, post: Dict[str, bytes]) -> str:
126 | return self._decode(self._read_url(
127 | url="https://pornolab.net/forum/login.php",
128 | data=self._urlencode(post),
129 | ))
130 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/pravtor_ru.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 typing import Dict
23 | from typing import Any
24 |
25 | from ...optconf import Option
26 |
27 | from ...tfile import Torrent
28 |
29 | from . import WithLogin
30 | from . import WithCheckHash
31 |
32 |
33 | # =====
34 | class Plugin(WithLogin, WithCheckHash):
35 | PLUGIN_NAMES = ["pravtor.ru"]
36 |
37 | _SITE_VERSION = 0
38 | _SITE_ENCODING = "cp1251"
39 |
40 | _SITE_FINGERPRINT_URL = "https://pravtor.ru"
41 | _SITE_FINGERPRINT_TEXT = " \d+)")
44 |
45 | _TORRENT_HASH_URL = "https://pravtor.ru/viewtopic.php?p={torrent_id}"
46 | _TORRENT_HASH_REGEXP = re.compile(r"(?P[a-zA-Z0-9]+) ")
47 |
48 | # =====
49 |
50 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
51 | self._init_bases(**kwargs)
52 | self._init_opener(with_cookies=True)
53 |
54 | @classmethod
55 | def get_options(cls) -> Dict[str, Option]:
56 | return cls._get_merged_options()
57 |
58 | def fetch_new_data(self, torrent: Torrent) -> bytes:
59 | torrent_id = self._assert_match(torrent)
60 |
61 | dl_id = self._assert_logic_re_search(
62 | regexp=re.compile(r""),
63 | text=self._decode(self._read_url(f"https://pravtor.ru/viewtopic.php?p={torrent_id}")),
64 | msg="Torrent-ID not found",
65 | ).group(1)
66 |
67 | self._set_cookie("bb_dl", torrent_id)
68 |
69 | return self._assert_valid_data(self._read_url(
70 | url=f"http://pravtor.ru/download.php?id={dl_id}",
71 | data=b"",
72 | headers={
73 | "Referer": f"http://pravtor.ru/viewtopic.php?t={torrent_id}",
74 | "Origin": "http://pravtor.ru",
75 | }
76 | ))
77 |
78 | def login(self) -> None:
79 | self._login_using_post(
80 | url="https://pravtor.ru/login.php",
81 | post={
82 | "login_username": self._encode(self._user),
83 | "login_password": self._encode(self._passwd),
84 | "login": b"\xc2\xf5\xee\xe4",
85 | },
86 | ok_text=f"{self._user} [
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 typing import Dict
23 | from typing import Any
24 |
25 | from ...optconf import Option
26 |
27 | from . import WithCheckHash
28 | from . import WithFetchByTorrentId
29 | from . import WithStat
30 |
31 |
32 | # =====
33 | class Plugin(WithCheckHash, WithFetchByTorrentId, WithStat):
34 | PLUGIN_NAMES = [
35 | "rutor.info",
36 | "rutor.org",
37 | ]
38 |
39 | _SITE_VERSION = 8
40 | _SITE_ENCODING = "utf-8"
41 |
42 | _SITE_FINGERPRINT_URL = "http://rutor.info"
43 | _SITE_FINGERPRINT_TEXT = " "
44 |
45 | _COMMENT_REGEXP = re.compile(r"^http://rutor\.(info|org|is)/torrent/(?P\d+)$")
46 |
47 | _TORRENT_HASH_URL = "http://rutor.info/torrent/{torrent_id}"
48 | _TORRENT_HASH_REGEXP = re.compile(r"\s+
[a-fA-F0-9]{40})")
50 |
51 | _DOWNLOAD_URL = "http://rutor.info/download/{torrent_id}"
52 |
53 | _STAT_URL = _TORRENT_HASH_URL
54 | _STAT_OK_REGEXP = _TORRENT_HASH_REGEXP
55 | _STAT_SEEDERS_REGEXP = re.compile(r"(?P\d+) ")
56 | _STAT_LEECHERS_REGEXP = re.compile(r"(?P\d+) ")
57 |
58 | # =====
59 |
60 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
61 | self._init_bases(**kwargs)
62 | self._init_opener(with_cookies=False)
63 |
64 | @classmethod
65 | def get_options(cls) -> Dict[str, Option]:
66 | return cls._get_merged_options({
67 | "user_agent": Option(default="Googlebot/2.1", help="User-agent for site"),
68 | })
69 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/rutracker_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 typing import Dict
23 | from typing import Any
24 |
25 | from ...optconf import Option
26 |
27 | from ...tfile import Torrent
28 |
29 | from . import WithLogin
30 | from . import WithCaptcha
31 | from . import WithCheckHash
32 | from . import WithStat
33 |
34 |
35 | # =====
36 | class Plugin(WithLogin, WithCaptcha, WithCheckHash, WithStat):
37 | PLUGIN_NAMES = [
38 | "rutracker.org",
39 | "torrents.ru",
40 | ]
41 |
42 | _SITE_VERSION = 8
43 | _SITE_ENCODING = "cp1251"
44 | _SITE_RETRY_CODES = [503, 404]
45 |
46 | _SITE_FINGERPRINT_URL = "https://rutracker.org/forum/index.php"
47 | _SITE_FINGERPRINT_TEXT = (" ")
49 |
50 | _COMMENT_REGEXP = re.compile(r"https?://rutracker\.org/forum/viewtopic\.php\?t=(?P\d+)")
51 |
52 | _TORRENT_HASH_URL = "https://rutracker.org/forum/viewtopic.php?t={torrent_id}"
53 | _TORRENT_HASH_REGEXP = re.compile(r"[a-fA-F0-9]{40})&[^\"]+\""
54 | r" class=\"med magnet-link\" data-topic_id=\"\d+\"")
55 |
56 | _STAT_URL = _TORRENT_HASH_URL
57 | _STAT_OK_REGEXP = _TORRENT_HASH_REGEXP
58 | _STAT_SEEDERS_REGEXP = re.compile(r"Сиды: \s+(?P\d+) ")
59 | _STAT_LEECHERS_REGEXP = re.compile(r"Личи: \s+(?P\d+) ")
60 |
61 | # =====
62 |
63 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
64 | self._init_bases(**kwargs)
65 | self._init_opener(with_cookies=True)
66 |
67 | @classmethod
68 | def get_options(cls) -> Dict[str, Option]:
69 | return cls._get_merged_options()
70 |
71 | def fetch_new_data(self, torrent: Torrent) -> bytes:
72 | torrent_id = self._assert_match(torrent)
73 | self._set_cookie("bb_dl", torrent_id, path="/forum/", secure=True)
74 | return self._assert_valid_data(self._read_url(
75 | url=f"https://rutracker.org/forum/dl.php?t={torrent_id}",
76 | data=b"",
77 | headers={
78 | "Referer": f"https://rutracker.org/forum/viewtopic.php?t={torrent_id}",
79 | "Origin": "https://rutracker.org",
80 | }
81 | ))
82 |
83 | def login(self) -> None:
84 | self._assert_required_user_passwd()
85 |
86 | post = {
87 | "login_username": self._encode(self._user),
88 | "login_password": self._encode(self._passwd),
89 | "login": b"\xc2\xf5\xee\xe4",
90 | }
91 | page = self.__read_login(post)
92 |
93 | cap_static_regexp = re.compile(r"\"//(static\.t-ru\.org/captcha/[^\"]+)\"")
94 | cap_static_match = cap_static_regexp.search(page)
95 | if cap_static_match is not None:
96 | cap_sid = self._assert_auth_re_search(
97 | regexp=re.compile(r"name=\"cap_sid\" value=\"([a-zA-Z0-9]+)\""),
98 | text=page,
99 | msg="Unknown cap_sid",
100 | ).group(1)
101 |
102 | cap_code = self._assert_auth_re_search(
103 | regexp=re.compile(r"name=\"(cap_code_[a-zA-Z0-9]+)\""),
104 | text=page,
105 | msg="Unknown cap_code",
106 | ).group(1)
107 |
108 | post[cap_code] = self._encode(self._captcha_decoder(f"https://{cap_static_match.group(1)}"))
109 | post["cap_sid"] = self._encode(cap_sid)
110 |
111 | page = self.__read_login(post)
112 | self._assert_auth(cap_static_regexp.search(page) is None, "Invalid user, password or captcha")
113 |
114 | def __read_login(self, post: Dict[str, bytes]) -> str:
115 | return self._decode(self._read_url(
116 | url="https://rutracker.org/forum/login.php",
117 | data=self._urlencode(post),
118 | ))
119 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/tr_anidub_com.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 | import operator
22 |
23 | from datetime import datetime
24 |
25 | from typing import Tuple
26 | from typing import List
27 | from typing import Dict
28 | from typing import Any
29 |
30 | from dateutil.relativedelta import relativedelta
31 |
32 | from ...optconf import Option
33 |
34 | from ...tfile import Torrent
35 |
36 | from . import WithLogin
37 | from . import WithCheckTime
38 |
39 |
40 | # =====
41 | class Plugin(WithLogin, WithCheckTime):
42 | PLUGIN_NAMES = ["tr.anidub.com"]
43 |
44 | _SITE_VERSION = 2
45 |
46 | _SITE_FINGERPRINT_URL = "https://tr.anidub.com"
47 | _SITE_FINGERPRINT_TEXT = (" \d+)")
51 |
52 | _TIMEZONE_STATIC = "Etc/GMT+4"
53 |
54 | # =====
55 |
56 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
57 | self._init_bases(**kwargs)
58 | self._init_opener(with_cookies=True)
59 |
60 | @classmethod
61 | def get_options(cls) -> Dict[str, Option]:
62 | return cls._get_merged_options()
63 |
64 | def fetch_time(self, torrent: Torrent) -> int:
65 | torrent_id = self._assert_match(torrent)
66 |
67 | date = self._assert_logic_re_search(
68 | regexp=re.compile(r"Дата: ([^,\s]+, \d\d:\d\d) "),
69 | text=self._decode(self._read_url(f"https://tr.anidub.com/?newsid={torrent_id}")),
70 | msg="Upload date not found",
71 | ).group(1)
72 |
73 | now = datetime.now(self._tzinfo)
74 | day_template = "{date.day:02d}-{date.month:02d}-{date.year}"
75 | if "Сегодня" in date:
76 | date = date.replace("Сегодня", day_template.format(date=now))
77 | if "Вчера" in date:
78 | yesterday = now - relativedelta(days=1)
79 | date = date.replace("Вчера", day_template.format(date=yesterday))
80 | date += " " + datetime.now(self._tzinfo).strftime("%z")
81 |
82 | upload_time = int(datetime.strptime(date, "%d-%m-%Y, %H:%M %z").strftime("%s"))
83 | return upload_time
84 |
85 | def fetch_new_data(self, torrent: Torrent) -> bytes:
86 | torrent_id = self._assert_match(torrent)
87 | page = self._decode(self._read_url(f"https://tr.anidub.com/?newsid={torrent_id}"))
88 | downloads = set(map(int, re.findall(r"", page)))
89 | candidates: Dict[str, List[Tuple[Torrent, str]]] = {}
90 | for download_id in downloads:
91 | data = self._assert_valid_data(self._read_url(
92 | url=f"https://tr.anidub.com/engine/download.php?id={download_id}",
93 | headers={"Referer": f"https://tr.anidub.com/?newsid={torrent_id}"},
94 | ))
95 | candidate = Torrent(data=data)
96 |
97 | name = candidate.get_name()
98 | candidates.setdefault(name, [])
99 | candidates[name].append((candidate, str(download_id)))
100 |
101 | name = torrent.get_name()
102 | self._assert_logic(name in candidates, f"Can't find torrent named {name!r} in downloads")
103 | self._assert_logic(len(candidates[name]) == 1,
104 | f"Too many variants to download: {', '.join(map(operator.itemgetter(1), candidates[name]))}")
105 | return candidates[name][0][0].get_data()
106 |
107 | def login(self) -> None:
108 | self._login_using_post(
109 | url="https://tr.anidub.com/",
110 | post={
111 | "login_name": self._encode(self._user),
112 | "login_password": self._encode(self._passwd),
113 | "login": b"submit",
114 | },
115 | ok_text=f"Мой профиль "
116 | )
117 |
--------------------------------------------------------------------------------
/emonoda/plugins/trackers/trec_to.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 WithCaptcha
33 | from . import WithCheckTime
34 | from . import WithFetchByDownloadId
35 | from . import WithStat
36 |
37 |
38 | # =====
39 | class Plugin(WithLogin, WithCaptcha, WithCheckTime, WithFetchByDownloadId, WithStat):
40 | PLUGIN_NAMES = [
41 | "trec.to",
42 | "torrent.rus.ec",
43 | ]
44 |
45 | _SITE_VERSION = 1
46 | _SITE_ENCODING = "utf-8"
47 |
48 | _SITE_FINGERPRINT_URL = "http://trec.to"
49 | _SITE_FINGERPRINT_TEXT = "var cookieDomain = \"trec.to\";"
50 |
51 | _COMMENT_REGEXP = re.compile(r"http://(torrent\.rus\.ec|trec\.to)/viewtopic\.php\?p=(?P\d+)")
52 |
53 | _TIMEZONE_URL = "http://trec.to"
54 | _TIMEZONE_REGEXP = re.compile(r"Часовой пояс: (?PGMT [+-] \d{1,2})
")
55 | _TIMEZONE_PREFIX = "Etc/"
56 |
57 | _DOWNLOAD_ID_URL = "http://trec.to/viewtopic.php?p={torrent_id}"
58 | _DOWNLOAD_ID_REGEXP = re.compile(r"\d+)\" class=\"(leech|seed|gen)med\">")
59 | _DOWNLOAD_URL = "http://trec.to/download.php?id={download_id}"
60 |
61 | _STAT_URL = _DOWNLOAD_ID_URL
62 | _STAT_OK_REGEXP = _DOWNLOAD_ID_REGEXP
63 | _STAT_SEEDERS_REGEXP = re.compile(r"Сидов: \s+(?P\d+) ")
64 | _STAT_LEECHERS_REGEXP = re.compile(r"Личеров: \s+(?P\d+) ")
65 |
66 | # =====
67 |
68 | def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called
69 | self._init_bases(**kwargs)
70 | self._init_opener(with_cookies=True)
71 |
72 | @classmethod
73 | def get_options(cls) -> Dict[str, Option]:
74 | return cls._get_merged_options()
75 |
76 | def fetch_time(self, torrent: Torrent) -> int:
77 | torrent_id = self._assert_match(torrent)
78 | date = self._assert_logic_re_search(
79 | regexp=re.compile(r" \s*Зарегистрирован \s*\[ "
80 | r"(\d\d-\d\d-\d\d\d\d \d\d:\d\d) ]\s* "),
81 | text=self._decode(self._read_url(f"http://trec.to/viewtopic.php?p={torrent_id}")),
82 | msg="Upload date not found",
83 | ).group(1)
84 | date += " " + datetime.now(self._tzinfo).strftime("%z")
85 | upload_time = int(datetime.strptime(date, "%d-%m-%Y %H:%M %z").strftime("%s"))
86 | return upload_time
87 |
88 | def login(self) -> None:
89 | self._login_using_post(
90 | url="http://trec.to/login.php",
91 | post={
92 | "login_username": self._encode(self._user),
93 | "login_password": self._encode(self._passwd),
94 | "login": self._encode("Вход"),
95 | },
96 | ok_text="
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/thirdparty/bencoder.pyx:
--------------------------------------------------------------------------------
1 | # cython: language_level=3
2 |
3 | # Description: A fast bencode implementation in Cython supports both Python2 & Python3
4 | # Upstream: https://github.com/whtsky/bencoder.pyx
5 | # License: BSD
6 |
7 | # The contents of this file are subject to the BitTorrent Open Source License
8 | # Version 1.1 (the License). You may not copy or use this file, in either
9 | # source code or executable form, except in compliance with the License. You
10 | # may obtain a copy of the License at http://www.bittorrent.com/license/.
11 | #
12 | # Software distributed under the License is distributed on an AS IS basis,
13 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
14 | # for the specific language governing rights and limitations under the
15 | # License.
16 |
17 | # Based on https://github.com/karamanolev/bencode3/blob/master/bencode.py
18 |
19 | __version__ = '1.2.1'
20 |
21 |
22 |
23 | from cpython.version cimport PY_MAJOR_VERSION, PY_MINOR_VERSION
24 | IS_PY2 = PY_MAJOR_VERSION == 2
25 | if IS_PY2:
26 | END_CHAR = 'e'
27 | ARRAY_TYPECODE = b'b'
28 | else:
29 | END_CHAR = ord('e')
30 | ARRAY_TYPECODE = 'b'
31 |
32 | if PY_MAJOR_VERSION >= 3 and PY_MINOR_VERSION >=7:
33 | OrderedDict = dict
34 | else:
35 | from collections import OrderedDict
36 |
37 | class BTFailure(Exception):
38 | pass
39 |
40 |
41 | def decode_int(bytes x, int f):
42 | f += 1
43 | cdef long new_f = x.index(b'e', f)
44 | n = int(x[f:new_f])
45 | if x[f] == b'-'[0]:
46 | if x[f + 1] == b'0'[0]:
47 | raise ValueError()
48 | elif x[f] == b'0'[0] and new_f != f + 1:
49 | raise ValueError()
50 | return n, new_f + 1
51 |
52 |
53 | def decode_string(bytes x, int f):
54 | cdef long colon = x.index(b':', f)
55 | cdef long n = int(x[f:colon])
56 | if x[f] == b'0'[0] and colon != f + 1:
57 | raise ValueError()
58 | colon += 1
59 | return x[colon:colon + n], colon + n
60 |
61 |
62 | def decode_list(bytes x, int f):
63 | r, f = [], f + 1
64 | while x[f] != END_CHAR:
65 | v, f = decode_func[x[f]](x, f)
66 | r.append(v)
67 | return r, f + 1
68 |
69 |
70 | def decode_dict(bytes x, int f):
71 | r = OrderedDict()
72 | f += 1
73 | while x[f] != END_CHAR:
74 | k, f = decode_string(x, f)
75 | r[k], f = decode_func[x[f]](x, f)
76 | return r, f + 1
77 |
78 |
79 | decode_func = dict()
80 |
81 | for func, keys in [
82 | (decode_list, 'l'),
83 | (decode_dict, 'd'),
84 | (decode_int, 'i'),
85 | (decode_string, [str(x) for x in range(10)])
86 | ]:
87 | for key in keys:
88 | if IS_PY2:
89 | decode_func[key] = func
90 | else:
91 | decode_func[ord(key)] = func
92 |
93 |
94 | def bdecode2(bytes x):
95 | try:
96 | r, l = decode_func[x[0]](x, 0)
97 | except (IndexError, KeyError, ValueError):
98 | raise BTFailure("not a valid bencoded string")
99 | return r, l
100 |
101 | def bdecode(bytes x):
102 | r, l = bdecode2(x)
103 | if l != len(x):
104 | raise BTFailure("invalid bencoded value (data after valid prefix)")
105 | return r
106 |
107 | cdef encode(v, list r):
108 | tp = type(v)
109 | if tp in encode_func:
110 | return encode_func[tp](v, r)
111 | else:
112 | for tp, func in encode_func.items():
113 | if isinstance(v, tp):
114 | return func(v, r)
115 | raise BTFailure(
116 | "Can't encode {0}(Type: {1})".format(v, type(v))
117 | )
118 |
119 |
120 | cdef encode_int(long x, list r):
121 | r.append(b'i')
122 | r.append(str(x).encode())
123 | r.append(b'e')
124 |
125 |
126 | cdef encode_long(x, list r):
127 | r.append(b'i')
128 | r.append(str(x).encode())
129 | r.append(b'e')
130 |
131 |
132 | cdef encode_bytes(bytes x, list r):
133 | r.append(str(len(x)).encode())
134 | r.append(b':')
135 | r.append(x)
136 |
137 |
138 | cdef encode_string(str x, list r):
139 | r.append(str(len(x)).encode())
140 | r.append(b':')
141 | r.append(x.encode())
142 |
143 |
144 | cdef encode_list(x, list r):
145 | r.append(b'l')
146 | for i in x:
147 | encode(i, r)
148 | r.append(b'e')
149 |
150 |
151 | cdef encode_dict(x, list r):
152 | r.append(b'd')
153 | item_list = list(x.items())
154 | item_list.sort()
155 | for k, v in item_list:
156 | if isinstance(k, str):
157 | k = k.encode()
158 | encode_bytes(k, r)
159 | encode(v, r)
160 | r.append(b'e')
161 |
162 |
163 | encode_func = {
164 | int: encode_int,
165 | bool: encode_int,
166 | long: encode_long,
167 | bytes: encode_bytes,
168 | str: encode_string,
169 | list: encode_list,
170 | tuple: encode_list,
171 | dict: encode_dict,
172 | OrderedDict: encode_dict,
173 | }
174 |
175 |
176 | def bencode(x):
177 | r = []
178 | encode(x, r)
179 | return b''.join(r)
180 |
--------------------------------------------------------------------------------
/emonoda/tools.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 Sequence
24 | from typing import Iterable
25 | from typing import Optional
26 | from typing import Any
27 |
28 | import chardet
29 |
30 |
31 | # =====
32 | def make_sub_name(path: str, prefix: str, suffix: str) -> str:
33 | return os.path.join(
34 | os.path.dirname(path),
35 | prefix + os.path.basename(path) + suffix,
36 | )
37 |
38 |
39 | def sorted_paths(paths: Iterable, get: Optional[Any]=None) -> List:
40 | if get is None:
41 | # def for speed
42 | def get_path_nulled(path: str) -> str:
43 | return path.replace(os.path.sep, "\0")
44 | else:
45 | def get_path_nulled(item: Sequence) -> str: # type: ignore
46 | return item[get].replace(os.path.sep, "\0") # type: ignore
47 | return sorted(paths, key=get_path_nulled)
48 |
49 |
50 | def get_decoded_path(path: str) -> str:
51 | try:
52 | path.encode()
53 | return path
54 | except UnicodeEncodeError:
55 | path_bytes = os.fsencode(path)
56 | try:
57 | return path_bytes.decode("cp1251")
58 | except UnicodeDecodeError:
59 | encoding = chardet.detect(path_bytes)["encoding"]
60 | assert encoding is not None, f"Can't determine encoding for bytes string: {path_bytes!r}"
61 | return path_bytes.decode(encoding)
62 |
--------------------------------------------------------------------------------
/emonoda/web/__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 socket
21 | import urllib.request
22 | import urllib.parse
23 | import urllib.error
24 | import http.client
25 | import http.cookiejar
26 | import io
27 | import time
28 |
29 | from typing import Tuple
30 | from typing import List
31 | from typing import Dict
32 | from typing import NamedTuple
33 | from typing import Optional
34 |
35 | from . import gziphandler
36 | from . import sockshandler
37 |
38 |
39 | # =====
40 | class MultipartFile(NamedTuple):
41 | name: str
42 | mimetype: str
43 | data: bytes
44 |
45 |
46 | def build_opener(
47 | proxy_url: str="",
48 | cookie_jar: Optional[http.cookiejar.CookieJar]=None,
49 | ) -> urllib.request.OpenerDirector:
50 |
51 | handlers: List[urllib.request.BaseHandler] = [gziphandler.GzipHandler()]
52 |
53 | if proxy_url:
54 | scheme = (urllib.parse.urlparse(proxy_url).scheme or "").lower()
55 | if scheme in ["http", "https"]:
56 | handlers.append(urllib.request.ProxyHandler({scheme: proxy_url}))
57 | elif scheme in ["socks4", "socks5"]:
58 | handlers.append(sockshandler.SocksHandler(proxy_url=proxy_url))
59 | else:
60 | raise RuntimeError(f"Invalid proxy protocol: {scheme}")
61 |
62 | if cookie_jar is not None:
63 | handlers.append(urllib.request.HTTPCookieProcessor(cookie_jar))
64 |
65 | return urllib.request.build_opener(*handlers)
66 |
67 |
68 | def read_url( # pylint: disable=too-many-positional-arguments
69 | opener: urllib.request.OpenerDirector,
70 | url: str,
71 | data: Optional[bytes]=None,
72 | headers: Optional[Dict[str, str]]=None,
73 | timeout: float=10.0,
74 | retries: int=10,
75 | retries_sleep: float=1.0,
76 | retry_codes: Optional[List[int]]=None,
77 | retry_timeout: bool=True,
78 | ) -> bytes:
79 |
80 | if retry_codes is None:
81 | retry_codes = [500, 502, 503]
82 |
83 | while True:
84 | try:
85 | request = urllib.request.Request(url, data, (headers or {}))
86 | return opener.open(request, timeout=timeout).read()
87 | except socket.timeout:
88 | if retries == 0 or not retry_timeout:
89 | raise
90 | except urllib.error.HTTPError as err:
91 | if retries == 0 or err.code not in retry_codes:
92 | raise
93 | except urllib.error.URLError as err:
94 | if "timed out" in str(err.reason):
95 | if retries == 0 or not retry_timeout:
96 | raise
97 | else:
98 | raise
99 | except (http.client.IncompleteRead, http.client.BadStatusLine, ConnectionResetError):
100 | if retries == 0:
101 | raise
102 |
103 | time.sleep(retries_sleep)
104 | retries -= 1
105 |
106 |
107 | def encode_multipart(
108 | fields: Dict[str, str],
109 | files: Dict[str, MultipartFile],
110 | encoding: str="utf-8",
111 | boundary: str="-------------------------acebdf13572468",
112 | ) -> Tuple[bytes, Dict[str, str]]:
113 |
114 | data_io = io.BytesIO()
115 |
116 | def write_line(line: str="") -> None:
117 | data_io.write(line.encode(encoding) + b"\r\n")
118 |
119 | def escape_quote(line: str) -> str:
120 | return line.replace("\"", "\\\"")
121 |
122 | for (key, value) in fields.items():
123 | write_line(f"--{boundary}")
124 | write_line(f"Content-Disposition: form-data; name=\"{escape_quote(key)}\"")
125 | write_line()
126 | write_line(value)
127 |
128 | for (key, mf) in files.items():
129 | write_line(f"--{boundary}")
130 | write_line(f"Content-Disposition: form-data; name=\"{escape_quote(key)}\"; filename=\"{escape_quote(mf.name)}\"")
131 | write_line(f"Content-Type: {mf.mimetype}")
132 | write_line()
133 | data_io.write(mf.data + b"\r\n")
134 |
135 | write_line(f"--{boundary}--")
136 |
137 | body = data_io.getvalue()
138 | headers = {
139 | "Content-Type": f"multipart/form-data; boundary={boundary}",
140 | "Content-Length": str(len(body)),
141 | }
142 | return (body, headers)
143 |
--------------------------------------------------------------------------------
/emonoda/web/gziphandler.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 | from urllib.request import BaseHandler
21 | from urllib.request import Request
22 |
23 | from urllib.response import addinfo # type: ignore
24 | from urllib.response import addinfourl
25 |
26 | import gzip
27 |
28 | from typing import Union
29 |
30 |
31 | # =====
32 | class GzipHandler(BaseHandler):
33 | def http_request(self, request: Request) -> Request:
34 | request.add_header("Accept-Encoding", "gzip")
35 | return request
36 |
37 | def http_response(self, request: Request, response: addinfo) -> Union[addinfo, addinfourl]: # pylint: disable=unused-argument
38 | if response.headers.get("Content-Encoding") == "gzip":
39 | gzip_file = gzip.GzipFile(fileobj=response, mode="r")
40 | new_response = addinfourl(gzip_file, response.headers, response.url, response.code) # type: ignore
41 | new_response.msg = response.msg # type: ignore
42 | return new_response
43 | return response
44 |
45 | https_request = http_request
46 | https_response = http_response
47 |
48 | # XXX: vulture hacks
49 | _ = https_request
50 | _ = https_response # type: ignore
51 | del _
52 |
--------------------------------------------------------------------------------
/emonoda/web/sockshandler.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 socket
21 | import urllib.parse
22 |
23 | from urllib.request import HTTPHandler
24 | from urllib.request import HTTPSHandler
25 | from urllib.request import Request
26 |
27 | from http.client import HTTPConnection
28 | from http.client import HTTPSConnection
29 | from http.client import HTTPResponse
30 |
31 | from typing import Tuple
32 | from typing import Optional
33 | from typing import Any
34 |
35 | from ..thirdparty import socks
36 |
37 |
38 | # =====
39 | SCHEME_TO_TYPE = {
40 | "socks4": socks.PROXY_TYPE_SOCKS4,
41 | "socks5": socks.PROXY_TYPE_SOCKS5,
42 | }
43 |
44 | SOCKS_PORT = 1080
45 |
46 |
47 | # =====
48 | class _SocksConnection(HTTPConnection):
49 | def __init__(self, *args: Any, **kwargs: Any) -> None:
50 | kwargs.pop("proxy_url", None) # XXX: Fix for "TypeError: __init__() got an unexpected keyword argument 'proxy_url'"
51 | super().__init__(*args, **kwargs)
52 | self.__proxy_args: Optional[Tuple[
53 | Optional[int],
54 | Optional[str],
55 | Optional[int],
56 | bool,
57 | Optional[str],
58 | Optional[str],
59 | ]] = None
60 |
61 | # XXX: because proxy args/kwargs break super
62 | def make_proxy_args( # pylint: disable=too-many-positional-arguments
63 | self,
64 | proxy_url: str="",
65 | proxy_type: Optional[int]=None,
66 | proxy_host: Optional[str]=None,
67 | proxy_port: Optional[int]=None,
68 | proxy_user: Optional[str]=None,
69 | proxy_passwd: Optional[str]=None,
70 | rdns: bool=True,
71 | ) -> None:
72 |
73 | if proxy_url:
74 | parsed = urllib.parse.urlparse(proxy_url)
75 | scheme = parsed.scheme
76 | proxy_user = parsed.username
77 | proxy_passwd = parsed.password
78 | proxy_host = parsed.hostname
79 | proxy_port = (parsed.port or SOCKS_PORT)
80 | proxy_type = SCHEME_TO_TYPE.get((scheme or "").lower())
81 | if proxy_type is None:
82 | raise RuntimeError(f"Invalid SOCKS protocol: {scheme}")
83 | params = dict(urllib.parse.parse_qsl(parsed.query))
84 | if str(params.get("rdns")) not in ["1", "yes", "true"]:
85 | rdns = False
86 |
87 | self.__proxy_args = (proxy_type, proxy_host, proxy_port, rdns, proxy_user, proxy_passwd)
88 |
89 | def connect(self) -> None:
90 | assert self.__proxy_args is not None, "Proxy args weren't initialized"
91 | self.sock = socks.socksocket()
92 | self.sock.setproxy(*self.__proxy_args)
93 | timeout = self.timeout # type: ignore
94 | if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: # type: ignore # pylint: disable=protected-access
95 | self.sock.settimeout(timeout)
96 | self.sock.connect((self.host, self.port)) # type: ignore
97 |
98 |
99 | class _SocksSecureConnection(HTTPSConnection, _SocksConnection):
100 | def __init__(self, *args: Any, **kwargs: Any) -> None:
101 | kwargs.pop("proxy_url", None) # XXX: Fix for "TypeError: __init__() got an unexpected keyword argument 'proxy_url'"
102 | super().__init__(*args, **kwargs)
103 |
104 |
105 | # =====
106 | class SocksHandler(HTTPHandler, HTTPSHandler):
107 | def __init__(self, *args: Any, **kwargs: Any) -> None:
108 | self.__args = args
109 | self.__kwargs = kwargs
110 | super().__init__(debuglevel=kwargs.pop("debuglevel", 0))
111 |
112 | def http_open(self, req: Request) -> HTTPResponse:
113 | def build(
114 | host: str,
115 | port: Optional[int]=None,
116 | timeout: int=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore # pylint: disable=protected-access
117 | ) -> _SocksConnection:
118 |
119 | connection = _SocksConnection(host, port=port, timeout=timeout, **self.__kwargs)
120 | connection.make_proxy_args(*self.__args, **self.__kwargs)
121 | return connection
122 |
123 | return self.do_open(build, req) # type: ignore
124 |
125 | def https_open(self, req: Request) -> HTTPResponse:
126 | def build(
127 | host: str,
128 | port: Optional[int]=None,
129 | timeout: int=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore # pylint: disable=protected-access
130 | ) -> _SocksSecureConnection:
131 |
132 | connection = _SocksSecureConnection(host, port=port, timeout=timeout, **self.__kwargs)
133 | connection.make_proxy_args(*self.__args, **self.__kwargs)
134 | return connection
135 |
136 | return self.do_open(build, req) # type: ignore
137 |
138 | # XXX: vulture hacks
139 | _ = http_open
140 | _ = https_open
141 | del _
142 |
--------------------------------------------------------------------------------
/flake8.ini:
--------------------------------------------------------------------------------
1 | [flake8]
2 | inline-quotes = double
3 | max-line-length = 160
4 | ignore = W503, E227, E241, E252, Q003
5 | # W503 line break before binary operator
6 | # E227 missing whitespace around bitwise or shift operator
7 | # E241 multiple spaces after
8 | # E252 missing whitespace around parameter equals
9 | # Q003 Change outer quotes to avoid escaping inner quotes
10 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | # https://squidfunk.github.io/mkdocs-material/getting-started
2 |
3 | site_name: Emonoda
4 | site_description: Система управления коллекцией торрентов
5 | site_author: Maxim Devaev
6 | site_url: https://mdevaev.github.io/emonoda
7 |
8 | repo_name: mdevaev/emonoda
9 | repo_url: https://github.com/mdevaev/emonoda
10 |
11 | copyright: "Copyright © 2018 Maxim Devaev"
12 |
13 | theme:
14 | name: material
15 | custom_dir: docs/theme
16 | feature:
17 | tabs: false
18 | palette:
19 | primary: blue grey
20 | accent: pink
21 |
22 | extra:
23 | social:
24 | - type: github-alt
25 | link: https://github.com/mdevaev/emonoda
26 | search:
27 | language: "en, ru"
28 |
29 | markdown_extensions:
30 | - admonition
31 | - toc:
32 | permalink: true
33 | - codehilite:
34 | guess_lang: false
35 | - markdown_include.include:
36 | base_path: docs
37 |
38 | pages:
39 | - "Emonoda -- 得物だ": index.md
40 | - "Настройка":
41 | - "Общие принципы настройки": config.md
42 | - "Настройка интеграции с торрент-клиентом": clients.md
43 | - "Настройка трекеров": trackers.md
44 | - "Настройка оповещений об обновлениях": confetti.md
45 | - "Команды":
46 | - "emupdate": emupdate.md
47 | - "emfile": emfile.md
48 | - "emdiff": emdiff.md
49 | - "emload": emload.md
50 | - "emrm": emrm.md
51 | - "emfind": emfind.md
52 | - "emconfetti-demo": emconfetti-demo.md
53 | - "emconfetti-tghi": emconfetti-tghi.md
54 | - "Спецкостыли для клиентов": hooks.md
55 | - "Инфа для разработчиков":
56 | - "rTorrent XMLRPC Reference": rTorrent-XMLRPC-Reference.md
57 | - "rTorrent system_multicall": rTorrent-system_multicall.md
58 |
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version = 3.10
3 | ignore_missing_imports = True
4 | disallow_untyped_defs = True
5 |
6 | [mypy-emonoda.thirdparty.*]
7 | # http://mypy.readthedocs.io/en/latest/config_file.html#examples
8 | ignore_errors = True
9 |
--------------------------------------------------------------------------------
/pylintrc:
--------------------------------------------------------------------------------
1 | [MASTER]
2 | ignore=.git
3 |
4 | [DESIGN]
5 | min-public-methods=0
6 | max-args=10
7 |
8 | [TYPECHECK]
9 | ignored-classes=
10 | pygments.lexers,
11 | pygments.formatters,
12 |
13 | extension-pkg-whitelist=
14 | emonoda.thirdparty.bencoder,
15 |
16 | [MESSAGES CONTROL]
17 | disable =
18 | file-ignored,
19 | locally-disabled,
20 | fixme,
21 | missing-docstring,
22 | superfluous-parens,
23 | duplicate-code,
24 | broad-except,
25 | redundant-keyword-arg,
26 | wrong-import-order,
27 | too-many-ancestors,
28 | no-else-return,
29 | len-as-condition,
30 | raise-missing-from,
31 | unspecified-encoding,
32 |
33 | [REPORTS]
34 | msg-template={symbol} -- {path}:{line}({obj}): {msg}
35 |
36 | [FORMAT]
37 | max-line-length=160
38 |
39 | [BASIC]
40 | # Regular expression matching correct method names
41 | method-rgx=[a-z_][a-z0-9_]{2,50}$
42 |
43 | # Regular expression matching correct function names
44 | function-rgx=[a-z_][a-z0-9_]{2,50}$
45 |
46 | # Regular expression which should only match correct module level names
47 | const-rgx=([a-zA-Z_][a-zA-Z0-9_]*)$
48 |
49 | # Regular expression which should only match correct argument names
50 | argument-rgx=[a-z_][a-z0-9_]{1,30}$
51 |
52 | # Regular expression which should only match correct variable names
53 | variable-rgx=[a-z_][a-z0-9_]{1,30}$
54 |
55 | # Regular expression which should only match correct instance attribute names
56 | attr-rgx=[a-z_][a-z0-9_]{1,30}$
57 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | chardet
2 | pyyaml
3 | colorama
4 | pygments
5 | pytz
6 | python-dateutil
7 | Mako
8 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Emonoda -- A set of tools to organize and manage your torrents
4 | Copyright (C) 2015 Devaev Maxim
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 textwrap
22 |
23 | import setuptools.command.easy_install
24 |
25 | from setuptools import setup
26 | from setuptools.extension import Extension
27 |
28 | from Cython.Build import cythonize
29 |
30 |
31 | # =====
32 | class _Template(str):
33 | def __init__(self, text: str) -> None:
34 | self.__text = textwrap.dedent(text).strip()
35 |
36 | def __mod__(self, kv: dict) -> str:
37 | kv = {"module_name": kv["ep"].module_name, **kv}
38 | return (self.__text % (kv))
39 |
40 |
41 | class _ScriptWriter(setuptools.command.easy_install.ScriptWriter):
42 | template = _Template("""
43 | # EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r
44 |
45 | __requires__ = %(spec)r
46 |
47 | from %(module_name)s import main
48 |
49 | if __name__ == '__main__':
50 | main()
51 | """)
52 |
53 |
54 | # =====
55 | def main() -> None:
56 | setuptools.command.easy_install.ScriptWriter = _ScriptWriter
57 |
58 | with open("requirements.txt") as requirements_file:
59 | install_requires = list(filter(None, requirements_file.read().splitlines()))
60 |
61 | setup(
62 | name="emonoda",
63 | version="2.1.38",
64 | url="https://github.com/mdevaev/emonoda",
65 | license="GPLv3",
66 | author="Devaev Maxim",
67 | author_email="mdevaev@gmail.com",
68 | description="A set of tools to organize and manage your torrents",
69 | platforms="any",
70 |
71 | packages=[
72 | "emonoda",
73 | "emonoda.web",
74 | "emonoda.optconf",
75 | "emonoda.apps",
76 | "emonoda.apps.hooks",
77 | "emonoda.apps.hooks.rtorrent",
78 | "emonoda.apps.hooks.transmission",
79 | "emonoda.helpers",
80 | "emonoda.plugins",
81 | "emonoda.plugins.clients",
82 | "emonoda.plugins.trackers",
83 | "emonoda.plugins.confetti",
84 | "emonoda.thirdparty",
85 | ],
86 |
87 | package_data={
88 | "emonoda.plugins.confetti": ["templates/*.mako"],
89 | "emonoda.thirdparty": ["bencoder.pyx"],
90 | },
91 |
92 | entry_points={
93 | "console_scripts": [
94 | "emdiff = emonoda.apps.emdiff:main",
95 | "emupdate = emonoda.apps.emupdate:main",
96 | "emfile = emonoda.apps.emfile:main",
97 | "emload = emonoda.apps.emload:main",
98 | "emfind = emonoda.apps.emfind:main",
99 | "emrm = emonoda.apps.emrm:main",
100 | "emconfetti-demo = emonoda.apps.emconfetti_demo:main",
101 | "emconfetti-tghi = emonoda.apps.emconfetti_tghi:main",
102 | "emhook-rtorrent-collectd-stat = emonoda.apps.hooks.rtorrent.collectd_stat:main",
103 | "emhook-rtorrent-manage-trackers = emonoda.apps.hooks.rtorrent.manage_trackers:main",
104 | "emhook-transmission-redownload = emonoda.apps.hooks.transmission.redownload:main",
105 | ],
106 | },
107 |
108 | ext_modules=cythonize(Extension(
109 | "emonoda.thirdparty.bencoder",
110 | ["emonoda/thirdparty/bencoder.pyx"],
111 | extra_compile_args=["-O3"],
112 | )),
113 |
114 | install_requires=install_requires,
115 |
116 | classifiers=[
117 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
118 | "Development Status :: 5 - Production/Stable",
119 | "Programming Language :: Python :: 3.10",
120 | "Programming Language :: Cython",
121 | "Topic :: Software Development :: Libraries :: Python Modules",
122 | "Topic :: Communications :: File Sharing",
123 | "Topic :: Internet :: WWW/HTTP",
124 | "Topic :: Utilities",
125 | "Operating System :: OS Independent",
126 | "Intended Audience :: System Administrators",
127 | "Intended Audience :: End Users/Desktop",
128 | ],
129 | )
130 |
131 |
132 | if __name__ == "__main__":
133 | main()
134 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = flake8, pylint, mypy, vulture
3 | skipsdist = true
4 |
5 | [testenv]
6 | basepython = python3.12
7 |
8 | [testenv:flake8]
9 | commands = flake8 --config=flake8.ini setup.py emonoda
10 | deps =
11 | Cython
12 | flake8
13 | flake8-quotes
14 | transmissionrpc
15 | -rrequirements.txt
16 |
17 | [testenv:pylint]
18 | commands =
19 | python setup.py build_ext --inplace
20 | pylint --output-format=colorized --reports=no setup.py emonoda
21 | deps =
22 | Cython
23 | pylint
24 | transmissionrpc
25 | setuptools
26 | -rrequirements.txt
27 |
28 | [testenv:mypy]
29 | commands = mypy setup.py emonoda
30 | deps =
31 | Cython
32 | mypy
33 | transmissionrpc
34 | types-chardet
35 | types-pytz
36 | types-python-dateutil
37 | types-PyYAML
38 | -rrequirements.txt
39 |
40 | [testenv:vulture]
41 | commands = vulture --exclude emonoda/thirdparty setup.py emonoda
42 | deps =
43 | Cython
44 | vulture
45 | transmissionrpc
46 | -rrequirements.txt
47 |
48 | [flake8]
49 | max-line-length = 140
50 | # F401 -- imported but unused // using pylint
51 | # F811 -- redefinition of unused // using pylint
52 | # E241 -- multiple spaces after ':'
53 | # E272 -- multiple spaces before keyword
54 | # E221 -- multiple spaces before operator
55 | # E252 -- missing whitespace around parameter equals
56 | # W503 -- line break before binary operator
57 | ignore=F401,F811,E241,E272,E221,E252,W503
58 |
--------------------------------------------------------------------------------
/trackers/booktracker.org.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "utf-8",
4 | "text": " ",
5 | "url": "https://booktracker.org"
6 | },
7 | "version": 2
8 | }
--------------------------------------------------------------------------------
/trackers/ipv6.nnm-club.me.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "http://ipv6.nnm-club.name"
6 | },
7 | "version": 1
8 | }
--------------------------------------------------------------------------------
/trackers/ipv6.nnm-club.name.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "http://ipv6.nnm-club.name"
6 | },
7 | "version": 1
8 | }
--------------------------------------------------------------------------------
/trackers/ipv6.nnmclub.to.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "http://ipv6.nnm-club.name"
6 | },
7 | "version": 1
8 | }
--------------------------------------------------------------------------------
/trackers/kinozal.tv.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": "\u0422\u043e\u0440\u0440\u0435\u043d\u0442 \u0442\u0440\u0435\u043a\u0435\u0440 \u041a\u0438\u043d\u043e\u0437\u0430\u043b.\u0422\u0412 ",
5 | "url": "http://kinozal.tv/"
6 | },
7 | "version": 0
8 | }
--------------------------------------------------------------------------------
/trackers/nnm-club.me.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "https://nnmclub.to"
6 | },
7 | "version": 7
8 | }
--------------------------------------------------------------------------------
/trackers/nnm-club.name.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "https://nnmclub.to"
6 | },
7 | "version": 7
8 | }
--------------------------------------------------------------------------------
/trackers/nnmclub.to.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "https://nnmclub.to"
6 | },
7 | "version": 7
8 | }
--------------------------------------------------------------------------------
/trackers/pornolab.net.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": "title=\"\u041f\u043e\u0438\u0441\u043a \u043d\u0430 Pornolab.net\" href=\"//static.pornolab.net/opensearch.xml\"",
5 | "url": "https://pornolab.net/forum/index.php"
6 | },
7 | "version": 3
8 | }
--------------------------------------------------------------------------------
/trackers/pravtor.ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "http://rutor.info"
6 | },
7 | "version": 8
8 | }
--------------------------------------------------------------------------------
/trackers/rutor.org.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "utf-8",
4 | "text": " ",
5 | "url": "http://rutor.info"
6 | },
7 | "version": 8
8 | }
--------------------------------------------------------------------------------
/trackers/rutracker.org.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "https://rutracker.org/forum/index.php"
6 | },
7 | "version": 8
8 | }
--------------------------------------------------------------------------------
/trackers/torrent.rus.ec.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "utf-8",
4 | "text": "var cookieDomain = \"trec.to\";",
5 | "url": "http://trec.to"
6 | },
7 | "version": 1
8 | }
--------------------------------------------------------------------------------
/trackers/torrents.ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "cp1251",
4 | "text": " ",
5 | "url": "https://rutracker.org/forum/index.php"
6 | },
7 | "version": 8
8 | }
--------------------------------------------------------------------------------
/trackers/tr.anidub.com.json:
--------------------------------------------------------------------------------
1 | {
2 | "fingerprint": {
3 | "encoding": "utf-8",
4 | "text": "