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
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
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
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
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,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
DETAILED TERMS AND CONDITIONS
|
||||
|
||||
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>
|
||||
|
||||
<p align="center">
|
||||
<strong>🎬 Элегантное приложение для поиска торрентов фильмов и сериалов</strong>
|
||||
Поиск торрентов фильмов и сериалов через TMDB и Jackett
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<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/GTK-4-4a86cf?style=flat-square" alt="GTK 4">
|
||||
<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 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
|
||||
cd Kadr
|
||||
./kadr
|
||||
```
|
||||
|
||||
**Горячие клавиши:**
|
||||
- `Ctrl+F` — Поиск
|
||||
- `Ctrl+Q` — Выход
|
||||
<details>
|
||||
<summary><strong>Зависимости</strong></summary>
|
||||
|
||||
---
|
||||
| Дистрибутив | Команда |
|
||||
|------------------|---------------------------------------------------------------------------------|
|
||||
| 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]
|
||||
name = "kadr"
|
||||
version = "2.0.0"
|
||||
version = "0.1.0"
|
||||
description = "Movie & TV torrent search application"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Cheviiot" }]
|
||||
keywords = ["movies", "torrents", "tmdb", "jackett", "gtk4", "libadwaita"]
|
||||
@@ -11,7 +11,7 @@ classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"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",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = '2.0.0'
|
||||
__version__ = '0.1.0'
|
||||
APP_ID = 'io.github.cheviiot.kadr'
|
||||
APP_NAME = 'Kadr'
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ class KadrApplication(Adw.Application):
|
||||
developer_name='Cheviiot',
|
||||
website='https://github.com/Cheviiot/Kadr',
|
||||
issue_url='https://github.com/Cheviiot/Kadr/issues',
|
||||
license_type=Gtk.License.MIT_X11,
|
||||
license_type=Gtk.License.GPL_3_0,
|
||||
developers=['Cheviiot'],
|
||||
copyright='© 2024–2026 Cheviiot',
|
||||
copyright='© 2026 Cheviiot',
|
||||
)
|
||||
about.present()
|
||||
|
||||
@@ -88,3 +88,72 @@ flowboxchild:selected {
|
||||
.error-label {
|
||||
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'},
|
||||
]
|
||||
|
||||
_HISTORY_MAX = 100
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
def __init__(self, settings=None):
|
||||
@@ -27,19 +29,45 @@ class DownloadManager:
|
||||
self._data_file = os.path.join(self._data_dir, 'downloads.json')
|
||||
self._history = self._load_history()
|
||||
self._lock = threading.Lock()
|
||||
self._tmp_dir = os.path.join(tempfile.gettempdir(), 'kadr_torrents')
|
||||
self._cleanup_temp()
|
||||
|
||||
def _load_history(self):
|
||||
try:
|
||||
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):
|
||||
return []
|
||||
|
||||
def _save_history(self):
|
||||
os.makedirs(self._data_dir, exist_ok=True)
|
||||
self._history = self._history[-_HISTORY_MAX:]
|
||||
with open(self._data_file, 'w') as f:
|
||||
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):
|
||||
return [c for c in DOWNLOAD_CLIENTS if shutil.which(c['command'])]
|
||||
|
||||
@@ -64,7 +92,8 @@ class DownloadManager:
|
||||
'Торрент-клиент не найден. '
|
||||
'Установите qBittorrent, Transmission или другой клиент.'
|
||||
)
|
||||
subprocess.Popen([client['command'], magnet_uri])
|
||||
|
||||
self._launch_client(client, magnet_uri)
|
||||
|
||||
entry = {
|
||||
'name': name,
|
||||
@@ -94,14 +123,14 @@ class DownloadManager:
|
||||
if not safe_name.lower().endswith('.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)
|
||||
path = os.path.join(tmp_dir, safe_name)
|
||||
|
||||
with open(path, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
|
||||
subprocess.Popen([client['command'], path])
|
||||
self._launch_client(client, path)
|
||||
|
||||
entry = {
|
||||
'name': name,
|
||||
|
||||
@@ -3,9 +3,9 @@ import requests
|
||||
import urllib.parse
|
||||
|
||||
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_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 f"{self._proxy['image_url']}/t/p/{size}{path}"
|
||||
|
||||
def search_movies(self, query, page=1):
|
||||
url = self._api_url('/search/movie', query=query, page=page)
|
||||
resp = self.session.get(url, timeout=15)
|
||||
def _fetch(self, path, timeout=10, **params):
|
||||
url = self._api_url(path, **params)
|
||||
resp = self.session.get(url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
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):
|
||||
url = self._api_url('/search/tv', query=query, page=page)
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._fetch('/search/tv', query=query, page=page)
|
||||
|
||||
def popular_movies(self, page=1):
|
||||
url = self._api_url('/movie/popular', page=page)
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._fetch('/movie/popular', page=page)
|
||||
|
||||
def popular_tv(self, page=1):
|
||||
url = self._api_url('/tv/popular', page=page)
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._fetch('/tv/popular', page=page)
|
||||
|
||||
def movie_details(self, movie_id):
|
||||
url = self._api_url(f'/movie/{int(movie_id)}')
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._fetch(f'/movie/{int(movie_id)}')
|
||||
|
||||
def tv_details(self, tv_id):
|
||||
url = self._api_url(f'/tv/{int(tv_id)}')
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._fetch(f'/tv/{int(tv_id)}')
|
||||
|
||||
def movie_credits(self, movie_id):
|
||||
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'):
|
||||
url = self._api_url(f'/genre/{media_type}/list')
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get('genres', [])
|
||||
return self._fetch(f'/genre/{media_type}/list').get('genres', [])
|
||||
|
||||
def check_health(self, proxy_id):
|
||||
proxy = TMDB_PROXIES.get(proxy_id)
|
||||
|
||||
+14
-6
@@ -2,12 +2,15 @@ import os
|
||||
import hashlib
|
||||
import threading
|
||||
import subprocess
|
||||
import collections
|
||||
|
||||
from gi.repository import GLib, GdkPixbuf, Gdk
|
||||
|
||||
import requests
|
||||
|
||||
_image_cache = {}
|
||||
_IMAGE_CACHE_MAX = 200
|
||||
_image_cache = collections.OrderedDict()
|
||||
_image_lock = threading.Lock()
|
||||
_cache_dir = None
|
||||
_download_sem = threading.Semaphore(6)
|
||||
|
||||
@@ -31,9 +34,11 @@ def load_image_async(url, width, height, callback):
|
||||
|
||||
cache_key = f'{width}x{height}:{url}'
|
||||
|
||||
if cache_key in _image_cache:
|
||||
GLib.idle_add(callback, _image_cache[cache_key])
|
||||
return
|
||||
with _image_lock:
|
||||
if cache_key in _image_cache:
|
||||
_image_cache.move_to_end(cache_key)
|
||||
GLib.idle_add(callback, _image_cache[cache_key])
|
||||
return
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
@@ -82,7 +87,10 @@ def load_image_async(url, width, height, callback):
|
||||
|
||||
if 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)
|
||||
else:
|
||||
GLib.idle_add(callback, None)
|
||||
@@ -107,9 +115,9 @@ def run_async(func, callback, *args):
|
||||
def copy_to_clipboard(text):
|
||||
"""Copy text to clipboard using system tools."""
|
||||
for cmd in (
|
||||
['wl-copy'],
|
||||
['xclip', '-selection', 'clipboard'],
|
||||
['xsel', '--clipboard', '--input'],
|
||||
['wl-copy'],
|
||||
):
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
|
||||
+236
-188
@@ -3,19 +3,25 @@ gi.require_version('Gtk', '4.0')
|
||||
gi.require_version('Adw', '1')
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from kadr.utils import run_async, load_image_async, copy_to_clipboard
|
||||
from kadr.widgets.torrent_row import TorrentRow
|
||||
from kadr.utils import run_async, load_image_async
|
||||
|
||||
|
||||
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:
|
||||
"""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):
|
||||
self.window = window
|
||||
self.media_type = media_type
|
||||
self.data = data
|
||||
self._build()
|
||||
self._search_torrents()
|
||||
self._load_details()
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
@@ -26,243 +32,285 @@ class DetailView:
|
||||
def _build(self):
|
||||
from kadr.widgets.media_card import _resolve_title
|
||||
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()
|
||||
self._page.set_child(toolbar_view)
|
||||
|
||||
header = Adw.HeaderBar()
|
||||
header.add_css_class('flat')
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
scrolled = Gtk.ScrolledWindow(vexpand=True)
|
||||
toolbar_view.set_content(scrolled)
|
||||
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
|
||||
content.set_margin_start(24)
|
||||
content.set_margin_end(24)
|
||||
content.set_margin_top(24)
|
||||
content.set_margin_bottom(24)
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
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)
|
||||
content.append(info_box)
|
||||
# Backdrop image
|
||||
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_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_valign(Gtk.Align.END)
|
||||
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)
|
||||
poster_frame.set_child(self._poster)
|
||||
info_box.append(poster_frame)
|
||||
hero_content.append(poster_frame)
|
||||
|
||||
poster_path = self.data.get('poster_path', '')
|
||||
if poster_path:
|
||||
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
|
||||
details = Gtk.Box(
|
||||
# Hero info column
|
||||
hero_info = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=12,
|
||||
spacing=8,
|
||||
hexpand=True,
|
||||
valign=Gtk.Align.START,
|
||||
valign=Gtk.Align.END,
|
||||
)
|
||||
info_box.append(details)
|
||||
hero_content.append(hero_info)
|
||||
|
||||
# Title
|
||||
title_label = Gtk.Label(label=title, xalign=0, wrap=True)
|
||||
title_label.add_css_class('title-1')
|
||||
details.append(title_label)
|
||||
title_label.add_css_class('detail-title')
|
||||
hero_info.append(title_label)
|
||||
|
||||
# Meta (rating, year)
|
||||
meta = Gtk.Box(spacing=12)
|
||||
details.append(meta)
|
||||
# Tagline placeholder (filled by _load_details)
|
||||
self._tagline_label = Gtk.Label(xalign=0, wrap=True, visible=False)
|
||||
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)
|
||||
if rating > 0:
|
||||
rating_lbl = Gtk.Label(label=f'★ {rating:.1f}')
|
||||
rating_lbl.add_css_class('rating-badge')
|
||||
meta.append(rating_lbl)
|
||||
|
||||
self._add_chip(f'★ {rating:.1f}', 'chip-rating')
|
||||
if year_str:
|
||||
year_lbl = Gtk.Label(label=year_str)
|
||||
year_lbl.add_css_class('dim-label')
|
||||
meta.append(year_lbl)
|
||||
self._add_chip(year_str)
|
||||
|
||||
# ── 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 = self.data.get('overview', '')
|
||||
if overview:
|
||||
desc = Gtk.Label(
|
||||
label=overview, xalign=0, wrap=True, max_width_chars=80,
|
||||
overview_label = Gtk.Label(
|
||||
label=overview, xalign=0, wrap=True,
|
||||
max_width_chars=100,
|
||||
)
|
||||
desc.set_valign(Gtk.Align.START)
|
||||
details.append(desc)
|
||||
overview_label.add_css_class('detail-overview')
|
||||
body.append(overview_label)
|
||||
|
||||
# ── Torrents section ──────────────────────────────────
|
||||
|
||||
content.append(Gtk.Separator())
|
||||
|
||||
torrents_header = Gtk.Box(spacing=12)
|
||||
content.append(torrents_header)
|
||||
|
||||
torrents_title = Gtk.Label(
|
||||
label='Торренты', xalign=0, hexpand=True,
|
||||
# Cast section placeholder
|
||||
self._cast_section = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=12, visible=False,
|
||||
)
|
||||
torrents_title.add_css_class('title-2')
|
||||
torrents_header.append(torrents_title)
|
||||
body.append(self._cast_section)
|
||||
|
||||
refresh_btn = Gtk.Button(
|
||||
icon_name='view-refresh-symbolic',
|
||||
tooltip_text='Обновить',
|
||||
cast_title = Gtk.Label(label='Актёры', xalign=0)
|
||||
cast_title.add_css_class('title-3')
|
||||
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())
|
||||
torrents_header.append(refresh_btn)
|
||||
cast_scroll.set_min_content_height(180)
|
||||
self._cast_section.append(cast_scroll)
|
||||
|
||||
self._torrents_spinner = Gtk.Spinner(
|
||||
spinning=True,
|
||||
halign=Gtk.Align.CENTER,
|
||||
self._cast_box = Gtk.Box(spacing=12)
|
||||
cast_scroll.set_child(self._cast_box)
|
||||
|
||||
# ── 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._torrents_spinner.set_margin_top(24)
|
||||
self._torrents_spinner.set_margin_bottom(24)
|
||||
content.append(self._torrents_spinner)
|
||||
self.window.nav_view.push(view.page)
|
||||
|
||||
self._status_label = Gtk.Label(
|
||||
halign=Gtk.Align.CENTER, visible=False,
|
||||
# ── Load full details + credits ───────────────────────────
|
||||
|
||||
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')
|
||||
content.append(self._status_label)
|
||||
card.set_size_request(100, -1)
|
||||
|
||||
self._torrent_list = Gtk.ListBox(
|
||||
selection_mode=Gtk.SelectionMode.NONE,
|
||||
visible=False,
|
||||
# Avatar
|
||||
avatar_frame = Gtk.Frame()
|
||||
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')
|
||||
content.append(self._torrent_list)
|
||||
name_label.add_css_class('caption')
|
||||
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):
|
||||
if 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_HEIGHT = 232
|
||||
COVER_RATIO = 1.5
|
||||
|
||||
|
||||
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):
|
||||
"""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__()
|
||||
self.torrent = torrent
|
||||
|
||||
@@ -56,6 +56,15 @@ class TorrentRow(Gtk.ListBoxRow):
|
||||
actions = Gtk.Box(spacing=8, valign=Gtk.Align.CENTER)
|
||||
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(
|
||||
icon_name='folder-download-symbolic',
|
||||
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 kadr.services.settings import SettingsManager
|
||||
from kadr.services.tmdb import TMDBService
|
||||
from kadr.services.jackett import JackettService
|
||||
from kadr.services.downloads import DownloadManager
|
||||
from kadr.services.streamer import TorrentStreamer
|
||||
|
||||
|
||||
class KadrWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.set_default_size(870, 800)
|
||||
self.set_default_size(1200, 800)
|
||||
self.set_title("Kadr")
|
||||
|
||||
# Backend services
|
||||
@@ -20,6 +18,7 @@ class KadrWindow(Adw.ApplicationWindow):
|
||||
self.tmdb = TMDBService(self.settings_manager)
|
||||
self.jackett = JackettService(self.settings_manager)
|
||||
self.downloads = DownloadManager(self.settings_manager)
|
||||
self.streamer = TorrentStreamer()
|
||||
|
||||
# Toast overlay (wraps everything for notifications)
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
|
||||
Reference in New Issue
Block a user