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:
2026-04-01 22:00:37 +10:00
parent 3508a7c5dd
commit 32fe518259
18 changed files with 1807 additions and 354 deletions
+35 -17
View File
@@ -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>
+70 -98
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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='© 20242026 Cheviiot', copyright='© 2026 Cheviiot',
) )
about.present() about.present()
+69
View File
@@ -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;
}
+33 -4
View File
@@ -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,
+2 -2
View File
@@ -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': ''},
] ]
+451
View File
@@ -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
View File
@@ -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)
+10 -2
View File
@@ -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,7 +34,9 @@ def load_image_async(url, width, height, callback):
cache_key = f'{width}x{height}:{url}' cache_key = f'{width}x{height}:{url}'
with _image_lock:
if cache_key in _image_cache: if cache_key in _image_cache:
_image_cache.move_to_end(cache_key)
GLib.idle_add(callback, _image_cache[cache_key]) GLib.idle_add(callback, _image_cache[cache_key])
return return
@@ -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)
with _image_lock:
_image_cache[cache_key] = texture _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
View File
@@ -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)
+290
View File
@@ -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
+353
View File
@@ -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)
-1
View File
@@ -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):
+217
View File
@@ -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()
+10 -1
View File
@@ -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
View File
@@ -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()