+
+
- 🎬 Элегантное приложение для поиска торрентов фильмов и сериалов + Поиск торрентов фильмов и сериалов через TMDB и Jackett
-- Установка • - Особенности • - Запуск • - Лицензия -
---- +## О проекте -## ✨ Особенности +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` — Выход +- Made with ❤️ by Cheviiot -
+## Настройки + +| Параметр | Варианты | По умолчанию | +|------------------|-------------------------------------------|----------------| +| Торрент-сервер | Автоматически / 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) diff --git a/pyproject.toml b/pyproject.toml index b68285d..fd16c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/kadr/__init__.py b/src/kadr/__init__.py index f340918..15cd1eb 100644 --- a/src/kadr/__init__.py +++ b/src/kadr/__init__.py @@ -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' diff --git a/src/kadr/application.py b/src/kadr/application.py index 0b27a4f..c4fd3af 100644 --- a/src/kadr/application.py +++ b/src/kadr/application.py @@ -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() diff --git a/src/kadr/data/style.css b/src/kadr/data/style.css index c5fa68e..4664c32 100644 --- a/src/kadr/data/style.css +++ b/src/kadr/data/style.css @@ -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; +} diff --git a/src/kadr/services/downloads.py b/src/kadr/services/downloads.py index 4c89640..ca47093 100644 --- a/src/kadr/services/downloads.py +++ b/src/kadr/services/downloads.py @@ -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, diff --git a/src/kadr/services/jackett.py b/src/kadr/services/jackett.py index cb2167a..a7ff82f 100644 --- a/src/kadr/services/jackett.py +++ b/src/kadr/services/jackett.py @@ -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': ''}, ] diff --git a/src/kadr/services/streamer.py b/src/kadr/services/streamer.py new file mode 100644 index 0000000..e3fe720 --- /dev/null +++ b/src/kadr/services/streamer.py @@ -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() diff --git a/src/kadr/services/tmdb.py b/src/kadr/services/tmdb.py index 77a08a5..3cf0193 100644 --- a/src/kadr/services/tmdb.py +++ b/src/kadr/services/tmdb.py @@ -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) diff --git a/src/kadr/utils.py b/src/kadr/utils.py index 590f0a1..00074f1 100644 --- a/src/kadr/utils.py +++ b/src/kadr/utils.py @@ -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) diff --git a/src/kadr/views/detail.py b/src/kadr/views/detail.py index ec30946..0aa3621 100644 --- a/src/kadr/views/detail.py +++ b/src/kadr/views/detail.py @@ -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) diff --git a/src/kadr/views/player.py b/src/kadr/views/player.py new file mode 100644 index 0000000..2e4ee6d --- /dev/null +++ b/src/kadr/views/player.py @@ -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 diff --git a/src/kadr/views/torrents.py b/src/kadr/views/torrents.py new file mode 100644 index 0000000..692e8dc --- /dev/null +++ b/src/kadr/views/torrents.py @@ -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) diff --git a/src/kadr/widgets/media_card.py b/src/kadr/widgets/media_card.py index efbf269..45e99b8 100644 --- a/src/kadr/widgets/media_card.py +++ b/src/kadr/widgets/media_card.py @@ -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): diff --git a/src/kadr/widgets/mpv_widget.py b/src/kadr/widgets/mpv_widget.py new file mode 100644 index 0000000..9a12d78 --- /dev/null +++ b/src/kadr/widgets/mpv_widget.py @@ -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() diff --git a/src/kadr/widgets/torrent_row.py b/src/kadr/widgets/torrent_row.py index 4bb0405..6171d1f 100644 --- a/src/kadr/widgets/torrent_row.py +++ b/src/kadr/widgets/torrent_row.py @@ -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='Скачать', diff --git a/src/kadr/window.py b/src/kadr/window.py index e0d82b4..32faa5b 100644 --- a/src/kadr/window.py +++ b/src/kadr/window.py @@ -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()