mirror of
https://github.com/Cheviiot/Kadr.git
synced 2026-08-03 15:57:22 +00:00
Add torrent streamer, UI overhaul, switch to GPL
Major updates: adds a libtorrent-based streaming service and refactors UI/behaviour. Key changes: - Add streaming service (src/kadr/services/streamer.py): HTTP server + sequential download streamer using libtorrent, StreamLog, buffering/ready states and local streaming URL. - Rework Detail view (src/kadr/views/detail.py): new hero/backdrop layout, poster sizing, metadata chips, tagline, overview, cast placeholder and a "Watch" action that navigates to a Torrents view; loads full TMDB details/credits asynchronously. - UI styling (src/kadr/data/style.css): many new classes for detail/hero/cast styling. - Downloads manager improvements (src/kadr/services/downloads.py): temp dir for torrents, cleanup of stale files, history size cap, unified client launch with error handling. - TMDB client refactor (src/kadr/services/tmdb.py): unified _fetch helper, added movie_credits/tv_credits endpoints. - Utils improvements (src/kadr/utils.py): thread-safe LRU image cache, image download concurrency, and slight clipboard command ordering tweak. - Jackett servers list updated (src/kadr/services/jackett.py): reorder/add servers. - Project metadata & licensing: switch LICENSE to GPL-3.0-or-later, update pyproject.toml and application about dialog to GPL, adjust package/version in pyproject and __init__. - README revamped (README.md): reorganized, added badges, Russian text, install/usage sections. - Added new view/widget files (player/torrents/mpv_widget) and other UI tweaks across widgets/window. These changes add streaming playback capability, tighten resource handling, and refresh the detail UI while changing the project license to GPL-3.0-or-later.
This commit is contained in:
@@ -1,21 +1,39 @@
|
|||||||
MIT License
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
Copyright (c) 2026 Cheviiot (https://github.com/Cheviiot)
|
Copyright (C) 2026 Cheviiot
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
This program is free software: you can redistribute it and/or modify
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
it under the terms of the GNU General Public License as published by
|
||||||
in the Software without restriction, including without limitation the rights
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
(at your option) any later version.
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
This program is distributed in the hope that it will be useful,
|
||||||
copies or substantial portions of the Software.
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
You should have received a copy of the GNU General Public License
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
DETAILED TERMS AND CONDITIONS
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
The full text of the GNU General Public License version 3 is available at:
|
||||||
|
<https://www.gnu.org/licenses/gpl-3.0.txt>
|
||||||
|
|||||||
@@ -1,127 +1,99 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="data/Kadr.png" alt="Kadr" width="128">
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Kadr</h1>
|
<h1 align="center">Kadr</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>🎬 Элегантное приложение для поиска торрентов фильмов и сериалов</strong>
|
Поиск торрентов фильмов и сериалов через TMDB и Jackett
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/Cheviiot/Kadr/releases"><img src="https://img.shields.io/github/v/release/Cheviiot/Kadr?style=flat-square&color=blue" alt="Release"></a>
|
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square" alt="GTK 4">
|
||||||
<a href="https://github.com/Cheviiot/Kadr/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Cheviiot/Kadr?style=flat-square" alt="License"></a>
|
<img src="https://img.shields.io/badge/Libadwaita-1-3584e4?style=flat-square" alt="Libadwaita">
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.10+-3776ab?style=flat-square" alt="Python 3.10+">
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-green?style=flat-square" alt="GPL-3.0"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#-установка">Установка</a> •
|
|
||||||
<a href="#-особенности">Особенности</a> •
|
|
||||||
<a href="#-запуск">Запуск</a> •
|
|
||||||
<a href="#-лицензия">Лицензия</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
## О проекте
|
||||||
|
|
||||||
## ✨ Особенности
|
Kadr — приложение для поиска торрентов фильмов и сериалов.
|
||||||
|
Показывает каталог из TMDB с постерами и рейтингами, ищет торренты через публичные
|
||||||
|
Jackett-серверы и отправляет загрузку в любимый торрент-клиент — всё из одного окна.
|
||||||
|
|
||||||
- 🔍 **Поиск фильмов и сериалов** — интеграция с TMDB
|
## Возможности
|
||||||
- 🧲 **Поиск торрентов** — через публичные Jackett серверы (без настройки!)
|
|
||||||
- ⬇️ **Загрузка в один клик** — отправка в FDM, qBittorrent, Transmission, Deluge, KTorrent
|
|
||||||
- 🎨 **Нативный интерфейс** — GTK4 + libadwaita
|
|
||||||
- 🚀 **Быстрая работа** — Python, асинхронная загрузка изображений
|
|
||||||
|
|
||||||
---
|
- **Каталог** — популярные фильмы и сериалы из TMDB, постеры, рейтинги, бесконечная подгрузка
|
||||||
|
- **Поиск** — поиск по названию в реальном времени с пагинацией
|
||||||
|
- **Торренты** — поиск через публичные Jackett-серверы (jacred.xyz, jac-red.ru, jac.red), без настройки
|
||||||
|
- **Загрузка** — отправка в FDM, qBittorrent, Transmission, Deluge, KTorrent одним кликом
|
||||||
|
- **Автовыбор** — автоматический выбор работающего сервера, прокси и клиента
|
||||||
|
- **Настройки** — выбор сервера, TMDB-прокси, торрент-клиента с проверкой доступности
|
||||||
|
- **Локализация** — интерфейс на русском языке
|
||||||
|
|
||||||
## 🛠️ Технологии
|
## Установка
|
||||||
|
|
||||||
| Компонент | Стек |
|
|
||||||
|-----------|------|
|
|
||||||
| UI | GTK4 + libadwaita |
|
|
||||||
| Язык | Python 3.10+ |
|
|
||||||
| HTTP | requests |
|
|
||||||
| API | TMDB, Jackett |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Установка зависимостей
|
|
||||||
|
|
||||||
### Fedora
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install gtk4-devel libadwaita-devel python3-gobject python3-requests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ubuntu / Debian
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install libgtk-4-dev libadwaita-1-dev gir1.2-adw-1 python3-gi python3-requests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -S gtk4 libadwaita python-gobject python-requests
|
|
||||||
```
|
|
||||||
|
|
||||||
Или через pip (PyGObject требует системных библиотек GTK):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install requests PyGObject
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Запуск
|
|
||||||
|
|
||||||
### Из исходников
|
### Из исходников
|
||||||
|
|
||||||
```bash
|
```
|
||||||
git clone https://github.com/Cheviiot/Kadr.git
|
git clone https://github.com/Cheviiot/Kadr.git
|
||||||
cd Kadr
|
cd Kadr
|
||||||
./kadr
|
./kadr
|
||||||
```
|
```
|
||||||
|
|
||||||
**Горячие клавиши:**
|
<details>
|
||||||
- `Ctrl+F` — Поиск
|
<summary><strong>Зависимости</strong></summary>
|
||||||
- `Ctrl+Q` — Выход
|
|
||||||
|
|
||||||
---
|
| Дистрибутив | Команда |
|
||||||
|
|------------------|---------------------------------------------------------------------------------|
|
||||||
|
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` |
|
||||||
|
| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-requests` |
|
||||||
|
| Arch | `pacman -S python-gobject gtk4 libadwaita python-requests` |
|
||||||
|
| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 python3-requests` |
|
||||||
|
|
||||||
## 📁 Структура проекта
|
</details>
|
||||||
|
|
||||||
```
|
## Использование
|
||||||
kadr # Исполняемый лаунчер
|
|
||||||
pyproject.toml # Сборка (hatchling)
|
|
||||||
data/
|
|
||||||
Kadr.png # Иконка приложения
|
|
||||||
io.github.cheviiot.kadr.desktop
|
|
||||||
src/kadr/
|
|
||||||
__init__.py # Версия, пути, константы
|
|
||||||
__main__.py # Entry point, проверка gi
|
|
||||||
application.py # Adw.Application
|
|
||||||
window.py # Главное окно
|
|
||||||
utils.py # Утилиты (async, кеш изображений)
|
|
||||||
data/
|
|
||||||
style.css # Кастомные стили
|
|
||||||
services/
|
|
||||||
settings.py # Настройки (~/.config/Kadr/)
|
|
||||||
tmdb.py # TMDB API
|
|
||||||
jackett.py # Jackett торрент-поиск
|
|
||||||
downloads.py # Менеджер загрузок
|
|
||||||
views/
|
|
||||||
home.py # Главный вид (сетка, поиск, табы)
|
|
||||||
detail.py # Детали фильма/сериала + торренты
|
|
||||||
settings_dialog.py # Диалог настроек
|
|
||||||
widgets/
|
|
||||||
media_card.py # Карточка фильма/сериала
|
|
||||||
torrent_row.py # Строка торрента
|
|
||||||
archive/ # Предыдущая версия (Go + Wails v3)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
### Как работает Kadr
|
||||||
|
|
||||||
## 📄 Лицензия
|
| Шаг | | |
|
||||||
|
|---|---|---|
|
||||||
|
| **1** | **Выбери фильм или сериал** | Найди в каталоге или через поиск (Ctrl+F) |
|
||||||
|
| **2** | **Открой детали** | Нажми на карточку — откроется страница с описанием |
|
||||||
|
| **3** | **Найди торрент** | Торренты загружаются автоматически с Jackett-серверов |
|
||||||
|
| **4** | **Скачай** | Нажми кнопку загрузки — файл откроется в торрент-клиенте |
|
||||||
|
|
||||||
[MIT](LICENSE)
|
### Горячие клавиши
|
||||||
|
|
||||||
---
|
| Действие | Комбинация |
|
||||||
|
|-------------|-------------|
|
||||||
|
| Поиск | `Ctrl+F` |
|
||||||
|
| Настройки | `Ctrl+,` |
|
||||||
|
| Выход | `Ctrl+Q` |
|
||||||
|
|
||||||
<p align="center">
|
## Настройки
|
||||||
Made with ❤️ by <a href="https://github.com/Cheviiot">Cheviiot</a>
|
|
||||||
</p>
|
| Параметр | Варианты | По умолчанию |
|
||||||
|
|------------------|-------------------------------------------|----------------|
|
||||||
|
| Торрент-сервер | Автоматически / jacred.xyz / jac-red.ru / jac.red | Автоматически |
|
||||||
|
| TMDB-прокси | Автоматически / Напрямую / APN Render | Автоматически |
|
||||||
|
| Торрент-клиент | Автоматически / FDM / qBittorrent / Transmission / Deluge / KTorrent | Автоматически |
|
||||||
|
|
||||||
|
## Данные
|
||||||
|
|
||||||
|
| Путь | Содержимое |
|
||||||
|
|-----------------------------------|----------------------|
|
||||||
|
| `~/.config/Kadr/settings.json` | Настройки |
|
||||||
|
|
||||||
|
## Участие в разработке
|
||||||
|
|
||||||
|
> Это личный проект. Kadr создаётся одним человеком для собственного использования при помощи ИИ.
|
||||||
|
|
||||||
|
Код полностью открыт. Если хотите помочь — Pull Request и Issue приветствуются:
|
||||||
|
исправление ошибок, новые функции, переводы.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
[GPL-3.0-or-later](LICENSE)
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kadr"
|
name = "kadr"
|
||||||
version = "2.0.0"
|
version = "0.1.0"
|
||||||
description = "Movie & TV torrent search application"
|
description = "Movie & TV torrent search application"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "GPL-3.0-or-later"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Cheviiot" }]
|
authors = [{ name = "Cheviiot" }]
|
||||||
keywords = ["movies", "torrents", "tmdb", "jackett", "gtk4", "libadwaita"]
|
keywords = ["movies", "torrents", "tmdb", "jackett", "gtk4", "libadwaita"]
|
||||||
@@ -11,7 +11,7 @@ classifiers = [
|
|||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: X11 Applications :: GTK",
|
"Environment :: X11 Applications :: GTK",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"Intended Audience :: End Users/Desktop",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__version__ = '2.0.0'
|
__version__ = '0.1.0'
|
||||||
APP_ID = 'io.github.cheviiot.kadr'
|
APP_ID = 'io.github.cheviiot.kadr'
|
||||||
APP_NAME = 'Kadr'
|
APP_NAME = 'Kadr'
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ class KadrApplication(Adw.Application):
|
|||||||
developer_name='Cheviiot',
|
developer_name='Cheviiot',
|
||||||
website='https://github.com/Cheviiot/Kadr',
|
website='https://github.com/Cheviiot/Kadr',
|
||||||
issue_url='https://github.com/Cheviiot/Kadr/issues',
|
issue_url='https://github.com/Cheviiot/Kadr/issues',
|
||||||
license_type=Gtk.License.MIT_X11,
|
license_type=Gtk.License.GPL_3_0,
|
||||||
developers=['Cheviiot'],
|
developers=['Cheviiot'],
|
||||||
copyright='© 2024–2026 Cheviiot',
|
copyright='© 2026 Cheviiot',
|
||||||
)
|
)
|
||||||
about.present()
|
about.present()
|
||||||
|
|||||||
@@ -88,3 +88,72 @@ flowboxchild:selected {
|
|||||||
.error-label {
|
.error-label {
|
||||||
color: @error_color;
|
color: @error_color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Detail View — Streaming-Platform Hero ────────────────────────────────── */
|
||||||
|
|
||||||
|
.detail-backdrop {
|
||||||
|
background-color: alpha(@card_shade_color, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-hero-gradient {
|
||||||
|
background: linear-gradient(to bottom,
|
||||||
|
alpha(@window_bg_color, 0.0) 0%,
|
||||||
|
alpha(@window_bg_color, 0.40) 50%,
|
||||||
|
@window_bg_color 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-poster-frame {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px alpha(black, 0.35), 0 0 0 1px alpha(black, 0.08);
|
||||||
|
background: @card_bg_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 2px 8px alpha(black, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tagline {
|
||||||
|
font-size: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
color: alpha(white, 0.7);
|
||||||
|
text-shadow: 0 1px 4px alpha(black, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chip {
|
||||||
|
background: alpha(white, 0.15);
|
||||||
|
color: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 3px alpha(black, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-rating {
|
||||||
|
background: alpha(rgba(245, 197, 24, 0.8), 0.25);
|
||||||
|
color: rgba(255, 220, 80, 1);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cast Cards ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cast-avatar-frame {
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 80px;
|
||||||
|
min-height: 80px;
|
||||||
|
background: alpha(@card_shade_color, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-3 {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ DOWNLOAD_CLIENTS = [
|
|||||||
{'id': 'ktorrent', 'name': 'KTorrent', 'command': 'ktorrent'},
|
{'id': 'ktorrent', 'name': 'KTorrent', 'command': 'ktorrent'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_HISTORY_MAX = 100
|
||||||
|
|
||||||
|
|
||||||
class DownloadManager:
|
class DownloadManager:
|
||||||
def __init__(self, settings=None):
|
def __init__(self, settings=None):
|
||||||
@@ -27,19 +29,45 @@ class DownloadManager:
|
|||||||
self._data_file = os.path.join(self._data_dir, 'downloads.json')
|
self._data_file = os.path.join(self._data_dir, 'downloads.json')
|
||||||
self._history = self._load_history()
|
self._history = self._load_history()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._tmp_dir = os.path.join(tempfile.gettempdir(), 'kadr_torrents')
|
||||||
|
self._cleanup_temp()
|
||||||
|
|
||||||
def _load_history(self):
|
def _load_history(self):
|
||||||
try:
|
try:
|
||||||
with open(self._data_file) as f:
|
with open(self._data_file) as f:
|
||||||
return json.load(f)
|
data = json.load(f)
|
||||||
|
return data[-_HISTORY_MAX:] if len(data) > _HISTORY_MAX else data
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _save_history(self):
|
def _save_history(self):
|
||||||
os.makedirs(self._data_dir, exist_ok=True)
|
os.makedirs(self._data_dir, exist_ok=True)
|
||||||
|
self._history = self._history[-_HISTORY_MAX:]
|
||||||
with open(self._data_file, 'w') as f:
|
with open(self._data_file, 'w') as f:
|
||||||
json.dump(self._history, f, indent=2, ensure_ascii=False)
|
json.dump(self._history, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _cleanup_temp(self):
|
||||||
|
"""Remove stale temp torrent files older than 1 hour."""
|
||||||
|
if not os.path.isdir(self._tmp_dir):
|
||||||
|
return
|
||||||
|
cutoff = time.time() - 3600
|
||||||
|
for name in os.listdir(self._tmp_dir):
|
||||||
|
path = os.path.join(self._tmp_dir, name)
|
||||||
|
try:
|
||||||
|
if os.path.getmtime(path) < cutoff:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _launch_client(self, client, arg):
|
||||||
|
"""Launch torrent client and raise on failure."""
|
||||||
|
try:
|
||||||
|
subprocess.Popen([client['command'], arg])
|
||||||
|
except OSError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Не удалось запустить {client["name"]}: {e}'
|
||||||
|
)
|
||||||
|
|
||||||
def available_clients(self):
|
def available_clients(self):
|
||||||
return [c for c in DOWNLOAD_CLIENTS if shutil.which(c['command'])]
|
return [c for c in DOWNLOAD_CLIENTS if shutil.which(c['command'])]
|
||||||
|
|
||||||
@@ -64,7 +92,8 @@ class DownloadManager:
|
|||||||
'Торрент-клиент не найден. '
|
'Торрент-клиент не найден. '
|
||||||
'Установите qBittorrent, Transmission или другой клиент.'
|
'Установите qBittorrent, Transmission или другой клиент.'
|
||||||
)
|
)
|
||||||
subprocess.Popen([client['command'], magnet_uri])
|
|
||||||
|
self._launch_client(client, magnet_uri)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -94,14 +123,14 @@ class DownloadManager:
|
|||||||
if not safe_name.lower().endswith('.torrent'):
|
if not safe_name.lower().endswith('.torrent'):
|
||||||
safe_name += '.torrent'
|
safe_name += '.torrent'
|
||||||
|
|
||||||
tmp_dir = os.path.join(tempfile.gettempdir(), 'kadr_torrents')
|
tmp_dir = self._tmp_dir
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
path = os.path.join(tmp_dir, safe_name)
|
path = os.path.join(tmp_dir, safe_name)
|
||||||
|
|
||||||
with open(path, 'wb') as f:
|
with open(path, 'wb') as f:
|
||||||
f.write(resp.content)
|
f.write(resp.content)
|
||||||
|
|
||||||
subprocess.Popen([client['command'], path])
|
self._launch_client(client, path)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import requests
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
JACKETT_SERVERS = [
|
JACKETT_SERVERS = [
|
||||||
{'id': 'jacred_xyz', 'name': 'Jacred.xyz', 'url': 'jacred.xyz', 'key': ''},
|
|
||||||
{'id': 'jac_red_ru', 'name': 'Jac-red.ru', 'url': 'jac-red.ru', 'key': ''},
|
|
||||||
{'id': 'jac_red', 'name': 'Jac.red', 'url': 'jac.red', 'key': ''},
|
{'id': 'jac_red', 'name': 'Jac.red', 'url': 'jac.red', 'key': ''},
|
||||||
|
{'id': 'jac_red_ru', 'name': 'Jac-red.ru', 'url': 'jac-red.ru', 'key': ''},
|
||||||
|
{'id': 'pro_jacred_ru', 'name': 'pro.jacred.ru', 'url': 'pro.jacred.ru', 'key': ''},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""Torrent streaming service — sequential download + embedded mpv playback."""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socketserver
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
|
||||||
|
class StreamLog:
|
||||||
|
"""Thread-safe log collector for streaming diagnostics."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lines = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._on_update = None
|
||||||
|
|
||||||
|
def set_on_update(self, cb):
|
||||||
|
"""cb(line: str) called on GTK main thread for each new line."""
|
||||||
|
self._on_update = cb
|
||||||
|
|
||||||
|
def log(self, msg):
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||||
|
line = f'[{ts}] {msg}'
|
||||||
|
with self._lock:
|
||||||
|
self._lines.append(line)
|
||||||
|
cb = self._on_update
|
||||||
|
if cb:
|
||||||
|
GLib.idle_add(cb, line)
|
||||||
|
|
||||||
|
def text(self):
|
||||||
|
with self._lock:
|
||||||
|
return '\n'.join(self._lines)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
with self._lock:
|
||||||
|
self._lines.clear()
|
||||||
|
|
||||||
|
_VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.webm', '.ts', '.m4v'}
|
||||||
|
|
||||||
|
# Minimum bytes buffered before launching player
|
||||||
|
_BUFFER_THRESHOLD = 20 * 1024 * 1024 # 20 MB
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""Serves a growing video file, blocking reads past the download frontier."""
|
||||||
|
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
srv = self.server
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'video/mp4')
|
||||||
|
self.send_header('Content-Length', str(srv.total_size))
|
||||||
|
self.send_header('Accept-Ranges', 'bytes')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
srv = self.server
|
||||||
|
total = srv.total_size
|
||||||
|
if total <= 0:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
start, end = 0, total - 1
|
||||||
|
range_hdr = self.headers.get('Range')
|
||||||
|
if range_hdr and range_hdr.startswith('bytes='):
|
||||||
|
try:
|
||||||
|
rng = range_hdr[6:]
|
||||||
|
s, e = rng.split('-', 1)
|
||||||
|
start = int(s) if s else 0
|
||||||
|
end = int(e) if e else total - 1
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
start = max(0, min(start, total - 1))
|
||||||
|
end = max(start, min(end, total - 1))
|
||||||
|
|
||||||
|
# If the request starts beyond the download frontier, wait briefly
|
||||||
|
# then fail — this handles mpv probing the end of file for index data
|
||||||
|
# (moov atom in MP4, Cues in MKV) without deadlocking.
|
||||||
|
if start >= srv.available and not srv.complete:
|
||||||
|
waited = 0.0
|
||||||
|
while srv.available <= start and not srv.stopped and waited < 3.0:
|
||||||
|
if srv.complete:
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
waited += 0.3
|
||||||
|
if srv.available <= start and not srv.complete:
|
||||||
|
self.send_response(416)
|
||||||
|
self.send_header(
|
||||||
|
'Content-Range', f'bytes */{total}',
|
||||||
|
)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
if range_hdr:
|
||||||
|
self.send_response(206)
|
||||||
|
self.send_header(
|
||||||
|
'Content-Range', f'bytes {start}-{end}/{total}',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.send_response(200)
|
||||||
|
|
||||||
|
length = end - start + 1
|
||||||
|
self.send_header('Content-Type', 'video/mp4')
|
||||||
|
self.send_header('Content-Length', str(length))
|
||||||
|
self.send_header('Accept-Ranges', 'bytes')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
chunk_size = 128 * 1024
|
||||||
|
pos = start
|
||||||
|
try:
|
||||||
|
with open(srv.video_path, 'rb') as f:
|
||||||
|
f.seek(pos)
|
||||||
|
while pos <= end:
|
||||||
|
# Wait for data to become available (sequential download)
|
||||||
|
need = pos + 1
|
||||||
|
waited = 0.0
|
||||||
|
while srv.available < need and not srv.stopped:
|
||||||
|
if srv.complete:
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
waited += 0.3
|
||||||
|
# Timeout for non-sequential reads (30s)
|
||||||
|
if waited > 30.0:
|
||||||
|
return
|
||||||
|
if srv.stopped:
|
||||||
|
return
|
||||||
|
# Read up to available data or chunk_size
|
||||||
|
readable = min(srv.available - pos, chunk_size, end - pos + 1)
|
||||||
|
if srv.complete:
|
||||||
|
readable = min(chunk_size, end - pos + 1)
|
||||||
|
if readable <= 0:
|
||||||
|
readable = min(chunk_size, end - pos + 1)
|
||||||
|
data = f.read(readable)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
self.wfile.write(data)
|
||||||
|
pos += len(data)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingServer(socketserver.ThreadingTCPServer):
|
||||||
|
"""Local HTTP server that serves a torrent video file as it downloads."""
|
||||||
|
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(('127.0.0.1', 0), _StreamingHandler)
|
||||||
|
self.video_path = ''
|
||||||
|
self.total_size = 0
|
||||||
|
self.available = 0
|
||||||
|
self.complete = False
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
port = self.server_address[1]
|
||||||
|
return f'http://127.0.0.1:{port}/video'
|
||||||
|
|
||||||
|
def configure(self, path, total_size, initial_available):
|
||||||
|
self.video_path = path
|
||||||
|
self.total_size = total_size
|
||||||
|
self.available = initial_available
|
||||||
|
self.complete = False
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
def start_serving(self):
|
||||||
|
threading.Thread(target=self.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def stop_serving(self):
|
||||||
|
self.stopped = True
|
||||||
|
try:
|
||||||
|
self.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import libtorrent as lt
|
||||||
|
LIBTORRENT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
lt = None
|
||||||
|
LIBTORRENT_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
class TorrentStreamer:
|
||||||
|
"""Manages a single torrent streaming session (download only)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
cache_base = os.environ.get(
|
||||||
|
'XDG_CACHE_HOME', os.path.expanduser('~/.cache'),
|
||||||
|
)
|
||||||
|
self._save_dir = os.path.join(cache_base, 'kadr', 'streaming')
|
||||||
|
self._session = None
|
||||||
|
self._handle = None
|
||||||
|
self._monitor_thread = None
|
||||||
|
self._stopping = False
|
||||||
|
self._status_cb = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._server = None
|
||||||
|
self.log = StreamLog()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._handle is not None and not self._stopping
|
||||||
|
|
||||||
|
def start(self, magnet_uri, status_cb=None, torrent_link=None):
|
||||||
|
"""Start streaming from a magnet URI or .torrent link.
|
||||||
|
|
||||||
|
If *torrent_link* is provided, attempts to download the .torrent file
|
||||||
|
first (avoids the slow DHT metadata phase). Falls back to magnet.
|
||||||
|
|
||||||
|
status_cb(state: str, detail: str) is called on the GTK main thread.
|
||||||
|
States: 'metadata', 'buffering', 'ready', 'downloading', 'error', 'stopped'.
|
||||||
|
The 'ready' state carries the video file path as detail.
|
||||||
|
"""
|
||||||
|
if not LIBTORRENT_AVAILABLE:
|
||||||
|
raise RuntimeError('libtorrent не установлен')
|
||||||
|
if self.is_active:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
self._stopping = False
|
||||||
|
self._status_cb = status_cb
|
||||||
|
self.log.clear()
|
||||||
|
self.log.log(f'start: magnet={bool(magnet_uri)} link={bool(torrent_link)}')
|
||||||
|
self.log.log(f'save_dir={self._save_dir}')
|
||||||
|
os.makedirs(self._save_dir, exist_ok=True)
|
||||||
|
|
||||||
|
self._session = lt.session({
|
||||||
|
'listen_interfaces': '0.0.0.0:6881,[::]:6891',
|
||||||
|
'alert_mask': (
|
||||||
|
lt.alert.category_t.status_notification
|
||||||
|
| lt.alert.category_t.dht_notification
|
||||||
|
| lt.alert.category_t.error_notification
|
||||||
|
),
|
||||||
|
'enable_dht': True,
|
||||||
|
'enable_lsd': True,
|
||||||
|
'dht_bootstrap_nodes': (
|
||||||
|
'router.bittorrent.com:6881,'
|
||||||
|
'router.utorrent.com:6881,'
|
||||||
|
'dht.transmissionbt.com:6881,'
|
||||||
|
'dht.libtorrent.org:25401'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
self._session.apply_settings({
|
||||||
|
'active_downloads': 1,
|
||||||
|
'active_seeds': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try .torrent file first (already contains metadata → instant start)
|
||||||
|
self.log.log(f'trying .torrent link: {torrent_link[:120] if torrent_link else "(none)"}')
|
||||||
|
atp = self._atp_from_link(torrent_link) if torrent_link else None
|
||||||
|
|
||||||
|
if atp is not None:
|
||||||
|
self.log.log('torrent file parsed — metadata available immediately')
|
||||||
|
elif magnet_uri:
|
||||||
|
self.log.log('falling back to magnet URI')
|
||||||
|
atp = lt.parse_magnet_uri(magnet_uri)
|
||||||
|
self._emit('metadata', 'Получение метаданных…')
|
||||||
|
else:
|
||||||
|
self.log.log('ERROR: no link and no magnet')
|
||||||
|
|
||||||
|
if atp is None:
|
||||||
|
self._emit('error', 'Нет ссылки для загрузки')
|
||||||
|
return
|
||||||
|
|
||||||
|
atp.save_path = self._save_dir
|
||||||
|
self._handle = self._session.add_torrent(atp)
|
||||||
|
self._handle.set_sequential_download(True)
|
||||||
|
self._handle.force_reannounce()
|
||||||
|
self.log.log('torrent added to session, monitor starting')
|
||||||
|
|
||||||
|
self._monitor_thread = threading.Thread(
|
||||||
|
target=self._monitor_loop, daemon=True,
|
||||||
|
)
|
||||||
|
self._monitor_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop streaming and clean up."""
|
||||||
|
self.log.log('stop() called')
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping:
|
||||||
|
self.log.log('stop() — already stopping')
|
||||||
|
return
|
||||||
|
self._stopping = True
|
||||||
|
|
||||||
|
if self._server:
|
||||||
|
self._server.stop_serving()
|
||||||
|
self._server = None
|
||||||
|
|
||||||
|
if self._session and self._handle:
|
||||||
|
try:
|
||||||
|
self._session.remove_torrent(self._handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._handle = None
|
||||||
|
self._session = None
|
||||||
|
self._emit('stopped', '')
|
||||||
|
|
||||||
|
def cleanup_files(self):
|
||||||
|
"""Remove cached streaming files."""
|
||||||
|
if os.path.isdir(self._save_dir):
|
||||||
|
for name in os.listdir(self._save_dir):
|
||||||
|
path = os.path.join(self._save_dir, name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Internal ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _atp_from_link(self, link):
|
||||||
|
"""Download a .torrent file from *link* and return add_torrent_params, or None."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(link, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.content
|
||||||
|
self.log.log(f'torrent download: {len(data)} bytes, status={resp.status_code}')
|
||||||
|
if not data or data[0:1] != b'd':
|
||||||
|
self.log.log(f'torrent data invalid: starts with {data[:10]!r}')
|
||||||
|
return None
|
||||||
|
ti = lt.torrent_info(lt.bdecode(data))
|
||||||
|
self.log.log(f'torrent parsed: "{ti.name()}", {ti.files().num_files()} files')
|
||||||
|
atp = lt.add_torrent_params()
|
||||||
|
atp.ti = ti
|
||||||
|
return atp
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.log(f'torrent download failed: {exc}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _emit(self, state, detail):
|
||||||
|
self.log.log(f'emit: state={state} detail={detail}')
|
||||||
|
cb = self._status_cb
|
||||||
|
if cb:
|
||||||
|
GLib.idle_add(cb, state, detail)
|
||||||
|
|
||||||
|
def _find_video_file(self, torrent_info):
|
||||||
|
"""Return (file_index, file_entry) for the largest video file."""
|
||||||
|
best_idx, best_size = -1, 0
|
||||||
|
fs = torrent_info.files()
|
||||||
|
for i in range(fs.num_files()):
|
||||||
|
path = fs.file_path(i)
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext in _VIDEO_EXTENSIONS and fs.file_size(i) > best_size:
|
||||||
|
best_idx = i
|
||||||
|
best_size = fs.file_size(i)
|
||||||
|
return best_idx
|
||||||
|
|
||||||
|
def _prioritize_video(self, torrent_info, video_idx):
|
||||||
|
"""Set only the video file to highest priority, skip others."""
|
||||||
|
num_files = torrent_info.files().num_files()
|
||||||
|
priorities = [0] * num_files
|
||||||
|
priorities[video_idx] = 7
|
||||||
|
self._handle.prioritize_files(priorities)
|
||||||
|
|
||||||
|
def _start_server(self, path, total_size, initial_available):
|
||||||
|
"""Create and start the local HTTP streaming server."""
|
||||||
|
self._server = _StreamingServer()
|
||||||
|
self._server.configure(path, total_size, initial_available)
|
||||||
|
self._server.start_serving()
|
||||||
|
self.log.log(f'HTTP streaming: {self._server.url} (size={total_size})')
|
||||||
|
|
||||||
|
def _monitor_loop(self):
|
||||||
|
ready_emitted = False
|
||||||
|
video_idx = -1
|
||||||
|
video_path = None
|
||||||
|
video_size = 0
|
||||||
|
|
||||||
|
while not self._stopping:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if not self._handle or not self._handle.is_valid():
|
||||||
|
break
|
||||||
|
|
||||||
|
status = self._handle.status()
|
||||||
|
|
||||||
|
# ── Waiting for metadata ──
|
||||||
|
if not status.has_metadata:
|
||||||
|
self.log.log(f'waiting metadata: peers={status.num_peers}')
|
||||||
|
self._emit('metadata', 'Получение метаданных…')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Find video file once ──
|
||||||
|
if video_idx < 0:
|
||||||
|
ti = self._handle.torrent_file()
|
||||||
|
video_idx = self._find_video_file(ti)
|
||||||
|
if video_idx < 0:
|
||||||
|
self._emit('error', 'Видеофайл не найден в торренте')
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
self._prioritize_video(ti, video_idx)
|
||||||
|
video_path = os.path.join(
|
||||||
|
self._save_dir, ti.files().file_path(video_idx),
|
||||||
|
)
|
||||||
|
video_size = ti.files().file_size(video_idx)
|
||||||
|
self.log.log(f'video file idx={video_idx} size={video_size} path={video_path}')
|
||||||
|
|
||||||
|
# ── Buffering / downloading stats ──
|
||||||
|
done = status.total_wanted_done
|
||||||
|
rate = status.download_rate
|
||||||
|
rate_str = f'{rate / 1024:.0f} КБ/с' if rate > 0 else ''
|
||||||
|
pct = status.progress * 100
|
||||||
|
|
||||||
|
if not ready_emitted:
|
||||||
|
exists = os.path.exists(video_path)
|
||||||
|
self.log.log(f'buffer: done={done} threshold={_BUFFER_THRESHOLD} exists={exists}')
|
||||||
|
if done >= _BUFFER_THRESHOLD and exists:
|
||||||
|
self._start_server(video_path, video_size, done)
|
||||||
|
self._emit('ready', self._server.url)
|
||||||
|
ready_emitted = True
|
||||||
|
else:
|
||||||
|
buf_pct = min(100, done * 100 // _BUFFER_THRESHOLD) if _BUFFER_THRESHOLD else 100
|
||||||
|
self._emit(
|
||||||
|
'buffering',
|
||||||
|
f'Буферизация {buf_pct}% {rate_str}',
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Update streaming server with download progress ──
|
||||||
|
if self._server:
|
||||||
|
self._server.available = done
|
||||||
|
if pct >= 100:
|
||||||
|
self._server.complete = True
|
||||||
|
|
||||||
|
# ── Downloading (player is active in UI) ──
|
||||||
|
self._emit('downloading', f'{pct:.1f}% {rate_str}')
|
||||||
|
|
||||||
|
if status.state in (lt.torrent_status.seeding, lt.torrent_status.finished):
|
||||||
|
self._emit('downloading', '100%')
|
||||||
|
# Keep session alive; UI will call stop() when user navigates back
|
||||||
|
while not self._stopping:
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Thread exits when stopping
|
||||||
|
if not self._stopping:
|
||||||
|
self.stop()
|
||||||
+18
-27
@@ -56,47 +56,38 @@ class TMDBService:
|
|||||||
return None
|
return None
|
||||||
return f"{self._proxy['image_url']}/t/p/{size}{path}"
|
return f"{self._proxy['image_url']}/t/p/{size}{path}"
|
||||||
|
|
||||||
def search_movies(self, query, page=1):
|
def _fetch(self, path, timeout=10, **params):
|
||||||
url = self._api_url('/search/movie', query=query, page=page)
|
url = self._api_url(path, **params)
|
||||||
resp = self.session.get(url, timeout=15)
|
resp = self.session.get(url, timeout=timeout)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
def search_movies(self, query, page=1):
|
||||||
|
return self._fetch('/search/movie', query=query, page=page)
|
||||||
|
|
||||||
def search_tv(self, query, page=1):
|
def search_tv(self, query, page=1):
|
||||||
url = self._api_url('/search/tv', query=query, page=page)
|
return self._fetch('/search/tv', query=query, page=page)
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def popular_movies(self, page=1):
|
def popular_movies(self, page=1):
|
||||||
url = self._api_url('/movie/popular', page=page)
|
return self._fetch('/movie/popular', page=page)
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def popular_tv(self, page=1):
|
def popular_tv(self, page=1):
|
||||||
url = self._api_url('/tv/popular', page=page)
|
return self._fetch('/tv/popular', page=page)
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def movie_details(self, movie_id):
|
def movie_details(self, movie_id):
|
||||||
url = self._api_url(f'/movie/{int(movie_id)}')
|
return self._fetch(f'/movie/{int(movie_id)}')
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def tv_details(self, tv_id):
|
def tv_details(self, tv_id):
|
||||||
url = self._api_url(f'/tv/{int(tv_id)}')
|
return self._fetch(f'/tv/{int(tv_id)}')
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
def movie_credits(self, movie_id):
|
||||||
return resp.json()
|
return self._fetch(f'/movie/{int(movie_id)}/credits')
|
||||||
|
|
||||||
|
def tv_credits(self, tv_id):
|
||||||
|
return self._fetch(f'/tv/{int(tv_id)}/credits')
|
||||||
|
|
||||||
def genres(self, media_type='movie'):
|
def genres(self, media_type='movie'):
|
||||||
url = self._api_url(f'/genre/{media_type}/list')
|
return self._fetch(f'/genre/{media_type}/list').get('genres', [])
|
||||||
resp = self.session.get(url, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json().get('genres', [])
|
|
||||||
|
|
||||||
def check_health(self, proxy_id):
|
def check_health(self, proxy_id):
|
||||||
proxy = TMDB_PROXIES.get(proxy_id)
|
proxy = TMDB_PROXIES.get(proxy_id)
|
||||||
|
|||||||
+14
-6
@@ -2,12 +2,15 @@ import os
|
|||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import collections
|
||||||
|
|
||||||
from gi.repository import GLib, GdkPixbuf, Gdk
|
from gi.repository import GLib, GdkPixbuf, Gdk
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
_image_cache = {}
|
_IMAGE_CACHE_MAX = 200
|
||||||
|
_image_cache = collections.OrderedDict()
|
||||||
|
_image_lock = threading.Lock()
|
||||||
_cache_dir = None
|
_cache_dir = None
|
||||||
_download_sem = threading.Semaphore(6)
|
_download_sem = threading.Semaphore(6)
|
||||||
|
|
||||||
@@ -31,9 +34,11 @@ def load_image_async(url, width, height, callback):
|
|||||||
|
|
||||||
cache_key = f'{width}x{height}:{url}'
|
cache_key = f'{width}x{height}:{url}'
|
||||||
|
|
||||||
if cache_key in _image_cache:
|
with _image_lock:
|
||||||
GLib.idle_add(callback, _image_cache[cache_key])
|
if cache_key in _image_cache:
|
||||||
return
|
_image_cache.move_to_end(cache_key)
|
||||||
|
GLib.idle_add(callback, _image_cache[cache_key])
|
||||||
|
return
|
||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +87,10 @@ def load_image_async(url, width, height, callback):
|
|||||||
|
|
||||||
if pixbuf:
|
if pixbuf:
|
||||||
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||||
_image_cache[cache_key] = texture
|
with _image_lock:
|
||||||
|
_image_cache[cache_key] = texture
|
||||||
|
if len(_image_cache) > _IMAGE_CACHE_MAX:
|
||||||
|
_image_cache.popitem(last=False)
|
||||||
GLib.idle_add(callback, texture)
|
GLib.idle_add(callback, texture)
|
||||||
else:
|
else:
|
||||||
GLib.idle_add(callback, None)
|
GLib.idle_add(callback, None)
|
||||||
@@ -107,9 +115,9 @@ def run_async(func, callback, *args):
|
|||||||
def copy_to_clipboard(text):
|
def copy_to_clipboard(text):
|
||||||
"""Copy text to clipboard using system tools."""
|
"""Copy text to clipboard using system tools."""
|
||||||
for cmd in (
|
for cmd in (
|
||||||
|
['wl-copy'],
|
||||||
['xclip', '-selection', 'clipboard'],
|
['xclip', '-selection', 'clipboard'],
|
||||||
['xsel', '--clipboard', '--input'],
|
['xsel', '--clipboard', '--input'],
|
||||||
['wl-copy'],
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||||
|
|||||||
+236
-188
@@ -3,19 +3,25 @@ gi.require_version('Gtk', '4.0')
|
|||||||
gi.require_version('Adw', '1')
|
gi.require_version('Adw', '1')
|
||||||
from gi.repository import Adw, Gtk
|
from gi.repository import Adw, Gtk
|
||||||
|
|
||||||
from kadr.utils import run_async, load_image_async, copy_to_clipboard
|
from kadr.utils import run_async, load_image_async
|
||||||
from kadr.widgets.torrent_row import TorrentRow
|
|
||||||
|
|
||||||
|
def _fmt_runtime(minutes):
|
||||||
|
if not minutes:
|
||||||
|
return ''
|
||||||
|
h, m = divmod(int(minutes), 60)
|
||||||
|
return f'{h} ч {m} мин' if h else f'{m} мин'
|
||||||
|
|
||||||
|
|
||||||
class DetailView:
|
class DetailView:
|
||||||
"""Detail view showing movie/TV info and torrent search results."""
|
"""Detail view styled as a streaming platform page."""
|
||||||
|
|
||||||
def __init__(self, window, media_type, data):
|
def __init__(self, window, media_type, data):
|
||||||
self.window = window
|
self.window = window
|
||||||
self.media_type = media_type
|
self.media_type = media_type
|
||||||
self.data = data
|
self.data = data
|
||||||
self._build()
|
self._build()
|
||||||
self._search_torrents()
|
self._load_details()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def page(self):
|
def page(self):
|
||||||
@@ -26,243 +32,285 @@ class DetailView:
|
|||||||
def _build(self):
|
def _build(self):
|
||||||
from kadr.widgets.media_card import _resolve_title
|
from kadr.widgets.media_card import _resolve_title
|
||||||
title, year_str = _resolve_title(self.data, self.media_type)
|
title, year_str = _resolve_title(self.data, self.media_type)
|
||||||
self._page = Adw.NavigationPage(title=title)
|
self._title = title
|
||||||
|
self._year_str = year_str
|
||||||
|
|
||||||
|
self._page = Adw.NavigationPage(title=title)
|
||||||
toolbar_view = Adw.ToolbarView()
|
toolbar_view = Adw.ToolbarView()
|
||||||
self._page.set_child(toolbar_view)
|
self._page.set_child(toolbar_view)
|
||||||
|
|
||||||
header = Adw.HeaderBar()
|
header = Adw.HeaderBar()
|
||||||
|
header.add_css_class('flat')
|
||||||
toolbar_view.add_top_bar(header)
|
toolbar_view.add_top_bar(header)
|
||||||
|
|
||||||
scrolled = Gtk.ScrolledWindow(vexpand=True)
|
scrolled = Gtk.ScrolledWindow(vexpand=True)
|
||||||
toolbar_view.set_content(scrolled)
|
toolbar_view.set_content(scrolled)
|
||||||
|
|
||||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
|
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
content.set_margin_start(24)
|
|
||||||
content.set_margin_end(24)
|
|
||||||
content.set_margin_top(24)
|
|
||||||
content.set_margin_bottom(24)
|
|
||||||
scrolled.set_child(content)
|
scrolled.set_child(content)
|
||||||
|
|
||||||
# ── Info section (poster + details) ───────────────────
|
# ── Hero section (backdrop + gradient + poster + info) ──
|
||||||
|
hero_overlay = Gtk.Overlay()
|
||||||
|
hero_overlay.set_size_request(-1, 420)
|
||||||
|
content.append(hero_overlay)
|
||||||
|
|
||||||
info_box = Gtk.Box(spacing=24)
|
# Backdrop image
|
||||||
content.append(info_box)
|
self._backdrop = Gtk.Picture()
|
||||||
|
self._backdrop.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
|
self._backdrop.set_size_request(-1, 420)
|
||||||
|
self._backdrop.add_css_class('detail-backdrop')
|
||||||
|
hero_overlay.set_child(self._backdrop)
|
||||||
|
|
||||||
|
backdrop_path = self.data.get('backdrop_path', '')
|
||||||
|
if backdrop_path:
|
||||||
|
url = self.window.tmdb.image_url(backdrop_path, 'w1280')
|
||||||
|
load_image_async(url, 1280, 720, self._on_backdrop_loaded)
|
||||||
|
|
||||||
|
# Gradient overlay
|
||||||
|
gradient = Gtk.Box()
|
||||||
|
gradient.add_css_class('detail-hero-gradient')
|
||||||
|
gradient.set_vexpand(True)
|
||||||
|
gradient.set_hexpand(True)
|
||||||
|
hero_overlay.add_overlay(gradient)
|
||||||
|
|
||||||
|
# Hero content (poster + info)
|
||||||
|
hero_content = Gtk.Box(spacing=24, valign=Gtk.Align.END)
|
||||||
|
hero_content.set_margin_start(32)
|
||||||
|
hero_content.set_margin_end(32)
|
||||||
|
hero_content.set_margin_bottom(32)
|
||||||
|
hero_overlay.add_overlay(hero_content)
|
||||||
|
|
||||||
# Poster
|
# Poster
|
||||||
poster_frame = Gtk.Frame()
|
poster_frame = Gtk.Frame()
|
||||||
poster_frame.add_css_class('card')
|
poster_frame.add_css_class('detail-poster-frame')
|
||||||
poster_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
poster_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
||||||
|
poster_frame.set_valign(Gtk.Align.END)
|
||||||
self._poster = Gtk.Picture()
|
self._poster = Gtk.Picture()
|
||||||
self._poster.set_size_request(240, 360)
|
self._poster.set_size_request(180, 270)
|
||||||
self._poster.set_content_fit(Gtk.ContentFit.COVER)
|
self._poster.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
poster_frame.set_child(self._poster)
|
poster_frame.set_child(self._poster)
|
||||||
info_box.append(poster_frame)
|
hero_content.append(poster_frame)
|
||||||
|
|
||||||
poster_path = self.data.get('poster_path', '')
|
poster_path = self.data.get('poster_path', '')
|
||||||
if poster_path:
|
if poster_path:
|
||||||
url = self.window.tmdb.image_url(poster_path, 'w500')
|
url = self.window.tmdb.image_url(poster_path, 'w500')
|
||||||
load_image_async(url, 240, 360, self._on_poster_loaded)
|
load_image_async(url, 180, 270, self._on_poster_loaded)
|
||||||
|
|
||||||
# Details column
|
# Hero info column
|
||||||
details = Gtk.Box(
|
hero_info = Gtk.Box(
|
||||||
orientation=Gtk.Orientation.VERTICAL,
|
orientation=Gtk.Orientation.VERTICAL,
|
||||||
spacing=12,
|
spacing=8,
|
||||||
hexpand=True,
|
hexpand=True,
|
||||||
valign=Gtk.Align.START,
|
valign=Gtk.Align.END,
|
||||||
)
|
)
|
||||||
info_box.append(details)
|
hero_content.append(hero_info)
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title_label = Gtk.Label(label=title, xalign=0, wrap=True)
|
title_label = Gtk.Label(label=title, xalign=0, wrap=True)
|
||||||
title_label.add_css_class('title-1')
|
title_label.add_css_class('detail-title')
|
||||||
details.append(title_label)
|
hero_info.append(title_label)
|
||||||
|
|
||||||
# Meta (rating, year)
|
# Tagline placeholder (filled by _load_details)
|
||||||
meta = Gtk.Box(spacing=12)
|
self._tagline_label = Gtk.Label(xalign=0, wrap=True, visible=False)
|
||||||
details.append(meta)
|
self._tagline_label.add_css_class('detail-tagline')
|
||||||
|
hero_info.append(self._tagline_label)
|
||||||
|
|
||||||
|
# Metadata chips row
|
||||||
|
self._meta_flow = Gtk.FlowBox(
|
||||||
|
selection_mode=Gtk.SelectionMode.NONE,
|
||||||
|
max_children_per_line=20,
|
||||||
|
min_children_per_line=1,
|
||||||
|
row_spacing=6,
|
||||||
|
column_spacing=6,
|
||||||
|
homogeneous=False,
|
||||||
|
)
|
||||||
|
self._meta_flow.set_halign(Gtk.Align.START)
|
||||||
|
hero_info.append(self._meta_flow)
|
||||||
|
|
||||||
|
# Initial chips from search result data
|
||||||
rating = self.data.get('vote_average', 0)
|
rating = self.data.get('vote_average', 0)
|
||||||
if rating > 0:
|
if rating > 0:
|
||||||
rating_lbl = Gtk.Label(label=f'★ {rating:.1f}')
|
self._add_chip(f'★ {rating:.1f}', 'chip-rating')
|
||||||
rating_lbl.add_css_class('rating-badge')
|
|
||||||
meta.append(rating_lbl)
|
|
||||||
|
|
||||||
if year_str:
|
if year_str:
|
||||||
year_lbl = Gtk.Label(label=year_str)
|
self._add_chip(year_str)
|
||||||
year_lbl.add_css_class('dim-label')
|
|
||||||
meta.append(year_lbl)
|
# ── Action buttons ────────────────────────────────────
|
||||||
|
actions_row = Gtk.Box(spacing=12)
|
||||||
|
actions_row.set_margin_top(16)
|
||||||
|
hero_info.append(actions_row)
|
||||||
|
|
||||||
|
watch_btn = Gtk.Button(label='Смотреть')
|
||||||
|
watch_btn.add_css_class('suggested-action')
|
||||||
|
watch_btn.add_css_class('pill')
|
||||||
|
watch_btn.set_icon_name('media-playback-start-symbolic')
|
||||||
|
watch_btn.connect('clicked', self._on_watch_clicked)
|
||||||
|
actions_row.append(watch_btn)
|
||||||
|
|
||||||
|
# ── Body content (below hero) ─────────────────────────
|
||||||
|
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
|
||||||
|
body.set_margin_start(32)
|
||||||
|
body.set_margin_end(32)
|
||||||
|
body.set_margin_top(24)
|
||||||
|
body.set_margin_bottom(32)
|
||||||
|
content.append(body)
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
overview = self.data.get('overview', '')
|
overview = self.data.get('overview', '')
|
||||||
if overview:
|
if overview:
|
||||||
desc = Gtk.Label(
|
overview_label = Gtk.Label(
|
||||||
label=overview, xalign=0, wrap=True, max_width_chars=80,
|
label=overview, xalign=0, wrap=True,
|
||||||
|
max_width_chars=100,
|
||||||
)
|
)
|
||||||
desc.set_valign(Gtk.Align.START)
|
overview_label.add_css_class('detail-overview')
|
||||||
details.append(desc)
|
body.append(overview_label)
|
||||||
|
|
||||||
# ── Torrents section ──────────────────────────────────
|
# Cast section placeholder
|
||||||
|
self._cast_section = Gtk.Box(
|
||||||
content.append(Gtk.Separator())
|
orientation=Gtk.Orientation.VERTICAL, spacing=12, visible=False,
|
||||||
|
|
||||||
torrents_header = Gtk.Box(spacing=12)
|
|
||||||
content.append(torrents_header)
|
|
||||||
|
|
||||||
torrents_title = Gtk.Label(
|
|
||||||
label='Торренты', xalign=0, hexpand=True,
|
|
||||||
)
|
)
|
||||||
torrents_title.add_css_class('title-2')
|
body.append(self._cast_section)
|
||||||
torrents_header.append(torrents_title)
|
|
||||||
|
|
||||||
refresh_btn = Gtk.Button(
|
cast_title = Gtk.Label(label='Актёры', xalign=0)
|
||||||
icon_name='view-refresh-symbolic',
|
cast_title.add_css_class('title-3')
|
||||||
tooltip_text='Обновить',
|
self._cast_section.append(cast_title)
|
||||||
|
|
||||||
|
cast_scroll = Gtk.ScrolledWindow(
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
|
||||||
|
vscrollbar_policy=Gtk.PolicyType.NEVER,
|
||||||
)
|
)
|
||||||
refresh_btn.connect('clicked', lambda *_: self._search_torrents())
|
cast_scroll.set_min_content_height(180)
|
||||||
torrents_header.append(refresh_btn)
|
self._cast_section.append(cast_scroll)
|
||||||
|
|
||||||
self._torrents_spinner = Gtk.Spinner(
|
self._cast_box = Gtk.Box(spacing=12)
|
||||||
spinning=True,
|
cast_scroll.set_child(self._cast_box)
|
||||||
halign=Gtk.Align.CENTER,
|
|
||||||
|
# ── Chip helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_chip(self, text, extra_class=None):
|
||||||
|
lbl = Gtk.Label(label=text)
|
||||||
|
lbl.add_css_class('detail-chip')
|
||||||
|
if extra_class:
|
||||||
|
lbl.add_css_class(extra_class)
|
||||||
|
child = Gtk.FlowBoxChild()
|
||||||
|
child.set_child(lbl)
|
||||||
|
self._meta_flow.append(child)
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_watch_clicked(self, _btn):
|
||||||
|
from kadr.views.torrents import TorrentsView
|
||||||
|
view = TorrentsView(
|
||||||
|
self.window, self.media_type, self.data, self._title,
|
||||||
)
|
)
|
||||||
self._torrents_spinner.set_size_request(32, 32)
|
self.window.nav_view.push(view.page)
|
||||||
self._torrents_spinner.set_margin_top(24)
|
|
||||||
self._torrents_spinner.set_margin_bottom(24)
|
|
||||||
content.append(self._torrents_spinner)
|
|
||||||
|
|
||||||
self._status_label = Gtk.Label(
|
# ── Load full details + credits ───────────────────────────
|
||||||
halign=Gtk.Align.CENTER, visible=False,
|
|
||||||
|
def _load_details(self):
|
||||||
|
media_id = self.data.get('id')
|
||||||
|
if not media_id:
|
||||||
|
return
|
||||||
|
run_async(self._fetch_details, self._on_details_loaded, media_id)
|
||||||
|
|
||||||
|
def _fetch_details(self, media_id):
|
||||||
|
tmdb = self.window.tmdb
|
||||||
|
if self.media_type == 'movie':
|
||||||
|
details = tmdb.movie_details(media_id)
|
||||||
|
credits = tmdb.movie_credits(media_id)
|
||||||
|
else:
|
||||||
|
details = tmdb.tv_details(media_id)
|
||||||
|
credits = tmdb.tv_credits(media_id)
|
||||||
|
return details, credits
|
||||||
|
|
||||||
|
def _on_details_loaded(self, result, error):
|
||||||
|
if error or not result:
|
||||||
|
return
|
||||||
|
details, credits = result
|
||||||
|
|
||||||
|
# Tagline
|
||||||
|
tagline = details.get('tagline', '')
|
||||||
|
if tagline:
|
||||||
|
self._tagline_label.set_label(tagline)
|
||||||
|
self._tagline_label.set_visible(True)
|
||||||
|
|
||||||
|
# Additional chips: runtime / seasons, genres
|
||||||
|
if self.media_type == 'movie':
|
||||||
|
runtime = details.get('runtime')
|
||||||
|
if runtime:
|
||||||
|
self._add_chip(_fmt_runtime(runtime))
|
||||||
|
else:
|
||||||
|
seasons = details.get('number_of_seasons')
|
||||||
|
if seasons:
|
||||||
|
s = 'сезон' if seasons == 1 else ('сезона' if 2 <= seasons <= 4 else 'сезонов')
|
||||||
|
self._add_chip(f'{seasons} {s}')
|
||||||
|
episodes = details.get('number_of_episodes')
|
||||||
|
if episodes:
|
||||||
|
self._add_chip(f'{episodes} эп.')
|
||||||
|
|
||||||
|
genres = details.get('genres', [])
|
||||||
|
for g in genres[:4]:
|
||||||
|
self._add_chip(g.get('name', ''))
|
||||||
|
|
||||||
|
# Cast
|
||||||
|
cast = credits.get('cast', [])[:15]
|
||||||
|
if cast:
|
||||||
|
self._cast_section.set_visible(True)
|
||||||
|
for person in cast:
|
||||||
|
self._build_cast_card(person)
|
||||||
|
|
||||||
|
def _build_cast_card(self, person):
|
||||||
|
card = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.VERTICAL,
|
||||||
|
spacing=4,
|
||||||
)
|
)
|
||||||
self._status_label.add_css_class('dim-label')
|
card.set_size_request(100, -1)
|
||||||
content.append(self._status_label)
|
|
||||||
|
|
||||||
self._torrent_list = Gtk.ListBox(
|
# Avatar
|
||||||
selection_mode=Gtk.SelectionMode.NONE,
|
avatar_frame = Gtk.Frame()
|
||||||
visible=False,
|
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
||||||
|
avatar_frame.add_css_class('cast-avatar-frame')
|
||||||
|
avatar = Gtk.Picture()
|
||||||
|
avatar.set_size_request(80, 80)
|
||||||
|
avatar.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
|
avatar_frame.set_child(avatar)
|
||||||
|
card.append(avatar_frame)
|
||||||
|
|
||||||
|
profile = person.get('profile_path', '')
|
||||||
|
if profile:
|
||||||
|
url = self.window.tmdb.image_url(profile, 'w185')
|
||||||
|
load_image_async(url, 80, 80, lambda tex, pic=avatar: pic.set_paintable(tex) if tex else None)
|
||||||
|
|
||||||
|
name_label = Gtk.Label(
|
||||||
|
label=person.get('name', ''),
|
||||||
|
xalign=0.5,
|
||||||
|
wrap=True,
|
||||||
|
max_width_chars=12,
|
||||||
|
lines=2,
|
||||||
)
|
)
|
||||||
self._torrent_list.add_css_class('boxed-list')
|
name_label.add_css_class('caption')
|
||||||
content.append(self._torrent_list)
|
card.append(name_label)
|
||||||
|
|
||||||
# ── Poster callback ───────────────────────────────────────
|
role = person.get('character', '')
|
||||||
|
if role:
|
||||||
|
role_label = Gtk.Label(
|
||||||
|
label=role,
|
||||||
|
xalign=0.5,
|
||||||
|
wrap=True,
|
||||||
|
max_width_chars=12,
|
||||||
|
lines=1,
|
||||||
|
)
|
||||||
|
role_label.add_css_class('dim-label')
|
||||||
|
role_label.add_css_class('caption')
|
||||||
|
card.append(role_label)
|
||||||
|
|
||||||
|
self._cast_box.append(card)
|
||||||
|
|
||||||
|
# ── Image callbacks ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_backdrop_loaded(self, texture):
|
||||||
|
if texture:
|
||||||
|
self._backdrop.set_paintable(texture)
|
||||||
|
|
||||||
def _on_poster_loaded(self, texture):
|
def _on_poster_loaded(self, texture):
|
||||||
if texture:
|
if texture:
|
||||||
self._poster.set_paintable(texture)
|
self._poster.set_paintable(texture)
|
||||||
|
|
||||||
# ── Torrent search ────────────────────────────────────────
|
|
||||||
|
|
||||||
def _search_torrents(self):
|
|
||||||
self._torrents_spinner.set_visible(True)
|
|
||||||
self._torrents_spinner.set_spinning(True)
|
|
||||||
self._status_label.set_visible(False)
|
|
||||||
self._torrent_list.set_visible(False)
|
|
||||||
self._clear_list()
|
|
||||||
|
|
||||||
queries = self._build_queries()
|
|
||||||
|
|
||||||
run_async(
|
|
||||||
self._search_with_fallback,
|
|
||||||
self._on_torrents_loaded,
|
|
||||||
queries,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_queries(self):
|
|
||||||
queries = []
|
|
||||||
if self.media_type == 'movie':
|
|
||||||
orig = self.data.get('original_title', '')
|
|
||||||
title = self.data.get('title', '')
|
|
||||||
date = self.data.get('release_date', '')
|
|
||||||
else:
|
|
||||||
orig = self.data.get('original_name', '')
|
|
||||||
title = self.data.get('name', '')
|
|
||||||
date = self.data.get('first_air_date', '')
|
|
||||||
|
|
||||||
year = date[:4] if date and len(date) >= 4 else ''
|
|
||||||
|
|
||||||
if orig:
|
|
||||||
queries.append(orig)
|
|
||||||
if title and title != orig:
|
|
||||||
queries.append(title)
|
|
||||||
if orig and year:
|
|
||||||
queries.append(f'{orig} {year}')
|
|
||||||
|
|
||||||
return queries or [title or orig]
|
|
||||||
|
|
||||||
def _search_with_fallback(self, queries):
|
|
||||||
server_id = self.window.jackett.resolve_server_id()
|
|
||||||
last_err = None
|
|
||||||
for query in queries:
|
|
||||||
try:
|
|
||||||
results = self.window.jackett.search(server_id, query)
|
|
||||||
if results:
|
|
||||||
return results
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
continue
|
|
||||||
|
|
||||||
if last_err:
|
|
||||||
raise last_err
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _on_torrents_loaded(self, results, error):
|
|
||||||
self._torrents_spinner.set_visible(False)
|
|
||||||
self._torrents_spinner.set_spinning(False)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
self._status_label.set_label(f'Ошибка: {error}')
|
|
||||||
self._status_label.set_visible(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
self._status_label.set_label('Торренты не найдены')
|
|
||||||
self._status_label.set_visible(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._torrent_list.set_visible(True)
|
|
||||||
for torrent in results:
|
|
||||||
row = TorrentRow(
|
|
||||||
torrent=torrent,
|
|
||||||
on_download=self._on_download,
|
|
||||||
on_copy=self._on_copy_magnet,
|
|
||||||
)
|
|
||||||
self._torrent_list.append(row)
|
|
||||||
|
|
||||||
# ── Torrent actions ───────────────────────────────────────
|
|
||||||
|
|
||||||
def _on_download(self, torrent):
|
|
||||||
magnet = torrent.get('magnet_uri', '')
|
|
||||||
link = torrent.get('link', '')
|
|
||||||
name = torrent.get('title', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
if magnet:
|
|
||||||
client = self.window.downloads.send_magnet(magnet, name)
|
|
||||||
elif link:
|
|
||||||
client = self.window.downloads.send_torrent_url(link, name)
|
|
||||||
else:
|
|
||||||
self.window.show_toast('Нет ссылки для скачивания')
|
|
||||||
return
|
|
||||||
self.window.show_toast(f'Отправлено в {client}')
|
|
||||||
except Exception as e:
|
|
||||||
self.window.show_toast(str(e))
|
|
||||||
|
|
||||||
def _on_copy_magnet(self, torrent):
|
|
||||||
magnet = torrent.get('magnet_uri', '')
|
|
||||||
if not magnet:
|
|
||||||
self.window.show_toast('Магнет-ссылка недоступна')
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
copy_to_clipboard(magnet)
|
|
||||||
self.window.show_toast('Магнет-ссылка скопирована')
|
|
||||||
except Exception as e:
|
|
||||||
self.window.show_toast(str(e))
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _clear_list(self):
|
|
||||||
while True:
|
|
||||||
child = self._torrent_list.get_first_child()
|
|
||||||
if child is None:
|
|
||||||
break
|
|
||||||
self._torrent_list.remove(child)
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Full-screen player view with embedded mpv, transport controls, and download overlay."""
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version('Gtk', '4.0')
|
||||||
|
gi.require_version('Adw', '1')
|
||||||
|
gi.require_version('Gdk', '4.0')
|
||||||
|
from gi.repository import Adw, Gdk, GLib, Gtk
|
||||||
|
|
||||||
|
from kadr.widgets.mpv_widget import MpvWidget
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(seconds):
|
||||||
|
"""Format seconds as H:MM:SS or M:SS."""
|
||||||
|
s = max(0, int(seconds))
|
||||||
|
h, s = divmod(s, 3600)
|
||||||
|
m, s = divmod(s, 60)
|
||||||
|
return f'{h}:{m:02d}:{s:02d}' if h else f'{m}:{s:02d}'
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerView:
|
||||||
|
"""Navigation page containing an embedded mpv video player with controls."""
|
||||||
|
|
||||||
|
def __init__(self, window, title=''):
|
||||||
|
self.window = window
|
||||||
|
self._seeking = False
|
||||||
|
self._hiding_id = 0
|
||||||
|
self._seek_timeout_id = 0
|
||||||
|
self._end_seek_id = 0
|
||||||
|
self._controls_visible = True
|
||||||
|
|
||||||
|
# ── Page ──
|
||||||
|
self._page = Adw.NavigationPage(title=title or 'Плеер')
|
||||||
|
|
||||||
|
toolbar_view = Adw.ToolbarView()
|
||||||
|
self._page.set_child(toolbar_view)
|
||||||
|
|
||||||
|
# Header bar (integrated into overlay, auto-hides)
|
||||||
|
self._header = Adw.HeaderBar()
|
||||||
|
self._header.add_css_class('osd')
|
||||||
|
toolbar_view.add_top_bar(self._header)
|
||||||
|
|
||||||
|
# ── Main overlay ──
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
toolbar_view.set_content(overlay)
|
||||||
|
|
||||||
|
# mpv render area
|
||||||
|
self._mpv_widget = MpvWidget()
|
||||||
|
self._mpv_widget.set_hexpand(True)
|
||||||
|
self._mpv_widget.set_vexpand(True)
|
||||||
|
overlay.set_child(self._mpv_widget)
|
||||||
|
|
||||||
|
# Download info banner (top)
|
||||||
|
self._dl_label = Gtk.Label(
|
||||||
|
halign=Gtk.Align.END,
|
||||||
|
valign=Gtk.Align.START,
|
||||||
|
)
|
||||||
|
self._dl_label.set_margin_top(8)
|
||||||
|
self._dl_label.set_margin_end(12)
|
||||||
|
self._dl_label.add_css_class('osd')
|
||||||
|
self._dl_label.add_css_class('caption')
|
||||||
|
overlay.add_overlay(self._dl_label)
|
||||||
|
|
||||||
|
# ── Controls bottom bar ──
|
||||||
|
controls_box = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.VERTICAL,
|
||||||
|
valign=Gtk.Align.END,
|
||||||
|
)
|
||||||
|
controls_box.add_css_class('osd')
|
||||||
|
controls_box.add_css_class('toolbar')
|
||||||
|
overlay.add_overlay(controls_box)
|
||||||
|
self._controls_box = controls_box
|
||||||
|
|
||||||
|
# Progress slider
|
||||||
|
self._progress_adj = Gtk.Adjustment(lower=0, upper=1, step_increment=5)
|
||||||
|
self._progress_scale = Gtk.Scale(
|
||||||
|
orientation=Gtk.Orientation.HORIZONTAL,
|
||||||
|
adjustment=self._progress_adj,
|
||||||
|
draw_value=False,
|
||||||
|
hexpand=True,
|
||||||
|
)
|
||||||
|
self._progress_scale.set_margin_start(12)
|
||||||
|
self._progress_scale.set_margin_end(12)
|
||||||
|
self._progress_scale.connect('change-value', self._on_change_value)
|
||||||
|
controls_box.append(self._progress_scale)
|
||||||
|
|
||||||
|
# Transport row
|
||||||
|
transport = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
|
||||||
|
transport.set_margin_bottom(8)
|
||||||
|
transport.set_margin_top(4)
|
||||||
|
controls_box.append(transport)
|
||||||
|
|
||||||
|
# Time label
|
||||||
|
self._time_label = Gtk.Label(label='0:00 / 0:00')
|
||||||
|
self._time_label.add_css_class('caption')
|
||||||
|
self._time_label.set_width_chars(17)
|
||||||
|
transport.append(self._time_label)
|
||||||
|
|
||||||
|
# Play / Pause
|
||||||
|
self._play_btn = Gtk.Button(icon_name='media-playback-pause-symbolic')
|
||||||
|
self._play_btn.connect('clicked', self._toggle_pause)
|
||||||
|
transport.append(self._play_btn)
|
||||||
|
|
||||||
|
# Volume
|
||||||
|
vol_adj = Gtk.Adjustment(value=100, lower=0, upper=150, step_increment=5)
|
||||||
|
vol_scale = Gtk.Scale(
|
||||||
|
orientation=Gtk.Orientation.HORIZONTAL,
|
||||||
|
adjustment=vol_adj,
|
||||||
|
draw_value=False,
|
||||||
|
)
|
||||||
|
vol_scale.set_size_request(100, -1)
|
||||||
|
vol_adj.connect('value-changed', lambda a: self._set_volume(a.get_value()))
|
||||||
|
transport.append(vol_scale)
|
||||||
|
|
||||||
|
# Fullscreen
|
||||||
|
fs_btn = Gtk.Button(icon_name='view-fullscreen-symbolic')
|
||||||
|
fs_btn.connect('clicked', self._toggle_fullscreen)
|
||||||
|
transport.append(fs_btn)
|
||||||
|
|
||||||
|
# ── Motion controller (auto-hide controls) ──
|
||||||
|
motion = Gtk.EventControllerMotion()
|
||||||
|
motion.connect('motion', self._on_motion)
|
||||||
|
overlay.add_controller(motion)
|
||||||
|
|
||||||
|
click = Gtk.GestureClick(button=1)
|
||||||
|
click.connect('released', self._on_click)
|
||||||
|
overlay.add_controller(click)
|
||||||
|
|
||||||
|
# ── mpv callbacks ──
|
||||||
|
self._mpv_widget.set_on_file_loaded(self._on_file_loaded)
|
||||||
|
self._mpv_widget.set_on_eof(self._on_eof)
|
||||||
|
|
||||||
|
# Observe time-pos and duration
|
||||||
|
self._duration = 0
|
||||||
|
self._setup_observers()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page(self):
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mpv_widget(self):
|
||||||
|
return self._mpv_widget
|
||||||
|
|
||||||
|
def play(self, path):
|
||||||
|
"""Load a file and start playback."""
|
||||||
|
self._mpv_widget.load(path)
|
||||||
|
|
||||||
|
def set_log_callback(self, cb):
|
||||||
|
"""Connect mpv_widget logging to external log collector."""
|
||||||
|
self._mpv_widget.set_log_callback(cb)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Release mpv resources."""
|
||||||
|
if self._hiding_id:
|
||||||
|
GLib.source_remove(self._hiding_id)
|
||||||
|
self._hiding_id = 0
|
||||||
|
if self._seek_timeout_id:
|
||||||
|
GLib.source_remove(self._seek_timeout_id)
|
||||||
|
self._seek_timeout_id = 0
|
||||||
|
if self._end_seek_id:
|
||||||
|
GLib.source_remove(self._end_seek_id)
|
||||||
|
self._end_seek_id = 0
|
||||||
|
self._mpv_widget.shutdown()
|
||||||
|
|
||||||
|
def set_download_info(self, text):
|
||||||
|
"""Update the download progress overlay text."""
|
||||||
|
self._dl_label.set_label(text)
|
||||||
|
|
||||||
|
# ── Observers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _setup_observers(self):
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
@player.property_observer('time-pos')
|
||||||
|
def _on_time(_name, value):
|
||||||
|
if value is not None:
|
||||||
|
GLib.idle_add(self._update_time, float(value))
|
||||||
|
|
||||||
|
@player.property_observer('duration')
|
||||||
|
def _on_duration(_name, value):
|
||||||
|
if value is not None:
|
||||||
|
self._duration = float(value)
|
||||||
|
GLib.idle_add(self._update_duration, float(value))
|
||||||
|
|
||||||
|
@player.property_observer('pause')
|
||||||
|
def _on_pause(_name, paused):
|
||||||
|
icon = ('media-playback-start-symbolic' if paused
|
||||||
|
else 'media-playback-pause-symbolic')
|
||||||
|
GLib.idle_add(self._play_btn.set_icon_name, icon)
|
||||||
|
|
||||||
|
def _update_time(self, pos):
|
||||||
|
if self._seeking:
|
||||||
|
return
|
||||||
|
self._progress_adj.set_value(pos)
|
||||||
|
dur = self._duration
|
||||||
|
self._time_label.set_label(f'{_fmt_time(pos)} / {_fmt_time(dur)}')
|
||||||
|
|
||||||
|
def _update_duration(self, dur):
|
||||||
|
self._progress_adj.set_upper(dur)
|
||||||
|
|
||||||
|
# ── User actions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_change_value(self, scale, scroll_type, value):
|
||||||
|
"""Handle user-initiated slider interaction (drag / click / keyboard)."""
|
||||||
|
self._seeking = True
|
||||||
|
# Cancel any pending end-seek reset
|
||||||
|
if self._end_seek_id:
|
||||||
|
GLib.source_remove(self._end_seek_id)
|
||||||
|
self._end_seek_id = 0
|
||||||
|
# Show target time immediately
|
||||||
|
dur = self._duration
|
||||||
|
self._time_label.set_label(f'{_fmt_time(value)} / {_fmt_time(dur)}')
|
||||||
|
# Debounce the actual seek (avoid spamming during drag)
|
||||||
|
if self._seek_timeout_id:
|
||||||
|
GLib.source_remove(self._seek_timeout_id)
|
||||||
|
self._seek_timeout_id = GLib.timeout_add(150, self._do_seek, value)
|
||||||
|
return False # let the scale update its visual position
|
||||||
|
|
||||||
|
def _do_seek(self, value):
|
||||||
|
"""Perform the debounced seek."""
|
||||||
|
self._seek_timeout_id = 0
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if player:
|
||||||
|
player.seek(value, reference='absolute')
|
||||||
|
# Re-enable time-pos updates after mpv stabilises
|
||||||
|
self._end_seek_id = GLib.timeout_add(500, self._end_seek)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _end_seek(self):
|
||||||
|
"""Resume time-pos → slider updates."""
|
||||||
|
self._end_seek_id = 0
|
||||||
|
self._seeking = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _toggle_pause(self, _btn):
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if player:
|
||||||
|
player.pause = not player.pause
|
||||||
|
|
||||||
|
def _set_volume(self, vol):
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if player:
|
||||||
|
player.volume = vol
|
||||||
|
|
||||||
|
def _toggle_fullscreen(self, _btn):
|
||||||
|
win = self._page.get_root()
|
||||||
|
if win and hasattr(win, 'is_fullscreen'):
|
||||||
|
if win.is_fullscreen():
|
||||||
|
win.unfullscreen()
|
||||||
|
else:
|
||||||
|
win.fullscreen()
|
||||||
|
|
||||||
|
def _on_file_loaded(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_eof(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Auto-hide controls ────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_motion(self, _ctrl, _x, _y):
|
||||||
|
self._show_controls()
|
||||||
|
self._schedule_hide()
|
||||||
|
|
||||||
|
def _on_click(self, _gesture, _n, _x, _y):
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if player:
|
||||||
|
player.pause = not player.pause
|
||||||
|
|
||||||
|
def _show_controls(self):
|
||||||
|
if not self._controls_visible:
|
||||||
|
self._controls_box.set_visible(True)
|
||||||
|
self._header.set_visible(True)
|
||||||
|
self._controls_visible = True
|
||||||
|
|
||||||
|
def _schedule_hide(self):
|
||||||
|
if self._hiding_id:
|
||||||
|
GLib.source_remove(self._hiding_id)
|
||||||
|
self._hiding_id = GLib.timeout_add_seconds(3, self._hide_controls)
|
||||||
|
|
||||||
|
def _hide_controls(self):
|
||||||
|
self._hiding_id = 0
|
||||||
|
player = self._mpv_widget.player
|
||||||
|
if player and not player.pause:
|
||||||
|
self._controls_box.set_visible(False)
|
||||||
|
self._header.set_visible(False)
|
||||||
|
self._controls_visible = False
|
||||||
|
return False
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""Torrents page — search, download, and stream torrents for a title."""
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version('Gtk', '4.0')
|
||||||
|
gi.require_version('Adw', '1')
|
||||||
|
from gi.repository import Adw, Gtk
|
||||||
|
|
||||||
|
from kadr.utils import run_async, copy_to_clipboard
|
||||||
|
from kadr.widgets.torrent_row import TorrentRow
|
||||||
|
from kadr.services.streamer import LIBTORRENT_AVAILABLE
|
||||||
|
from kadr.widgets.mpv_widget import MPV_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
class TorrentsView:
|
||||||
|
"""Navigation page for browsing and streaming torrents."""
|
||||||
|
|
||||||
|
def __init__(self, window, media_type, data, title):
|
||||||
|
self.window = window
|
||||||
|
self.media_type = media_type
|
||||||
|
self.data = data
|
||||||
|
self._player_view = None
|
||||||
|
self._build(title)
|
||||||
|
self._search_torrents()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page(self):
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
# ── Build UI ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build(self, title):
|
||||||
|
self._page = Adw.NavigationPage(title='Просмотр')
|
||||||
|
self._page.connect('hidden', self._on_page_hidden)
|
||||||
|
|
||||||
|
toolbar_view = Adw.ToolbarView()
|
||||||
|
self._page.set_child(toolbar_view)
|
||||||
|
|
||||||
|
header = Adw.HeaderBar()
|
||||||
|
header.set_title_widget(Adw.WindowTitle(title='Просмотр', subtitle=title))
|
||||||
|
toolbar_view.add_top_bar(header)
|
||||||
|
|
||||||
|
scrolled = Gtk.ScrolledWindow(vexpand=True)
|
||||||
|
toolbar_view.set_content(scrolled)
|
||||||
|
|
||||||
|
content = Gtk.Box(
|
||||||
|
orientation=Gtk.Orientation.VERTICAL, spacing=16,
|
||||||
|
)
|
||||||
|
content.set_margin_start(24)
|
||||||
|
content.set_margin_end(24)
|
||||||
|
content.set_margin_top(16)
|
||||||
|
content.set_margin_bottom(24)
|
||||||
|
scrolled.set_child(content)
|
||||||
|
|
||||||
|
# ── Streaming status banner ──
|
||||||
|
self._stream_banner = Adw.Banner(revealed=False)
|
||||||
|
self._stream_banner.set_button_label('Остановить')
|
||||||
|
self._stream_banner.connect(
|
||||||
|
'button-clicked', lambda *_: self._stop_stream(),
|
||||||
|
)
|
||||||
|
content.append(self._stream_banner)
|
||||||
|
|
||||||
|
# ── Torrents header ──
|
||||||
|
torrents_header = Gtk.Box(spacing=12)
|
||||||
|
content.append(torrents_header)
|
||||||
|
|
||||||
|
torrents_title = Gtk.Label(
|
||||||
|
label='Торренты', xalign=0, hexpand=True,
|
||||||
|
)
|
||||||
|
torrents_title.add_css_class('title-3')
|
||||||
|
torrents_header.append(torrents_title)
|
||||||
|
|
||||||
|
refresh_btn = Gtk.Button(
|
||||||
|
icon_name='view-refresh-symbolic',
|
||||||
|
tooltip_text='Обновить',
|
||||||
|
)
|
||||||
|
refresh_btn.add_css_class('flat')
|
||||||
|
refresh_btn.connect('clicked', lambda *_: self._search_torrents())
|
||||||
|
torrents_header.append(refresh_btn)
|
||||||
|
|
||||||
|
# Spinner
|
||||||
|
self._torrents_spinner = Gtk.Spinner(
|
||||||
|
spinning=True, halign=Gtk.Align.CENTER,
|
||||||
|
)
|
||||||
|
self._torrents_spinner.set_size_request(32, 32)
|
||||||
|
self._torrents_spinner.set_margin_top(16)
|
||||||
|
self._torrents_spinner.set_margin_bottom(16)
|
||||||
|
content.append(self._torrents_spinner)
|
||||||
|
|
||||||
|
# Status label
|
||||||
|
self._status_label = Gtk.Label(
|
||||||
|
halign=Gtk.Align.CENTER, visible=False,
|
||||||
|
)
|
||||||
|
self._status_label.add_css_class('dim-label')
|
||||||
|
content.append(self._status_label)
|
||||||
|
|
||||||
|
# Torrent list
|
||||||
|
self._torrent_list = Gtk.ListBox(
|
||||||
|
selection_mode=Gtk.SelectionMode.NONE,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
self._torrent_list.add_css_class('boxed-list')
|
||||||
|
content.append(self._torrent_list)
|
||||||
|
|
||||||
|
# ── Stream log panel ──
|
||||||
|
self._log_expander = Gtk.Expander(label='Лог стриминга', expanded=False)
|
||||||
|
self._log_expander.set_visible(False)
|
||||||
|
content.append(self._log_expander)
|
||||||
|
|
||||||
|
log_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
self._log_expander.set_child(log_box)
|
||||||
|
|
||||||
|
log_scroll = Gtk.ScrolledWindow()
|
||||||
|
log_scroll.set_min_content_height(200)
|
||||||
|
log_scroll.set_max_content_height(400)
|
||||||
|
log_box.append(log_scroll)
|
||||||
|
|
||||||
|
self._log_textview = Gtk.TextView(
|
||||||
|
editable=False,
|
||||||
|
monospace=True,
|
||||||
|
wrap_mode=Gtk.WrapMode.WORD_CHAR,
|
||||||
|
top_margin=4,
|
||||||
|
bottom_margin=4,
|
||||||
|
left_margin=8,
|
||||||
|
right_margin=8,
|
||||||
|
)
|
||||||
|
self._log_textview.add_css_class('card')
|
||||||
|
log_scroll.set_child(self._log_textview)
|
||||||
|
|
||||||
|
copy_log_btn = Gtk.Button(label='Скопировать лог', halign=Gtk.Align.END)
|
||||||
|
copy_log_btn.set_margin_top(4)
|
||||||
|
copy_log_btn.connect('clicked', self._copy_log)
|
||||||
|
log_box.append(copy_log_btn)
|
||||||
|
|
||||||
|
# ── Torrent search ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _search_torrents(self):
|
||||||
|
self._torrents_spinner.set_visible(True)
|
||||||
|
self._torrents_spinner.set_spinning(True)
|
||||||
|
self._status_label.set_visible(False)
|
||||||
|
self._torrent_list.set_visible(False)
|
||||||
|
self._clear_list()
|
||||||
|
|
||||||
|
queries = self._build_queries()
|
||||||
|
run_async(
|
||||||
|
self._search_with_fallback,
|
||||||
|
self._on_torrents_loaded,
|
||||||
|
queries,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_queries(self):
|
||||||
|
queries = []
|
||||||
|
if self.media_type == 'movie':
|
||||||
|
orig = self.data.get('original_title', '')
|
||||||
|
title = self.data.get('title', '')
|
||||||
|
date = self.data.get('release_date', '')
|
||||||
|
else:
|
||||||
|
orig = self.data.get('original_name', '')
|
||||||
|
title = self.data.get('name', '')
|
||||||
|
date = self.data.get('first_air_date', '')
|
||||||
|
|
||||||
|
year = date[:4] if date and len(date) >= 4 else ''
|
||||||
|
|
||||||
|
if orig:
|
||||||
|
queries.append(orig)
|
||||||
|
if title and title != orig:
|
||||||
|
queries.append(title)
|
||||||
|
if orig and year:
|
||||||
|
queries.append(f'{orig} {year}')
|
||||||
|
|
||||||
|
return queries or [title or orig]
|
||||||
|
|
||||||
|
def _search_with_fallback(self, queries):
|
||||||
|
server_id = self.window.jackett.resolve_server_id()
|
||||||
|
last_err = None
|
||||||
|
for query in queries:
|
||||||
|
try:
|
||||||
|
results = self.window.jackett.search(server_id, query)
|
||||||
|
if results:
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
if last_err:
|
||||||
|
raise last_err
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _on_torrents_loaded(self, results, error):
|
||||||
|
self._torrents_spinner.set_visible(False)
|
||||||
|
self._torrents_spinner.set_spinning(False)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
self._status_label.set_label(f'Ошибка: {error}')
|
||||||
|
self._status_label.set_visible(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
self._status_label.set_label('Торренты не найдены')
|
||||||
|
self._status_label.set_visible(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._torrent_list.set_visible(True)
|
||||||
|
can_stream = LIBTORRENT_AVAILABLE and MPV_AVAILABLE
|
||||||
|
for torrent in results:
|
||||||
|
row = TorrentRow(
|
||||||
|
torrent=torrent,
|
||||||
|
on_download=self._on_download,
|
||||||
|
on_copy=self._on_copy_magnet,
|
||||||
|
on_stream=self._on_stream if can_stream else None,
|
||||||
|
)
|
||||||
|
self._torrent_list.append(row)
|
||||||
|
|
||||||
|
# ── Torrent actions ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_download(self, torrent):
|
||||||
|
magnet = torrent.get('magnet_uri', '')
|
||||||
|
link = torrent.get('link', '')
|
||||||
|
name = torrent.get('title', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if magnet:
|
||||||
|
client = self.window.downloads.send_magnet(magnet, name)
|
||||||
|
elif link:
|
||||||
|
client = self.window.downloads.send_torrent_url(link, name)
|
||||||
|
else:
|
||||||
|
self.window.show_toast('Нет ссылки для скачивания')
|
||||||
|
return
|
||||||
|
self.window.show_toast(f'Отправлено в {client}')
|
||||||
|
except Exception as e:
|
||||||
|
self.window.show_toast(str(e))
|
||||||
|
|
||||||
|
def _on_copy_magnet(self, torrent):
|
||||||
|
magnet = torrent.get('magnet_uri', '')
|
||||||
|
if not magnet:
|
||||||
|
self.window.show_toast('Магнет-ссылка недоступна')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
copy_to_clipboard(magnet)
|
||||||
|
self.window.show_toast('Магнет-ссылка скопирована')
|
||||||
|
except Exception as e:
|
||||||
|
self.window.show_toast(str(e))
|
||||||
|
|
||||||
|
# ── Streaming ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_stream(self, torrent):
|
||||||
|
magnet = torrent.get('magnet_uri', '')
|
||||||
|
link = torrent.get('link', '')
|
||||||
|
if not magnet and not link:
|
||||||
|
self.window.show_toast('Нет ссылки для воспроизведения')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log_expander.set_visible(True)
|
||||||
|
self._log_expander.set_expanded(True)
|
||||||
|
buf = self._log_textview.get_buffer()
|
||||||
|
buf.set_text('')
|
||||||
|
self.window.streamer.log.clear()
|
||||||
|
self.window.streamer.log.set_on_update(self._on_log_line)
|
||||||
|
try:
|
||||||
|
self.window.streamer.start(
|
||||||
|
magnet, self._on_stream_status, torrent_link=link,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.window.show_toast(str(e))
|
||||||
|
|
||||||
|
def _on_stream_status(self, state, detail):
|
||||||
|
if state == 'error':
|
||||||
|
self._stream_banner.set_revealed(False)
|
||||||
|
self._cleanup_player()
|
||||||
|
self.window.show_toast(detail)
|
||||||
|
return
|
||||||
|
if state == 'stopped':
|
||||||
|
self._stream_banner.set_revealed(False)
|
||||||
|
return
|
||||||
|
if state == 'ready':
|
||||||
|
self._stream_banner.set_revealed(False)
|
||||||
|
self._open_player(detail)
|
||||||
|
return
|
||||||
|
if state == 'downloading' and self._player_view:
|
||||||
|
self._player_view.set_download_info(detail)
|
||||||
|
return
|
||||||
|
label_map = {
|
||||||
|
'metadata': f'⏳ {detail}',
|
||||||
|
'buffering': f'⏳ {detail}',
|
||||||
|
}
|
||||||
|
self._stream_banner.set_title(label_map.get(state, detail))
|
||||||
|
self._stream_banner.set_revealed(True)
|
||||||
|
|
||||||
|
def _open_player(self, video_path):
|
||||||
|
from kadr.views.player import PlayerView
|
||||||
|
slog = self.window.streamer.log
|
||||||
|
slog.log(f'opening player: {video_path}')
|
||||||
|
self._player_view = PlayerView(
|
||||||
|
self.window, title=self._page.get_title(),
|
||||||
|
)
|
||||||
|
self._player_view.set_log_callback(slog.log)
|
||||||
|
self._player_view.page.connect(
|
||||||
|
'hidden', lambda _: self._on_player_closed(),
|
||||||
|
)
|
||||||
|
self.window.nav_view.push(self._player_view.page)
|
||||||
|
self._player_view.play(video_path)
|
||||||
|
|
||||||
|
def _on_player_closed(self):
|
||||||
|
if self._player_view:
|
||||||
|
self._player_view.shutdown()
|
||||||
|
self._player_view = None
|
||||||
|
self.window.streamer.stop()
|
||||||
|
|
||||||
|
def _cleanup_player(self):
|
||||||
|
if self._player_view:
|
||||||
|
self._player_view.shutdown()
|
||||||
|
self._player_view = None
|
||||||
|
self.window.nav_view.pop_to_page(self._page)
|
||||||
|
|
||||||
|
def _stop_stream(self):
|
||||||
|
self.window.streamer.stop()
|
||||||
|
self._stream_banner.set_revealed(False)
|
||||||
|
self._cleanup_player()
|
||||||
|
|
||||||
|
def _on_page_hidden(self, _page):
|
||||||
|
if self._player_view is not None:
|
||||||
|
return
|
||||||
|
self._stop_stream()
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_log_line(self, line):
|
||||||
|
buf = self._log_textview.get_buffer()
|
||||||
|
end_iter = buf.get_end_iter()
|
||||||
|
if buf.get_char_count() > 0:
|
||||||
|
buf.insert(end_iter, '\n')
|
||||||
|
end_iter = buf.get_end_iter()
|
||||||
|
buf.insert(end_iter, line)
|
||||||
|
end_mark = buf.create_mark(None, buf.get_end_iter(), False)
|
||||||
|
self._log_textview.scroll_mark_onscreen(end_mark)
|
||||||
|
buf.delete_mark(end_mark)
|
||||||
|
|
||||||
|
def _copy_log(self, _btn):
|
||||||
|
text = self.window.streamer.log.text()
|
||||||
|
if not text:
|
||||||
|
self.window.show_toast('Лог пуст')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
copy_to_clipboard(text)
|
||||||
|
self.window.show_toast('Лог скопирован')
|
||||||
|
except Exception as e:
|
||||||
|
self.window.show_toast(str(e))
|
||||||
|
|
||||||
|
def _clear_list(self):
|
||||||
|
while True:
|
||||||
|
child = self._torrent_list.get_first_child()
|
||||||
|
if child is None:
|
||||||
|
break
|
||||||
|
self._torrent_list.remove(child)
|
||||||
@@ -6,7 +6,6 @@ from kadr.utils import load_image_async
|
|||||||
|
|
||||||
POSTER_WIDTH = 155
|
POSTER_WIDTH = 155
|
||||||
POSTER_HEIGHT = 232
|
POSTER_HEIGHT = 232
|
||||||
COVER_RATIO = 1.5
|
|
||||||
|
|
||||||
|
|
||||||
def _has_ru_title(data, media_type):
|
def _has_ru_title(data, media_type):
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""GTK4 widget that renders mpv video via OpenGL (Gtk.GLArea + MpvRenderContext).
|
||||||
|
|
||||||
|
Inspired by the Cine project (github.com/diegopvlk/Cine).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version('Gtk', '4.0')
|
||||||
|
gi.require_version('Gdk', '4.0')
|
||||||
|
from gi.repository import Gdk, GLib, Gtk
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mpv
|
||||||
|
MPV_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
mpv = None
|
||||||
|
MPV_AVAILABLE = False
|
||||||
|
|
||||||
|
# EGL / GL helpers for the render context
|
||||||
|
_libegl = None
|
||||||
|
_egl_get_proc = None
|
||||||
|
_libgl = None
|
||||||
|
_glGetIntegerv = None
|
||||||
|
_GL_FRAMEBUFFER_BINDING = 0x8CA6
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_gl_libs():
|
||||||
|
global _libegl, _egl_get_proc, _libgl, _glGetIntegerv
|
||||||
|
if _libegl is not None:
|
||||||
|
return
|
||||||
|
_libegl = ctypes.CDLL('libEGL.so.1')
|
||||||
|
_egl_get_proc = _libegl.eglGetProcAddress
|
||||||
|
_egl_get_proc.restype = ctypes.c_void_p
|
||||||
|
_egl_get_proc.argtypes = [ctypes.c_char_p]
|
||||||
|
|
||||||
|
_libgl = ctypes.CDLL('libGL.so.1')
|
||||||
|
_glGetIntegerv = _libgl.glGetIntegerv
|
||||||
|
_glGetIntegerv.argtypes = [ctypes.c_uint, ctypes.POINTER(ctypes.c_int)]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display_param():
|
||||||
|
"""Return wl_display / x11_display pointer for mpv render context."""
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
if display is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
_gtk = ctypes.CDLL('libgtk-4.so.1')
|
||||||
|
|
||||||
|
def _ptr(obj):
|
||||||
|
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
|
||||||
|
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
|
||||||
|
return ctypes.pythonapi.PyCapsule_GetPointer(obj.__gpointer__, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gi.require_version('GdkWayland', '4.0')
|
||||||
|
from gi.repository import GdkWayland
|
||||||
|
if isinstance(display, GdkWayland.WaylandDisplay):
|
||||||
|
_gtk.gdk_wayland_display_get_wl_display.restype = ctypes.c_void_p
|
||||||
|
_gtk.gdk_wayland_display_get_wl_display.argtypes = [ctypes.c_void_p]
|
||||||
|
ptr = _gtk.gdk_wayland_display_get_wl_display(_ptr(display))
|
||||||
|
if ptr:
|
||||||
|
return {'wl_display': ptr}
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
gi.require_version('GdkX11', '4.0')
|
||||||
|
from gi.repository import GdkX11
|
||||||
|
if isinstance(display, GdkX11.X11Display):
|
||||||
|
_gtk.gdk_x11_display_get_xdisplay.restype = ctypes.c_void_p
|
||||||
|
_gtk.gdk_x11_display_get_xdisplay.argtypes = [ctypes.c_void_p]
|
||||||
|
ptr = _gtk.gdk_x11_display_get_xdisplay(_ptr(display))
|
||||||
|
if ptr:
|
||||||
|
return {'x11_display': ptr}
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class MpvWidget(Gtk.GLArea):
|
||||||
|
"""A Gtk.GLArea that renders an mpv player via OpenGL."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
if not MPV_AVAILABLE:
|
||||||
|
raise RuntimeError('python-mpv не установлен')
|
||||||
|
|
||||||
|
_ensure_gl_libs()
|
||||||
|
|
||||||
|
self._mpv = mpv.MPV(
|
||||||
|
vo='libmpv',
|
||||||
|
ao='pulse,alsa',
|
||||||
|
osc='no',
|
||||||
|
keep_open='yes',
|
||||||
|
keep_open_pause='no',
|
||||||
|
input_default_bindings=False,
|
||||||
|
input_vo_keyboard=False,
|
||||||
|
terminal=False,
|
||||||
|
config=False,
|
||||||
|
load_scripts=False,
|
||||||
|
ytdl=False,
|
||||||
|
cache='yes',
|
||||||
|
force_seekable='yes',
|
||||||
|
demuxer_max_bytes=150 * 1024 * 1024,
|
||||||
|
demuxer_readahead_secs=120,
|
||||||
|
cache_pause_wait=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._ctx = None
|
||||||
|
self._fbo = ctypes.c_int()
|
||||||
|
self._on_file_loaded_cb = None
|
||||||
|
self._on_eof_cb = None
|
||||||
|
self._log_cb = None
|
||||||
|
|
||||||
|
self.connect('realize', self._on_realize)
|
||||||
|
self.connect('render', self._on_render)
|
||||||
|
self.connect('unrealize', self._on_unrealize)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player(self):
|
||||||
|
return self._mpv
|
||||||
|
|
||||||
|
def load(self, path):
|
||||||
|
"""Load and play a video file."""
|
||||||
|
self._log(f'mpv_widget: load({path})')
|
||||||
|
self._mpv.loadfile(path)
|
||||||
|
|
||||||
|
def set_on_file_loaded(self, cb):
|
||||||
|
"""cb() called on GTK thread when file finishes loading."""
|
||||||
|
self._on_file_loaded_cb = cb
|
||||||
|
|
||||||
|
def set_on_eof(self, cb):
|
||||||
|
"""cb() called on GTK thread when playback reaches end."""
|
||||||
|
self._on_eof_cb = cb
|
||||||
|
|
||||||
|
def set_log_callback(self, cb):
|
||||||
|
"""cb(msg: str) called for mpv log messages."""
|
||||||
|
self._log_cb = cb
|
||||||
|
|
||||||
|
def _log(self, msg):
|
||||||
|
cb = self._log_cb
|
||||||
|
if cb:
|
||||||
|
cb(msg)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Clean up mpv resources."""
|
||||||
|
self._log('mpv_widget: shutdown()')
|
||||||
|
if self._ctx:
|
||||||
|
self._ctx.free()
|
||||||
|
self._ctx = None
|
||||||
|
if self._mpv:
|
||||||
|
self._mpv.terminate()
|
||||||
|
self._mpv = None
|
||||||
|
|
||||||
|
# ── GL callbacks ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_realize(self, area):
|
||||||
|
self._log('mpv_widget: realize')
|
||||||
|
area.make_current()
|
||||||
|
|
||||||
|
proc_fn = mpv.MpvGlGetProcAddressFn(
|
||||||
|
lambda _inst, name: _egl_get_proc(name),
|
||||||
|
)
|
||||||
|
|
||||||
|
display_param = _get_display_param()
|
||||||
|
self._log(f'mpv_widget: display_param={display_param}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ctx = mpv.MpvRenderContext(
|
||||||
|
self._mpv,
|
||||||
|
'opengl',
|
||||||
|
opengl_init_params={'get_proc_address': proc_fn},
|
||||||
|
**display_param,
|
||||||
|
)
|
||||||
|
self._log('mpv_widget: MpvRenderContext created OK')
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f'mpv_widget: MpvRenderContext FAILED: {exc}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._ctx.update_cb = lambda: GLib.idle_add(
|
||||||
|
self.queue_render,
|
||||||
|
priority=GLib.PRIORITY_HIGH_IDLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Observe events
|
||||||
|
@self._mpv.event_callback('file-loaded')
|
||||||
|
def _on_loaded(_evt):
|
||||||
|
self._log('mpv: file-loaded')
|
||||||
|
if self._on_file_loaded_cb:
|
||||||
|
GLib.idle_add(self._on_file_loaded_cb)
|
||||||
|
|
||||||
|
@self._mpv.property_observer('eof-reached')
|
||||||
|
def _on_eof(_name, value):
|
||||||
|
self._log(f'mpv: eof-reached={value}')
|
||||||
|
if value and self._on_eof_cb:
|
||||||
|
GLib.idle_add(self._on_eof_cb)
|
||||||
|
|
||||||
|
def _on_render(self, area, _gl_ctx):
|
||||||
|
if not self._ctx:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_glGetIntegerv(_GL_FRAMEBUFFER_BINDING, self._fbo)
|
||||||
|
scale = area.props.scale_factor
|
||||||
|
w = int(area.get_width() * scale)
|
||||||
|
h = int(area.get_height() * scale)
|
||||||
|
self._ctx.render(
|
||||||
|
flip_y=True,
|
||||||
|
opengl_fbo={'w': w, 'h': h, 'fbo': self._fbo.value},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f'mpv_widget: render error: {exc}')
|
||||||
|
|
||||||
|
def _on_unrealize(self, _area):
|
||||||
|
self._log('mpv_widget: unrealize')
|
||||||
|
self.shutdown()
|
||||||
@@ -6,7 +6,7 @@ from gi.repository import Gtk
|
|||||||
class TorrentRow(Gtk.ListBoxRow):
|
class TorrentRow(Gtk.ListBoxRow):
|
||||||
"""Row widget displaying a torrent search result."""
|
"""Row widget displaying a torrent search result."""
|
||||||
|
|
||||||
def __init__(self, torrent, on_download, on_copy):
|
def __init__(self, torrent, on_download, on_copy, on_stream=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.torrent = torrent
|
self.torrent = torrent
|
||||||
|
|
||||||
@@ -56,6 +56,15 @@ class TorrentRow(Gtk.ListBoxRow):
|
|||||||
actions = Gtk.Box(spacing=8, valign=Gtk.Align.CENTER)
|
actions = Gtk.Box(spacing=8, valign=Gtk.Align.CENTER)
|
||||||
box.append(actions)
|
box.append(actions)
|
||||||
|
|
||||||
|
if on_stream:
|
||||||
|
stream_btn = Gtk.Button(
|
||||||
|
icon_name='media-playback-start-symbolic',
|
||||||
|
tooltip_text='Смотреть',
|
||||||
|
)
|
||||||
|
stream_btn.add_css_class('suggested-action')
|
||||||
|
stream_btn.connect('clicked', lambda *_: on_stream(torrent))
|
||||||
|
actions.append(stream_btn)
|
||||||
|
|
||||||
dl_btn = Gtk.Button(
|
dl_btn = Gtk.Button(
|
||||||
icon_name='folder-download-symbolic',
|
icon_name='folder-download-symbolic',
|
||||||
tooltip_text='Скачать',
|
tooltip_text='Скачать',
|
||||||
|
|||||||
+3
-4
@@ -1,18 +1,16 @@
|
|||||||
import gi
|
|
||||||
gi.require_version('Gtk', '4.0')
|
|
||||||
gi.require_version('Adw', '1')
|
|
||||||
from gi.repository import Adw, Gtk
|
from gi.repository import Adw, Gtk
|
||||||
|
|
||||||
from kadr.services.settings import SettingsManager
|
from kadr.services.settings import SettingsManager
|
||||||
from kadr.services.tmdb import TMDBService
|
from kadr.services.tmdb import TMDBService
|
||||||
from kadr.services.jackett import JackettService
|
from kadr.services.jackett import JackettService
|
||||||
from kadr.services.downloads import DownloadManager
|
from kadr.services.downloads import DownloadManager
|
||||||
|
from kadr.services.streamer import TorrentStreamer
|
||||||
|
|
||||||
|
|
||||||
class KadrWindow(Adw.ApplicationWindow):
|
class KadrWindow(Adw.ApplicationWindow):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.set_default_size(870, 800)
|
self.set_default_size(1200, 800)
|
||||||
self.set_title("Kadr")
|
self.set_title("Kadr")
|
||||||
|
|
||||||
# Backend services
|
# Backend services
|
||||||
@@ -20,6 +18,7 @@ class KadrWindow(Adw.ApplicationWindow):
|
|||||||
self.tmdb = TMDBService(self.settings_manager)
|
self.tmdb = TMDBService(self.settings_manager)
|
||||||
self.jackett = JackettService(self.settings_manager)
|
self.jackett = JackettService(self.settings_manager)
|
||||||
self.downloads = DownloadManager(self.settings_manager)
|
self.downloads = DownloadManager(self.settings_manager)
|
||||||
|
self.streamer = TorrentStreamer()
|
||||||
|
|
||||||
# Toast overlay (wraps everything for notifications)
|
# Toast overlay (wraps everything for notifications)
|
||||||
self.toast_overlay = Adw.ToastOverlay()
|
self.toast_overlay = Adw.ToastOverlay()
|
||||||
|
|||||||
Reference in New Issue
Block a user