Мониторинг торрентов и автоматическая скачка

в 8:54, , рубрики: p2p, python, python2, torrent, говнокод, Программирование, сериалы, метки:

Совсем недавно на Хабре было 2 статьи о том, как автоматизировать процесс скачивания новых серий с торрентов. Авторы обеих статей поделились своими приложениями. Вот уже год мы тоже разрабатываем подобное приложение и мне кажется, пришло время рассказать хабрасообществу о нашем маленьком, но прекрасном проекте Monitorrent, который, возможно, сделает вашу жизнь настолько проще и удобнее, насколько сделал нашу.

Main Page

Веб приложение написано на Python 2 (с поддержкой Python 3). Оно позволяет добавлять новые торренты для мониторинга, автоматически скачивать новые серии и добавлять их в торрент клиент.

Мы им пользуемся на постоянной основе с конца прошлого года, а 1 мая 2016 мы выпустили первую релизную версию, которая без каких-либо сбоев крутится до сих пор на cubietruck в docker контейнере.

За подробностями того как оно работает внутри прошу под кат.

Мне хочется прийти с работы домой и, сев ужинать, просто открыть Kodi, выбрать свежую серию любимого сериала и посмотреть её. Не прилагая никаких усилий для ее поиска на торрент трекерах и не тратя время на ожидание, пока она скачается.

Решений для данной автоматизации очень много. Сначала я пользовался плагином для Chrome, который следил за изменениями на rutracker, а изменённые торренты качал вручную и добавлял в uTorrent по RDC, а позже через их веб приложение.

TorrentMonitor

Но после того, как я открыл для себя TorrentMonitor все стало намного проще. Он у меня работал на роутере больше года. Даже пару pull request’ов к нему было. Об этом приложении было 2 замечательные статьи на хабре от его автора (раз, два). Огромное спасибо автору.

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

FlexGet

Следующим открытием для меня был FlexGet. Очень мощный инструмент. В нем не было поддержки lostfilm.tv и прикрутить ее было то ещё приключение. В остальном он работал исправно, однако научить его следить за изменением торрента на rutracker’е у меня так и не вышло. Наверное и сейчас этого сделать нельзя. Зато у меня было настроенное правило, которое скачивало фильмы этого и предыдущего года с rutor, с качеством 720p (больше инет не позволял) и рейтингом imdb больше 6.0, при этом исключая фильмы из Японии (ну не люблю я японский синематограф, а рейтинг у них стабильно высокий). Всё это описывалось всего лишь парой строчек в yaml.

Долгое время оба сервиса (TorrentMonitor и FlexGet) работали рядом на роутере.

После того как мне подарили cubietruck, и я установил в него 2.5 винчестер на 2 Тб, он превратился в маленький, но очень практичный NAS, который кушает мало электричества и исправно качает торренты. А мобильная батарейка спасает от проблем с перебоями электричества. Скорость доступа к файлам около 30 Мб/с стабильно, этого достаточно для моих задач. TorrentMonitor и FlexGet перекочевали на cubietruck.

Однако, проблема со скачиванием торрентов нулевого размера никуда не делась.

Monitorrent

И мне захотелось сделать свой проект для автоматизации скачивания новых серий. TorrentMonitor написан на PHP и вызывает curl для скачивания новых торрентов. Для настройки времени запуска использует вызов php через cron.

Мне же хотелось все из коробки, чтобы установил — и оно заработало.

Так появился Monitorrent. Как идея написать что-то полезное для себя на python. Маленький набор скриптов не в счет.

Это одностраничное веб приложение написанное на Python 2. В качестве front-end’а используется Angular 1.4 и angular-material. А back-end — это просто REST сервис, написанный с использованием falcon.

Все исходники лежат на github, и распространяются под лицензией Do What the Fuck You Want to Public License.

Сейчас поддерживаются следующие трекеры:

  • lostfilm.tv c парсингом страницы сериала
  • rutor.org (сейчас rutor.is и rutor.info) со слежением изменений торрента
  • rutracker.org со слежением изменений торрента и авторизацией
  • free-torrents.org со слежением изменений торрента
  • tapochek.net со слежением изменений торрента и авторизацией
  • unionpeer.org со слежением изменений торрента

Скачанные торренты можно добавлять в следующие торрент клиенты:

  • Transmission
  • Deluge
  • uTorrent
  • qbittorrent

Это покрывает мои нужды на 200% (в основном использую только 3 трекера и 2 торрент клиента).

Front-end

Вообще, это двухстраничное приложение.

Одна страница для логина, вторая — все остальное приложение. Отдельная страница логина нужна только для того, чтобы нельзя было скачивать статические файлы (картинки, css или js) до того, как авторизируешься в систему. Я наверное параноик, смысла в этом мало, но мне нравится думать, что так слегка безопаснее.

Обе страницы генерируются из одного index.htm файла, которая потом трансформируется с помощью gulp-preprocess плагина.

Все внешние js файлы (фреймворки и js библиотеки) грузятся из CDN, для того чтобы облегчить доступ к Monitorrent’у извне, когда он развернут в домашней сети. Если дома ADSL, а скорость отдачи только 512 кбит/с, то гораздо быстрее скачать js из интернета, чем из домашней сети на ограниченной скорости, потому что канал и так забит раздачей торрентов. Все внутренние js файлы уже приходится скачивать из домашней сети, которые потом отлично кэшируются браузером.

А так как все остальное общение сделано через REST, то данных между front-end’ом и back-end’ом пересылается очень мало.

Авторизация сделана через JWT. Мне кажется, что это самая оптимальная технология авторизации. Она позволяет не хранить сессию на сервере и не дает клиенту видеть, какие именно данные у него хранятся. Если вы еще не используете JWT в своих приложениях, то настоятельно рекомендую это сделать. Особенно хорошо, как мне кажется, использовать JWT в микросервисной архитектуре.

Сборка сделана с помощью gulp, который заменил собой grunt. Все js файлы просто склеиваются вместе в один большой bundle, который пока даже не минифицируется. Но всё склеивается правильно, потому что основной файл называется app.js и первым попадает в финальный js. Всё остальное работает благодаря DI от angular.

Сейчас я бы прикрутил webpack. Но я не front-end разработчик и я не знал ничего о front-end разработке, когда этот проект только начинался.

Динамическая генерация форм

Из дополнительных особенностей имплементации можно упомянуть реализованную нами angular директиву для генерации динамических форм.

Настройки всех плагинов – это простые формы, например, вот так выглядит форма настройки соединения с Transmission:

Transmission Settings

Эта форма состоит из 2-х строк, в каждой из которых по 2 текстовых блока. Длина элемента host равно 80%, а длина port 20%. Текст блоки для логина и пароля размера 50%. Написание этой формы на angular-material — тривиальная задача.

Однако, нам хотелось упростить разработку плагинов и сосредоточиться на написании backend-логики, и не заморачиваться html’ом. Плагин должен поставляться в виде единственно файла, без дополнительного файла разметки.

Мы разработали простой формат для описания разметки формы в коде плагина:

form = [{
    'type': 'row',
    'content': [{
        'type': 'text',
        'label': 'Host',
        'model': 'host',
        'flex': 80
    }, {
        'type': 'text',
        'label': 'Port',
        'model': 'port',
        'flex': 20
    }]
}, {
    'type': 'row',
    'content': [{
        'type': 'text',
        'label': 'Username',
        'model': 'username',
        'flex': 50
    }, {
        'type': 'password',
        'label': 'Password',
        'model': 'password',
        'flex': 50
    }]
}]

Это описание формы редактирования настроек для Transmission. Здесь описаны 3 текстовых блока и один блок для ввода пароля. Назначение свойств type и label понятны из их названий. Имя свойства flex, было выбрано неудачно, правильнее его было назвать width – оно определяет длину элемента в процентах внутри строки. Оно было так названо, потому что angular-material использует flexbox для описания расположения элементов на странице.

После того как пользователь введёт данные в эту форму, и нажмет кнопку Save. На back-end будет послана модель следующего вида:

{
    "host": "myhost",
    "port": "9091",
    "username": "username",
    "password": "******"
}

Имена свойств этой модели берутся из свойства model описания формы.

Это позволило сосредоточится на написании только логики плагинов back-end’а и упростило написание UI. В мобильной версии приложения все элементы будут располагаться друг за другом, т.е. элементы внутри одной строки разобьются на несколько строк. Этот функционал все ещё не реализован, но надеюсь появится в будущем.

Естественно, динамическая генерация форм – это не самое гибкое решение, но я считаю его правильным и обоснованным. Хотя наш front-end девелопер с этим не согласен по сегодняшний день и до сих пор спорит со мной об этом решении.

Websocket

В одной из первых версий была реализована работа с Websocket’ами. Сначала полностью руками, потом на socket.io.

Для работы с Websocket’ами со стороны python была использована python библиотека для работы с socket.io. Она использует gevent, для создания coroutine (легковесных потоков, greenlet’ов и много других, название которые я уже не помню). Это отличная библиотека для написания асинхронных приложений, какими обязаны быть приложения, использующие Websocket’ы.

Но, к сожалению, python socket.io реализация требует библиотеку gevent больше 1.0 версии. А для домашних роутеров gevent есть только версии 0.13. Исключать возможность запуска Monitorrent’а на роутерах нам очень не хотелось несмотря на то, что я сам уже давно пользуюсь cubietruck. Поэтому от Websocket’ов пришлось отказаться и заменить их на long polling запросы в REST интерфейсе. Сейчас они используются только в одном месте, для получения статуса текущей проверки на новые серии.

Back-end

Написан на python 2 с использованием falcon. Falcon обещает очень высокую производительность и он показался мне очень удобным. Первоначально Monitorrent был написан на cherrypy, потом переписан на flask, была попытка использование bottle, но тоже так и не сложилось и мы остановились на falcon.

К сожалению falcon – это фреймворк для написания REST сервисов в первую очередь, а отдавать статику тоже нужно. Такой функциональности falcon не предоставляет из коробки, в отличии от тех же flask и cherrypy. Пришлось реализовать этот функционал самим. К тому же все средства в falcon для этого есть.

@no_auth
class StaticFiles(object):
    def __init__(self, folder=None, filename=None, redirect_to_login=True):
        self.folder = folder
        self.filename = filename
        self.redirect_to_login = redirect_to_login

    def on_get(self, req, resp, filename=None):
        if self.redirect_to_login and not AuthMiddleware.validate_auth(req):
            resp.status = falcon.HTTP_FOUND
            resp.location = '/login'
            return

        file_path = filename or self.filename
        if self.folder:
            file_path = os.path.join(self.folder, file_path)
        if not os.path.isfile(file_path):
            raise falcon.HTTPNotFound(description='Requested page not found')

        mime_type, encoding = mimetypes.guess_type(file_path)
        etag, last_modified = self._get_static_info(file_path)

        resp.content_type = mime_type or 'text/plain'

        headers = {'Date': formatdate(time.time(), usegmt=True),
                   'ETag': etag,
                   'Last-Modified': last_modified,
                   'Cache-Control': 'max-age=86400'}
        resp.set_headers(headers)

        if_modified_since = req.get_header('if-modified-since', None)
        if if_modified_since and (parsedate(if_modified_since) >= parsedate(last_modified)):
            resp.status = falcon.HTTP_NOT_MODIFIED
            return

        if_none_match = req.get_header('if-none-match', None)
        if if_none_match and (if_none_match == '*' or etag in if_none_match):
            resp.status = falcon.HTTP_NOT_MODIFIED
            return

        resp.stream_len = os.path.getsize(file_path)
        resp.stream = open(file_path, mode='rb')

    @staticmethod
    def _get_static_info(file_path):
        mtime = os.stat(file_path).st_mtime
        return str(mtime), formatdate(mtime, usegmt=True)

Тут пришлось делать распознавание mimetype, а так же проверку if-modified-since и if-not-match заголовков для правильного кеширования браузером статики. Мне кажется, я украл это решение то ли у cherrypy, то ли у flask и просто переписал его для falcon. Не думаю, что ему место в falcon, потому и не слал им pull request.

Решение мне кажется ужасным, но более красивого мы пока не нашли.

Встроенный WSGI веб сервер falcon можно использовать только для разработки, поэтому всё крутится на WSGI имплементации из cherrypy, которая, насколько мне известно, очень стабильная:

d = wsgiserver.WSGIPathInfoDispatcher({'/': app})
server_start_params = (config.ip, config.port)
server = wsgiserver.CherryPyWSGIServer(server_start_params, d)

Если кто-то знает хороший и быстрый WSGI сервер для python, поделитесь пожалуйста в комментариях. Нужно кроссплатформенное решение, так как Monitorrent также работает и под Windows.

Это первый серьёзный проект на python, поэтому многих особенностей мы не знаем. Наверное, получение статики можно переложить на какой-то WSGI сервер, а всю работу по обработке запросов REST оставить на falcon. Будем благодарны, если кто-то подскажет, как это сделать правильно.

Dependency Injection

Мне сложно понять, как можно жить без DI контейнера, но в python-мире не принято их использовать. Было уже много холиваров на эту тему. К сожалению, хорошего решения не было найдено, так что мы воспользовались явной инъекцией зависимостей во все классы.

Система plugin’ов

Все трекеры и торрент клиенты реализованы в виде плагинов. Пока это все типы плагинов, но в ближайшее время появятся плагины для уведомлений. Соответствующий pull request ожидает review и будет доступен в версии 1.1.

Мы не нашли красивой системы для загрузки плагинов, где можно было бы просто просканировать папку и каким-то образом загрузить оттуда все классы, поэтому идея реализации была украдена у FlexGet’а.

Каждый плагин сам себя регистрирует в системе. Хотя, как мне кажется, было бы правильнее, чтобы система сама искала плагины, а не плагин знал, как себя зарегистрировать в системе.

Плагин для торрент клиента

Интерфейс плагина очень простой:

class MyClientPlugin(object):
    name = "myclient"
    form = [{
        ...
    }]

    def get_settings(self):
        pass

    def set_settings(self, settings):
        pass

    def check_connection(self):
        pass

    def find_torrent(self, torrent_hash):
        pass

    def add_torrent(self, torrent):
        pass

    def remove_torrent(self, torrent_hash):
        pass

register_plugin('client', 'myclient', MyClientPlugin())

Методы можно разбить на 2 группы, одна группа для хранения настроек торрент клиента, другая — для управления торрентами.

Методы set_settings() и get_settings() сохраняют и читаю данные из базы.

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

Библиотека для разбора торрент файла была взята из FlexGet’а. Откуда она появилась там я не смог выяснить (хотя и не сильно пытался). В неё была внесена парочка маленьких модификаций для поддержки python 3 и для чтения чистого не разобранного массива байт.

Поле form описывает форму настроек этого плагина на UI. О том как это работает можно почитать выше в разделе о динамической генерации форм.

Плагины достаточно компакты и просты в имплементации. Например transmission занимает всего 115 строчек, включая пару строк комментариев и 7 строк импортов.

Плагин для трекера

В терминах Monitorrent’а любая подписка на изменения торрентов называется темой (topic). Например для lostfilm мы следим за изменениями сериала на его странице, а не путём разбора RSS. После выхода новой серии мы будем скачивать новый торрент файл, а не изменённый. Поэтому называть подписку темой мне кажется более разумным.

Если контракт плагина для торрент клиента очень простой и поэтому для него нет базового класса, то для трекера все сложнее. Для начала рассмотрим простой интерфейс плагина для трекера:

class TrackerPluginBase(with_metaclass(abc.ABCMeta, object)):
    topic_form = [{
        ...
    }]

    @abc.abstractmethod
    def can_parse_url(self, url):
        pass

    def prepare_add_topic(self, url):
        pass

    def add_topic(self, url, params):
        pass

    def get_topics(self, ids):
        pass

    def save_topic(self, topic, last_update, status=Status.Ok):
        pass

    def get_topic(self, id):
        pass

    def update_topic(self, id, params):
        pass

    @abc.abstractmethod
    def execute(self, topics, engine):
        pass

Здесь также есть методы по работе с настройками определенного торрента *_topic() и отдельный метод для получения всех тем get_topics().

Добавление нового торрента для мониторинга происходит по URL темы. Например для rutracker – это адрес страницы форума, для lostfilm – это страница сериала. Для того, чтобы узнать какой плагин может обработать этот URL, у всех плагинов поочерёдно вызывается метод can_parse_url(), который через regex проверяет может ли он работать с этим URL’ом или нет. Если такой плагин не найден, то пользователь увидит сообщение, что тему добавить не удалось. Если плагин который понимает этот URL был найден, то сначала у него вызывается метод prepare_add_topic(), который возвращает модель с разобранными данными и позволяет пользователю отредактировать эти данные. Форма для редактирования данных описана в поле topic_form. После того как пользователь отредактирует данные темы и нажмёт кнопку Add, вызывается метод add_topic, которому передаётся отредактированная модель и он сохранит эту тему в базу для мониторинга.

Сейчас есть одно общее свойство для всех тем — это display_name. Заголовок который виден на главной странице. Для lostfilm ещё можно выбрать качество скачиваемых серий.

Самый большой и главный метод – это execute(self, topics, engine). Он отвечает за проверку изменений и скачивание новых серий. Ему передается список его тем для проверки и специальный объект engine. Объект engine позволяет добавлять новые торренты в торрент клиент, а так же предоставляет объект для логирования. Торрент клиент для скачиваний может быть только один. И плагину не важно какой-то это клиент, за выбор клиента отвечает engine, плагин просто передает скачанный торрент в engine. В случае когда сериал раздаётся путем добавления новых серий, engine удаляет предыдущую раздачу и добавляет новую.

Так как некоторые трекеры требуют авторизации, для них реализован отдельный тип плагинов который может сохранять информацию для логина WithCredentialsMixin. Как видно из названия этот класс является миксином (почему именно миксин рассказано ниже). Только эти типы плагинов сейчас имеют настройку на UI. Этот класс добавляет к интерфейсу плагина ещё пару методов:

class WithCredentialsMixin(with_metaclass(abc.ABCMeta, TrackerPluginMixinBase)):
    credentials_form = [{
        ...
    }]

    @abc.abstractmethod
    def login(self):
        pass

    @abc.abstractmethod
    def verify(self):
        pass

    def get_credentials(self):
        pass

    def update_credentials(self, credentials):
        pass

    def execute(self, ids, engine):
        if not self._execute_login(engine):
            return
        super(WithCredentialsMixin, self).execute(ids, engine)

    def _execute_login(self, engine):
        pass

Методы для хранения и загрузки данных авторизации *_credentials. Методы для логина и проверки введенных данных login() и verify() соответственно. Так же он переопределяет метод execute(), для того чтобы сначала авторизоваться на трекере (вызовом метода _execute_login()) и только потом проверять темы на изменения.

Для редактирования настроек используется динамически генерируемая форма из поля credentials_form.

Сейчас проверка изменений на всех трекерах, кроме lostfilm, ведется путём скачивания торрент файла и сравнения его хеша с тем, что был скачан в прошлый раз. Если хеш отличается, то скачивается новый торрент и добавляется в торрент клиент. Наверное, достаточно было сделать проверку самой страницы, посылая HEAD запрос или что-то ещё, но этот вариант надежнее. Размер страницы, как оказалось, больше, чем сам торрент файл, а простое добавление комментария, не являясь изменением торрента, изменит страницу. К тому же rutor не поддерживал HEAD совсем.

Эта логика вынесена в метод execute класса ExecuteWithHashChangeMixin. Это снова миксин, как и WithCredentialsMixin. Это позволяет писать плагины, наследуя 1 или 2 миксина в зависимости от трекера, и переопределять только пару методов.

Вот так определён плагин для free-torrents.org:

class FreeTorrentsOrgPlugin(WithCredentialsMixin, ExecuteWithHashChangeMixin, TrackerPluginBase):
    ...
    topic_form = [{
        ...
    }]

    def login(self):
        pass

    def verify(self):
        pass

    def can_parse_url(self, url):
        return self.tracker.can_parse_url(url)

    def parse_url(self, url):
        return self.tracker.parse_url(url)

    def _prepare_request(self, topic):
        headers = {'referer': topic.url, 'host': "dl.free-torrents.org"}
        cookies = self.tracker.get_cookies()
        request = requests.Request('GET', self.tracker.get_download_url(topic.url), headers=headers, cookies=cookies)
        return request.prepare()

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

Плагин для rutor.org использует только ExecuteWithHashChangeMixin:

class RutorOrgPlugin(ExecuteWithHashChangeMixin, TrackerPluginBase):
    pass

А плагин для lostfilm использует только WithCredentialsMixin, потому что реализация по поиску изменений у него своя:

class LostFilmPlugin(WithCredentialsMixin, TrackerPluginBase):
    pass

Плагин для lostfilm довольно сложный, на целых 640 строк. Особенно сложен логин через bogi, но всё работает как часы уже более 7 месяцев.

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

К сожалению на rutor иногда удаляют раздачи и приходится искать новую, Monitorrent может отследить удалённые раздачи и подсвечивает такие темы пользователю на главном экране. За эту логику тоже отвечает метод execute().

База данных

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

В качестве ORM используется sqlalchemy. Это мощная и удобная ORM, которая поддерживает наследование классов с отображением их на базу. Именно sqlalchemy упростит переход на другую базу данных, если когда-то мы соберёмся добавить поддержку других баз данных.

Код в Monitorrent’е поддерживает миграции данных и схем. К сожалению sqlalchemy из коробки не имеет этого функционала, но есть другой проект от авторов sqlachemyalembic, который мы и используем для этих целей.

Sqlite драйвер для python имеет пару ограничений. Одно из них это то, что нельзя использовать транзакции вместе с модификацией схемы данных. Иногда это критично при миграции базы данных на новую версию. Решение этой проблемы описано на сайте sqlalchemy. Оттуда этот код был перенесен в Monitorrent. Сейчас миграции работают без каких-либо проблем.

Уже почти все плагины трекеров обзавелись своими миграциями, с самых старых версий до последнего релиза. Реализована поддержка миграций через метод upgrade, который передается при регистрации плагина.

Метод upgrade сначала определяет текущую версию путём различных проверок вроде существования столбцов и таблиц, а потом производит непосредственно миграцию с этой версии до последней.

Пример кода миграции для rutor:

def upgrade(engine, operations_factory):
    if not engine.dialect.has_table(engine.connect(), RutorOrgTopic.__tablename__):
        return
    version = get_current_version(engine)
    if version == 0:
        upgrade_0_to_1(engine, operations_factory)
        version = 1
    if version == 1:
        upgrade_1_to_2(operations_factory)
        version = 2

def get_current_version(engine):
    m = MetaData(engine)
    t = Table(RutorOrgTopic.__tablename__, m, autoload=True)
    if 'url' in t.columns:
        return 0
    if 'hash' in t.columns and not t.columns['hash'].nullable:
        return 1
    return 2

def upgrade_0_to_1(engine, operations_factory):
    m0 = MetaData()
    rutor_topic_0 = Table("rutororg_topics", m0,
                          Column('id', Integer, primary_key=True),
                          Column('name', String, unique=True, nullable=False),
                          Column('url', String, nullable=False, unique=True),
                          Column('hash', String, nullable=False),
                          Column('last_update', UTCDateTime, nullable=True))

    m1 = MetaData()
    topic_last = Table('topics', m1, *[c.copy() for c in Topic.__table__.columns])
    rutor_topic_1 = Table('rutororg_topics1', m1,
                          Column("id", Integer, ForeignKey('topics.id'), primary_key=True),
                          Column("hash", String, nullable=False))

    def topic_mapping(topic_values, raw_topic):
        topic_values['display_name'] = raw_topic['name']

    with operations_factory() as operations:
        if not engine.dialect.has_table(engine.connect(), topic_last.name):
            topic_last.create(engine)
        operations.upgrade_to_base_topic(rutor_topic_0, rutor_topic_1, PLUGIN_NAME,
                                         topic_mapping=topic_mapping)

В одной из самых первых версий все плагины имели свою таблицу для хранения тем. Позже общие поля, такие как url и display_name были вынесены в таблицу topics. В коде это реализовано в виде наследования всех классов тем от базового Topic класса.

Поэтому все имеющиеся данные нужно было мигрировать, путём удаления старых столбцов и переноса их в общую таблицу topics. Так как для всех плагинов нужно было проделать аналогичные операции, то она была вынесена в общее место MonitorrentOperations.upgrade_to_base_topic:

def upgrade_to_base_topic(self, v0, v1, polymorphic_identity, topic_mapping=None, column_renames=None):
    from .plugins import Topic

    self.create_table(v1)
    topics = self.db.query(v0)
    for topic in topics:
        raw_topic = row2dict(topic, v0)
        # insert into topics
        topic_values = {c: v for c, v in list(raw_topic.items()) if c in Topic.__table__.c and c != 'id'}
        topic_values['type'] = polymorphic_identity
        if topic_mapping:
            topic_mapping(topic_values, raw_topic)
        result = self.db.execute(Topic.__table__.insert(), topic_values)

        # get topic.id
        inserted_id = result.inserted_primary_key[0]

        # insert into v1 table
        concrete_topic = {c: v for c, v in list(raw_topic.items()) if c in v1.c}
        concrete_topic['id'] = inserted_id
        if column_renames:
            column_renames(concrete_topic, raw_topic)
        self.db.execute(v1.insert(), concrete_topic)
    # drop original table
    self.drop_table(v0.name)
    # rename new created table to old one
    self.rename_table(v1.name, v0.name)

На самом деле миграции пока не нужны, делать их для 2.5 пользователей не имело смысла. Но мне было обязательно обкатать этот функционал с самого начала, чтобы потом было проще. А миграции ещё явно понадобятся.

Для работы с базой данных был создан специальный класс DBSession который расширен поддержкой python оператора with, для того чтобы можно было писать код следующим образом:

with DBSession() as db:
    cred = db.query(self.credentials_class).first()
    cred.c_uid = self.tracker.c_uid
    cred.c_pass = self.tracker.c_pass
    cred.c_usess = self.tracker.c_usess

За основу как обычно брался код из FlexGet’а. Все плагины напрямую работают с базой данных через DBSession(). Никаких репозиториев или других вспомогательных классов не создавалось.

Периодический запуск

Monitorrent из коробки умеет запускаться периодически для проверки обновлений тем. По умолчанию запуск происходит раз в 2 часа, но это значение можно изменить.

Это реализовано с помощью обертки над threading.Thread. Реализация очень похожа на реализацию threading.Timer, но в нашей реализации есть stop(), который завершит этот поток для правильной остановки приложения. И имеется возможность запустить исполнение по требованию, не дожидаясь запуска по расписанию. Запуск по требованию сбросит таймер, и следующий запуск произойдет спустя 2 часа (если значение не изменено) с момента завершения запуска по требованию.

Старт приложения

Для запуска Monitorrent’а нужно запустить файл server.py. Он поднимет вебсервер через cherrypy на порту 6687 по умолчанию.

Отдельно стоит рассказать про параметры запуска:

  • debug – режим, в котором ключ для JWT всегда один и тот же, чтобы каждый раз не авторизовываться во время разработки. По умолчанию false. Это всё за что сейчас отвечает этот параметр.
  • ip – адрес, на котором сервер слушает входящие соединения. По умолчанию 0.0.0.0.
  • port – порт, на котором сервер слушает входящие соединения. По умолчанию 6687
  • db_path – путь к файлу базы данных. По умолчанию monitorrent.db. Т.е. база данных расположена в той же папке где и приложение.
  • сonfig – путь к файлу конфигурации, где можно указать параметры описанные выше. По умолчанию config.py

Конфигурировать запуск можно 3-мя способами.

Первый, это собственно аргументы запуска описанные выше.

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

Читается он через python exec команду. Для работы с python 3 он был заменён на вызов метода exec_ пакета six.

with open(config_path) as config_file:
    six.exec_(compile(config_file.read(), config_path, 'exec'), {}, parsed_config)

Если файл прочитать не удалось по какой-то причине, выводится ошибка и он игнорируется, на запуск это никак не влияет.

Третий способ конфигурации — переменные окружения: MONITORRENT_DEBUG, MONITORRENT_IP, MONITORRENT_PORT и MONITORRENT_DB_PATH. Файл конфигурации нельзя указывать через переменные окружения.

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

Для запуска в docker контейнере гораздо проще использовать либо аргументы, либо переменные окружения.

Для запуска из папки проще написать config.py, базовой реализации этого файла пока нет.

Юнит тестирование

Весь python код покрыт юнит тестами на 100%. За исключения входной точки –файла server.py. Но в нем тестировать имеет смысл только логику по разбору параметров запуска и чтения их из конфигурационного файла или из переменных окружения. Всё остальное — обычные настройки приложения. Поэтому все это тестировалось лишь один раз при реализации этого функционала.

Чтобы не портить красивую цифру в 100% покрытия кода тестами, этот файл исключен из подсчёта.

В качестве тестового фреймворка используется пакет unittest из стандартной библиотеки python.

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

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

Основной функционал Monitorrent’а – это закачка торрент файлов с различных трекеров и тестировать в первую очередь нужно именно его.

Мы использовали библиотеку vcrpy, которая упрощает написание тестов для кода, загружающего данные с удалённых серверов. Она делает monkey патчинг пакета requests и может возвращать готовый ответ с файловой системы, не выполняя запрос на удаленный сервер. Т.е. во время первого запуска теста он ходит в настоящий back-end, сохраняет ответ в файл, который потом может быть использован, чтобы быстро вернуть этот же ответ. Это очень сильно ускоряет тесты, так как не нужно ходить за данными в настоящий back-end каждый раз. А если нужно, то всегда можно удалить эти файлы и прогнать тесты на настоящих данных. Правда, это уже стоит называть интеграционными тестами, а не юнит тестами.

Для некоторых тестов реальных данных не достаточно. И тогда нужно формировать html на основе подготовленных фальшивых данных. Или, к примеру, нужно возвращать 404 ошибку. Для этих целей используется библиотека httpretty. С помощью httpretty можно указать, что возвращать, по какому адресу и с каким статусом.

Как обычно, работа тестов через vcrpy была подсмотрена у FlexGet’а. У нас около 97% тестов используют vcrpy и только пару специфических используют httpretty.

Мы используем 2 сервиса, которые показывают процент покрытия кода тестами. Это coveralls.io и codecov.io.

Раньше у нас был небольшой кусочек кода, внутри плагина lostfilm, отвечающий за выбор парсера html:

parser = None
# lxml have some issue with parsing lostfilm on Windows
if sys.platform == 'win32':
    parser = 'html5lib'
soup = get_soup(r.text, parser)

Это платформозависимый код, поэтому покрытие тестами на Linux для этого файла было не 100%. В отличии от coveralls.io, codecov.io позволяет объединять результаты покрытия тестами с нескольких билд систем. Именно поэтому у нас появилась вторая система. Сейчас этот код заменён на следующий:

# lxml have some issue with parsing lostfilm on Windows, so replace it on html5lib for Windows
soup = get_soup(r.text, 'html5lib' if sys.platform == 'win32' else None)

Модуль подсчета покрытия тестами python проверят покрытие всей строки, а не отдельных логических ветвей. Это можно считать жульничеством, но зато теперь можно оставить только один сервис. Какой именно мы ещё не решили. На самом деле они оба хороши. У codecov.io есть плагин для Chrome, который показывает покрытие прямо в github, что оказалось очень удобным.

Тестирование front-end сейчас отсутствует полностью. И я считаю это большой проблемой. Из-за этого уже было пару мелких багов, которые легко было бы отловить тестами.

Также мне хотелось бы проводить полное автоматизированное тестирование всего приложения, используя vcrpy, т.е. без хождения в настоящие back-end’ы.

Билд сервер

Monitorrent – кроссплатформенное приложение. Поэтому нам понадобилось 2 билд сервера: один на Windows – это ci.appveyor.com, второй — travis-ci.org на Linux. Appveyor собирает исталлятор для Windows при каждом билде. Но основной – это travis, он не только запускает тесты, но и загружает отчёты по покрытию кода в coveralls.io и codecov.io.

В планах подключить drone.io для автоматической сборки docker контейнеров для x86/x64 и для ARM. Насколько я понял, он это умеет. Если у кого есть опыт, поделитесь пожалуйста в комментариях.

Система контроля версий

Хотелось бы добавить пару слов про систему контроля версий. Мы пользуемся git flow и хостим все исходники на github. В ветке master есть мерджи только релизов, все мерджи помечены тегами и доступны с кратким описанием и списком закрытых issue. Вся текущая разработка ведётся в ветке develop.

Именование версий ведётся по Semantic Versioning. Последняя версия на момент написания статьи — 1.0.0. До неё было 4 релиз кандидата, несколько альф и бет.

Для планирования следующих версий мы пользуемся плагином ZenHub для Chrome & Firefox, который добавляет вкладки Boards и Burndown прямо на страницу github и позволяет организовать все issue. До этого мы пытались воспользоваться waffle.io, но остановились на ZenHub.

Заключение

Monitorrent работает бесперебойно уже больше 9 недель со дня релиза.

До этого была одна большая проблема с периодическим зависанием поиска изменений. Она проявлялась только на очень нестабильном соединении с интернетом. Пока к нам в дом вели оптику, разрывы случались каждые несколько минут. Если в этот момент Monitorrent проверял наличие новых серий, то был большой шанс, что он просто зависнет в модуле requests. Почему-то соединение не сбрасывалось по таймауту. Воспроизвести проблему не удавалось на Windows, зато легко воспроизводилось на cubietruck. Выключение модема или разрыв соединения с интернетом вручную тоже не позволяли воспроизвести зависание.

Проблема решилась явным указанием таймаута запроса. Сейчас таймаут равен 10 секундам, и это значение хранится в базе, UI для его редактирования пока отсутствует.

Есть ещё пару не раскрытых тем (логирование, UX вопросы и их решение и другое), но статья и так получилось очень большой и слегка сумбурной, оставлю это на следующий раз.

Планов по развитию много, можно следить за тикетом на github. Также приветствуются советы, pull request’ы и предложения по улучшению, создавайте тикеты на github’е. Или просто запросы на добавление новых трекеров.

Спасибо за то, что дочитали до конца. Надеюсь вам понравится Monitorrent.

Автор: werwolfby

Источник

Поделиться новостью

* - обязательные к заполнению поля