commit 3508a7c5ddfed984aa5903779476e38bbc4fdd24
Author: Александр <6terio9@vk.com>
Date: Fri Mar 20 10:57:17 2026 +1000
Initial commit: Kadr v2.0.0
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7737bc7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,58 @@
+# Python
+__pycache__/
+.pycache/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+.venv/
+venv/
+ENV/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+.directory
+
+# Project
+*.log
+.cache/
+
+# Logs
+*.log
+npm-debug.log*
+
+# Temp
+*.tmp
+*.temp
+.cache/
+
+# Package artifacts
+*.AppImage
+*.deb
+*.rpm
+*.pkg.tar.zst
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2af49b7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Cheviiot (https://github.com/Cheviiot)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b62fd76
--- /dev/null
+++ b/README.md
@@ -0,0 +1,127 @@
+
Kadr
+
+
+ 🎬 Элегантное приложение для поиска торрентов фильмов и сериалов
+
+
+
+
+
+
+
+
+ Установка •
+ Особенности •
+ Запуск •
+ Лицензия
+
+
+---
+
+## ✨ Особенности
+
+- 🔍 **Поиск фильмов и сериалов** — интеграция с TMDB
+- 🧲 **Поиск торрентов** — через публичные Jackett серверы (без настройки!)
+- ⬇️ **Загрузка в один клик** — отправка в FDM, qBittorrent, Transmission, Deluge, KTorrent
+- 🎨 **Нативный интерфейс** — GTK4 + libadwaita
+- 🚀 **Быстрая работа** — Python, асинхронная загрузка изображений
+
+---
+
+## 🛠️ Технологии
+
+| Компонент | Стек |
+|-----------|------|
+| 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` — Выход
+
+---
+
+## 📁 Структура проекта
+
+```
+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)
+```
+
+---
+
+## 📄 Лицензия
+
+[MIT](LICENSE)
+
+---
+
+
+ Made with ❤️ by Cheviiot
+
diff --git a/data/Kadr.png b/data/Kadr.png
new file mode 100644
index 0000000..86eee80
Binary files /dev/null and b/data/Kadr.png differ
diff --git a/data/icons/hicolor/128x128/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/128x128/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..e0b48c2
Binary files /dev/null and b/data/icons/hicolor/128x128/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/16x16/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/16x16/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..80d374d
Binary files /dev/null and b/data/icons/hicolor/16x16/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/24x24/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/24x24/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..f70a431
Binary files /dev/null and b/data/icons/hicolor/24x24/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/256x256/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/256x256/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..1cc4a62
Binary files /dev/null and b/data/icons/hicolor/256x256/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/32x32/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/32x32/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..d4d65da
Binary files /dev/null and b/data/icons/hicolor/32x32/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/48x48/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/48x48/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..da63bdf
Binary files /dev/null and b/data/icons/hicolor/48x48/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/512x512/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/512x512/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..33e370c
Binary files /dev/null and b/data/icons/hicolor/512x512/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/64x64/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/64x64/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..9724173
Binary files /dev/null and b/data/icons/hicolor/64x64/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/icons/hicolor/scalable/apps/io.github.cheviiot.kadr.png b/data/icons/hicolor/scalable/apps/io.github.cheviiot.kadr.png
new file mode 100644
index 0000000..be05c75
Binary files /dev/null and b/data/icons/hicolor/scalable/apps/io.github.cheviiot.kadr.png differ
diff --git a/data/io.github.cheviiot.kadr.desktop b/data/io.github.cheviiot.kadr.desktop
new file mode 100644
index 0000000..fecf26a
--- /dev/null
+++ b/data/io.github.cheviiot.kadr.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Name=Kadr
+Comment=Movie & TV torrent search
+Comment[ru]=Поиск торрентов фильмов и сериалов
+Exec=kadr
+Icon=io.github.cheviiot.kadr
+Terminal=false
+Type=Application
+Categories=AudioVideo;Video;Network;
+Keywords=movies;torrents;tmdb;
+StartupNotify=true
diff --git a/kadr b/kadr
new file mode 100755
index 0000000..2c11657
--- /dev/null
+++ b/kadr
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+"""Dev launcher for Kadr."""
+
+import sys
+from pathlib import Path
+
+# All pycache in one place
+sys.pycache_prefix = str(Path(__file__).resolve().parent / ".pycache")
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
+
+from kadr.__main__ import main
+
+main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b68285d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,37 @@
+[project]
+name = "kadr"
+version = "2.0.0"
+description = "Movie & TV torrent search application"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.10"
+authors = [{ name = "Cheviiot" }]
+keywords = ["movies", "torrents", "tmdb", "jackett", "gtk4", "libadwaita"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: X11 Applications :: GTK",
+ "Intended Audience :: End Users/Desktop",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: POSIX :: Linux",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Multimedia :: Video",
+]
+dependencies = ["requests>=2.28.0"]
+
+[project.urls]
+Homepage = "https://github.com/Cheviiot/Kadr"
+Repository = "https://github.com/Cheviiot/Kadr"
+Issues = "https://github.com/Cheviiot/Kadr/issues"
+
+[project.gui-scripts]
+kadr = "kadr.__main__:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/kadr"]
diff --git a/src/kadr/__init__.py b/src/kadr/__init__.py
new file mode 100644
index 0000000..f340918
--- /dev/null
+++ b/src/kadr/__init__.py
@@ -0,0 +1,23 @@
+"""Kadr — приложение для поиска торрентов фильмов и сериалов."""
+
+from pathlib import Path
+
+__version__ = '2.0.0'
+APP_ID = 'io.github.cheviiot.kadr'
+APP_NAME = 'Kadr'
+
+# Paths: try development first, then fall back to installed locations
+_PKG_DIR = Path(__file__).parent
+_DATA_DIR = _PKG_DIR.parent.parent / 'data'
+
+# Icon
+_DEV_ICON = _DATA_DIR / 'Kadr.png'
+ICON_PATH = _DEV_ICON if _DEV_ICON.exists() else None
+
+# Hicolor icon theme directory (for dev runs)
+_ICONS_DIR = _DATA_DIR / 'icons'
+ICONS_DIR = _ICONS_DIR if _ICONS_DIR.is_dir() else None
+
+# CSS (in package data)
+_PKG_CSS = _PKG_DIR / 'data' / 'style.css'
+CSS_PATH = _PKG_CSS if _PKG_CSS.exists() else None
diff --git a/src/kadr/__main__.py b/src/kadr/__main__.py
new file mode 100644
index 0000000..e1bb646
--- /dev/null
+++ b/src/kadr/__main__.py
@@ -0,0 +1,54 @@
+"""Entry point for the Kadr application."""
+
+import os
+import sys
+
+
+def _ensure_gi_available():
+ """Add system site-packages to path if gi is not importable (e.g., in venv)."""
+ try:
+ __import__("gi")
+ return
+ except ImportError:
+ pass
+
+ candidates = [
+ "/usr/lib64/python3/site-packages",
+ "/usr/lib/python3/site-packages",
+ "/usr/lib/python3/dist-packages",
+ f"/usr/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages",
+ ]
+ for path in candidates:
+ if os.path.isdir(os.path.join(path, "gi")):
+ sys.path.insert(0, path)
+ return
+
+ print(
+ "Error: PyGObject (gi) not found.\n"
+ "Install it with your package manager:\n"
+ " Fedora: sudo dnf install python3-gobject gtk4 libadwaita\n"
+ " Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1\n"
+ " Arch: sudo pacman -S python-gobject gtk4 libadwaita",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+def main():
+ _ensure_gi_available()
+
+ import gi
+
+ gi.require_version("Gtk", "4.0")
+ gi.require_version("Adw", "1")
+ gi.require_version("Gdk", "4.0")
+ gi.require_version("GdkPixbuf", "2.0")
+
+ from kadr.application import KadrApplication
+
+ app = KadrApplication()
+ app.run(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/kadr/application.py b/src/kadr/application.py
new file mode 100644
index 0000000..0b27a4f
--- /dev/null
+++ b/src/kadr/application.py
@@ -0,0 +1,91 @@
+from gi.repository import Adw, Gtk, Gdk, Gio
+
+from kadr import APP_ID, CSS_PATH, ICON_PATH, ICONS_DIR
+
+
+class KadrApplication(Adw.Application):
+ def __init__(self):
+ super().__init__(
+ application_id=APP_ID,
+ flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
+ )
+
+ def do_startup(self):
+ Adw.Application.do_startup(self)
+ self._register_icons()
+ self._load_css()
+ self._setup_actions()
+
+ def do_activate(self):
+ win = self.props.active_window
+ if not win:
+ from kadr.window import KadrWindow
+ win = KadrWindow(application=self)
+ win.present()
+
+ def _register_icons(self):
+ if ICONS_DIR:
+ theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
+ theme.add_search_path(str(ICONS_DIR))
+
+ def _load_css(self):
+ if not CSS_PATH:
+ return
+ provider = Gtk.CssProvider()
+ provider.load_from_path(str(CSS_PATH))
+ Gtk.StyleContext.add_provider_for_display(
+ Gdk.Display.get_default(),
+ provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
+ )
+
+ def _setup_actions(self):
+ quit_action = Gio.SimpleAction.new('quit', None)
+ quit_action.connect('activate', lambda *_: self.quit())
+ self.add_action(quit_action)
+ self.set_accels_for_action('app.quit', ['q'])
+
+ search_action = Gio.SimpleAction.new('search', None)
+ search_action.connect('activate', self._on_search_action)
+ self.add_action(search_action)
+ self.set_accels_for_action('app.search', ['f'])
+
+ prefs_action = Gio.SimpleAction.new('preferences', None)
+ prefs_action.connect('activate', self._on_preferences)
+ self.add_action(prefs_action)
+ self.set_accels_for_action('app.preferences', ['comma'])
+
+ about_action = Gio.SimpleAction.new('about', None)
+ about_action.connect('activate', self._on_about)
+ self.add_action(about_action)
+
+ def _on_search_action(self, action, param):
+ win = self.props.active_window
+ if win and hasattr(win, 'toggle_search'):
+ win.toggle_search()
+
+ def _on_preferences(self, action, param):
+ win = self.props.active_window
+ if win:
+ from kadr.views.settings_dialog import SettingsDialog
+ dialog = SettingsDialog(win)
+ dialog.present()
+
+ def _on_about(self, action, param):
+ win = self.props.active_window
+ if not win:
+ return
+ from kadr import __version__, APP_ID, APP_NAME
+ about = Adw.AboutWindow(
+ transient_for=win,
+ application_name=APP_NAME,
+ application_icon=APP_ID,
+ version=__version__,
+ developer_name='Cheviiot',
+ website='https://github.com/Cheviiot/Kadr',
+ issue_url='https://github.com/Cheviiot/Kadr/issues',
+ license_type=Gtk.License.MIT_X11,
+ developers=['Cheviiot'],
+ copyright='© 2024–2026 Cheviiot',
+ )
+ about.present()
diff --git a/src/kadr/data/style.css b/src/kadr/data/style.css
new file mode 100644
index 0000000..c5fa68e
--- /dev/null
+++ b/src/kadr/data/style.css
@@ -0,0 +1,90 @@
+/* Kadr — Custom GTK4/Adwaita styles */
+
+/* ── Grid Layout ──────────────────────────────────────────────────────────── */
+
+flowboxchild {
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ outline: none;
+ border: none;
+ box-shadow: none;
+}
+
+flowboxchild:focus,
+flowboxchild:hover,
+flowboxchild:selected {
+ background: transparent;
+ outline: none;
+ box-shadow: none;
+}
+
+/* ── Media Card (Tile) ────────────────────────────────────────────────────── */
+
+.media-card {
+ border-radius: 14px;
+ background: @card_bg_color;
+ box-shadow: 0 1px 3px alpha(black, 0.10), 0 0 0 1px alpha(black, 0.04);
+ transition: box-shadow 150ms ease-out, transform 150ms ease-out;
+}
+
+.media-card:hover {
+ box-shadow: 0 4px 12px alpha(black, 0.16), 0 0 0 1px alpha(black, 0.06);
+}
+
+/* ── Poster Image / Skeleton ──────────────────────────────────────────────── */
+
+.poster-image {
+ background-color: alpha(@card_shade_color, 0.15);
+ border-radius: 14px;
+}
+
+/* ── Card Info Overlay ────────────────────────────────────────────────────── */
+
+.card-info {
+ padding: 20px 8px 8px 8px;
+ background: linear-gradient(to bottom,
+ transparent 0%,
+ alpha(black, 0.55) 50%,
+ alpha(black, 0.75) 100%);
+ border-radius: 0 0 14px 14px;
+}
+
+.card-info label {
+ color: white;
+}
+
+.card-title {
+ font-weight: 600;
+ font-size: 0.85em;
+ opacity: 0.92;
+}
+
+.media-card:hover .card-title {
+ opacity: 1;
+}
+
+.card-year {
+ font-size: 0.78em;
+ opacity: 0.65;
+}
+
+/* ── Rating Badge ─────────────────────────────────────────────────────────── */
+
+.rating-badge {
+ background-color: rgba(0, 0, 0, 0.55);
+ color: rgba(245, 197, 24, 0.95);
+ border-radius: 6px;
+ padding: 2px 6px;
+ font-weight: bold;
+ font-size: 0.8em;
+}
+
+/* ── Status Labels ────────────────────────────────────────────────────────── */
+.success-label {
+ color: @success_color;
+}
+
+.error-label {
+ color: @error_color;
+}
diff --git a/src/kadr/services/__init__.py b/src/kadr/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/kadr/services/downloads.py b/src/kadr/services/downloads.py
new file mode 100644
index 0000000..4c89640
--- /dev/null
+++ b/src/kadr/services/downloads.py
@@ -0,0 +1,125 @@
+import os
+import shutil
+import subprocess
+import json
+import time
+import threading
+import tempfile
+
+import requests
+
+DOWNLOAD_CLIENTS = [
+ {'id': 'fdm', 'name': 'Free Download Manager', 'command': 'fdm'},
+ {'id': 'qbittorrent', 'name': 'qBittorrent', 'command': 'qbittorrent'},
+ {'id': 'transmission', 'name': 'Transmission', 'command': 'transmission-gtk'},
+ {'id': 'deluge', 'name': 'Deluge', 'command': 'deluge'},
+ {'id': 'ktorrent', 'name': 'KTorrent', 'command': 'ktorrent'},
+]
+
+
+class DownloadManager:
+ def __init__(self, settings=None):
+ self._settings = settings
+ config_dir = os.environ.get(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ )
+ self._data_dir = os.path.join(config_dir, 'Kadr')
+ self._data_file = os.path.join(self._data_dir, 'downloads.json')
+ self._history = self._load_history()
+ self._lock = threading.Lock()
+
+ def _load_history(self):
+ try:
+ with open(self._data_file) as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError):
+ return []
+
+ def _save_history(self):
+ os.makedirs(self._data_dir, exist_ok=True)
+ with open(self._data_file, 'w') as f:
+ json.dump(self._history, f, indent=2, ensure_ascii=False)
+
+ def available_clients(self):
+ return [c for c in DOWNLOAD_CLIENTS if shutil.which(c['command'])]
+
+ def find_client(self):
+ preferred = (
+ self._settings.get('download_client', 'auto')
+ if self._settings else 'auto'
+ )
+ clients = self.available_clients()
+ if not clients:
+ return None
+ if preferred != 'auto':
+ for c in clients:
+ if c['id'] == preferred:
+ return c
+ return clients[0]
+
+ def send_magnet(self, magnet_uri, name=''):
+ client = self.find_client()
+ if not client:
+ raise RuntimeError(
+ 'Торрент-клиент не найден. '
+ 'Установите qBittorrent, Transmission или другой клиент.'
+ )
+ subprocess.Popen([client['command'], magnet_uri])
+
+ entry = {
+ 'name': name,
+ 'magnet': magnet_uri,
+ 'client': client['name'],
+ 'time': int(time.time()),
+ }
+ with self._lock:
+ self._history.append(entry)
+ self._save_history()
+ return client['name']
+
+ def send_torrent_url(self, url, name=''):
+ client = self.find_client()
+ if not client:
+ raise RuntimeError(
+ 'Торрент-клиент не найден. '
+ 'Установите qBittorrent, Transmission или другой клиент.'
+ )
+
+ resp = requests.get(url, timeout=30)
+ resp.raise_for_status()
+
+ safe_name = ''.join(
+ c if c.isalnum() or c in ' .-_' else '_' for c in name
+ )
+ if not safe_name.lower().endswith('.torrent'):
+ safe_name += '.torrent'
+
+ tmp_dir = os.path.join(tempfile.gettempdir(), 'kadr_torrents')
+ 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])
+
+ entry = {
+ 'name': name,
+ 'torrent_url': url,
+ 'client': client['name'],
+ 'time': int(time.time()),
+ }
+ with self._lock:
+ self._history.append(entry)
+ self._save_history()
+ return client['name']
+
+ @property
+ def history(self):
+ with self._lock:
+ return list(self._history)
+
+ def clear_history(self):
+ with self._lock:
+ self._history = []
+ self._save_history()
diff --git a/src/kadr/services/jackett.py b/src/kadr/services/jackett.py
new file mode 100644
index 0000000..cb2167a
--- /dev/null
+++ b/src/kadr/services/jackett.py
@@ -0,0 +1,121 @@
+import time
+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': ''},
+]
+
+
+def format_bytes(size):
+ if size <= 0:
+ return '0 B'
+ for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
+ if abs(size) < 1024:
+ return f'{size:.1f} {unit}'
+ size /= 1024
+ return f'{size:.1f} PB'
+
+
+class JackettService:
+ def __init__(self, settings):
+ self.settings = settings
+ self.session = requests.Session()
+ self.session.headers['User-Agent'] = (
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
+ )
+ self._healthy_server = None
+
+ @property
+ def servers(self):
+ return list(JACKETT_SERVERS)
+
+ def _get_server(self, server_id):
+ for s in JACKETT_SERVERS:
+ if s['id'] == server_id:
+ return s
+ return None
+
+ def resolve_server_id(self):
+ """Return the server_id to use: pinned or first healthy."""
+ chosen = self.settings.get('selected_server', 'auto')
+ if chosen != 'auto':
+ return chosen
+ if self._healthy_server:
+ return self._healthy_server
+ for s in JACKETT_SERVERS:
+ if self.check_health(s['id']):
+ self._healthy_server = s['id']
+ return s['id']
+ return JACKETT_SERVERS[0]['id']
+
+ def check_health(self, server_id):
+ server = self._get_server(server_id)
+ if not server:
+ return False
+ api_path = '/api/v2.0/indexers/status:healthy/results'
+ for proto in ('https', 'http'):
+ try:
+ url = f"{proto}://{server['url']}{api_path}?apikey={server['key']}"
+ resp = self.session.get(url, timeout=5)
+ if resp.status_code in (200, 401):
+ return True
+ except Exception:
+ continue
+ return False
+
+ def search(self, server_id, query):
+ server = self._get_server(server_id)
+ if not server:
+ raise ValueError(f'Сервер не найден: {server_id}')
+
+ api_path = '/api/v2.0/indexers/status:healthy/results'
+ encoded = urllib.parse.quote(query)
+
+ last_err = None
+ for attempt in range(3):
+ if attempt > 0:
+ time.sleep(attempt * 2)
+
+ for proto in ('https', 'http'):
+ try:
+ url = (
+ f"{proto}://{server['url']}{api_path}"
+ f"?apikey={server['key']}&Query={encoded}"
+ )
+ resp = self.session.get(url, timeout=30)
+
+ if resp.status_code == 429:
+ last_err = 'Сервер перегружен (429). Попробуйте другой сервер.'
+ break
+
+ resp.raise_for_status()
+ data = resp.json()
+
+ results = []
+ for r in data.get('Results', []):
+ seeders = r.get('Seeders', 0)
+ peers = r.get('Peers', 0)
+ results.append({
+ 'title': r.get('Title', ''),
+ 'size': r.get('Size', 0),
+ 'size_str': format_bytes(r.get('Size', 0)),
+ 'seeders': seeders,
+ 'leechers': max(0, peers - seeders),
+ 'magnet_uri': r.get('MagnetUri', ''),
+ 'link': r.get('Link', ''),
+ 'tracker': r.get('Tracker', ''),
+ 'publish_date': r.get('PublishDate', ''),
+ 'category': r.get('CategoryDesc', ''),
+ })
+
+ results.sort(key=lambda x: x['seeders'], reverse=True)
+ return results
+
+ except requests.RequestException as e:
+ last_err = str(e)
+ continue
+
+ raise ConnectionError(last_err or 'Не удалось подключиться к серверу')
diff --git a/src/kadr/services/settings.py b/src/kadr/services/settings.py
new file mode 100644
index 0000000..0c019de
--- /dev/null
+++ b/src/kadr/services/settings.py
@@ -0,0 +1,38 @@
+import os
+import json
+
+DEFAULTS = {
+ 'selected_server': 'auto',
+ 'tmdb_proxy': 'auto',
+ 'download_client': 'auto',
+}
+
+
+class SettingsManager:
+ def __init__(self):
+ config_dir = os.environ.get(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ )
+ self._dir = os.path.join(config_dir, 'Kadr')
+ self._path = os.path.join(self._dir, 'settings.json')
+ self._data = dict(DEFAULTS)
+ self._load()
+
+ def _load(self):
+ try:
+ with open(self._path) as f:
+ self._data.update(json.load(f))
+ except (FileNotFoundError, json.JSONDecodeError):
+ pass
+
+ def save(self):
+ os.makedirs(self._dir, exist_ok=True)
+ with open(self._path, 'w') as f:
+ json.dump(self._data, f, indent=2, ensure_ascii=False)
+
+ def get(self, key, default=None):
+ return self._data.get(key, default)
+
+ def set(self, key, value):
+ self._data[key] = value
+ self.save()
diff --git a/src/kadr/services/tmdb.py b/src/kadr/services/tmdb.py
new file mode 100644
index 0000000..77a08a5
--- /dev/null
+++ b/src/kadr/services/tmdb.py
@@ -0,0 +1,110 @@
+import requests
+import urllib.parse
+
+TMDB_API_KEY = '4ef0d7355d9ffb5151e987764708ce96'
+
+TMDB_PROXIES = {
+ 'direct': {
+ 'name': 'TMDB Direct',
+ 'api_url': 'https://api.themoviedb.org',
+ 'image_url': 'https://image.tmdb.org',
+ },
+ 'apn_render': {
+ 'name': 'APN Render',
+ 'api_url': 'https://apn-latest.onrender.com/https://api.themoviedb.org',
+ 'image_url': 'https://apn-latest.onrender.com/https://image.tmdb.org',
+ },
+}
+
+
+class TMDBService:
+ def __init__(self, settings):
+ self.settings = settings
+ self.session = requests.Session()
+ self.session.headers.update({
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
+ 'Origin': 'https://kadr.app',
+ })
+ self._healthy_proxy = None
+
+ def _resolve_proxy_id(self):
+ """Return the proxy_id to use: pinned or first healthy."""
+ chosen = self.settings.get('tmdb_proxy', 'auto')
+ if chosen != 'auto':
+ return chosen
+ if self._healthy_proxy:
+ return self._healthy_proxy
+ for pid in TMDB_PROXIES:
+ if self.check_health(pid):
+ self._healthy_proxy = pid
+ return pid
+ return 'direct'
+
+ @property
+ def _proxy(self):
+ pid = self._resolve_proxy_id()
+ return TMDB_PROXIES.get(pid, TMDB_PROXIES['direct'])
+
+ def _api_url(self, path, **params):
+ params['api_key'] = TMDB_API_KEY
+ params.setdefault('language', 'ru-RU')
+ qs = urllib.parse.urlencode(params)
+ return f"{self._proxy['api_url']}/3{path}?{qs}"
+
+ def image_url(self, path, size='w342'):
+ if not path:
+ 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)
+ resp.raise_for_status()
+ return resp.json()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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', [])
+
+ def check_health(self, proxy_id):
+ proxy = TMDB_PROXIES.get(proxy_id)
+ if not proxy:
+ return False
+ try:
+ url = f"{proxy['api_url']}/3/movie/popular?api_key={TMDB_API_KEY}&language=ru-RU&page=1"
+ resp = self.session.get(url, timeout=5)
+ return resp.status_code == 200
+ except Exception:
+ return False
diff --git a/src/kadr/utils.py b/src/kadr/utils.py
new file mode 100644
index 0000000..590f0a1
--- /dev/null
+++ b/src/kadr/utils.py
@@ -0,0 +1,121 @@
+import os
+import hashlib
+import threading
+import subprocess
+
+from gi.repository import GLib, GdkPixbuf, Gdk
+
+import requests
+
+_image_cache = {}
+_cache_dir = None
+_download_sem = threading.Semaphore(6)
+
+
+def _get_cache_dir():
+ global _cache_dir
+ if _cache_dir is None:
+ base = os.environ.get(
+ 'XDG_CACHE_HOME', os.path.expanduser('~/.cache')
+ )
+ _cache_dir = os.path.join(base, 'kadr', 'images')
+ os.makedirs(_cache_dir, exist_ok=True)
+ return _cache_dir
+
+
+def load_image_async(url, width, height, callback):
+ """Load image from URL asynchronously with disk + memory cache."""
+ if not url:
+ GLib.idle_add(callback, None)
+ return
+
+ cache_key = f'{width}x{height}:{url}'
+
+ if cache_key in _image_cache:
+ GLib.idle_add(callback, _image_cache[cache_key])
+ return
+
+ def _worker():
+ try:
+ file_hash = hashlib.sha256(cache_key.encode()).hexdigest()[:32]
+ cache_path = os.path.join(_get_cache_dir(), file_hash)
+
+ data = None
+ if os.path.exists(cache_path):
+ with open(cache_path, 'rb') as f:
+ data = f.read()
+ if len(data) < 100:
+ data = None
+ os.remove(cache_path)
+
+ if data is None:
+ _download_sem.acquire()
+ try:
+ for _attempt in range(4):
+ try:
+ resp = requests.get(
+ url, timeout=(4, 10),
+ headers={'User-Agent': 'Mozilla/5.0'},
+ )
+ resp.raise_for_status()
+ data = resp.content
+ break
+ except (requests.ConnectionError, requests.Timeout):
+ if _attempt == 3:
+ raise
+ import time
+ time.sleep(0.3 * (_attempt + 1))
+ finally:
+ _download_sem.release()
+ with open(cache_path, 'wb') as f:
+ f.write(data)
+
+ loader = GdkPixbuf.PixbufLoader()
+ loader.write(data)
+ loader.close()
+ pixbuf = loader.get_pixbuf()
+
+ if pixbuf and width > 0 and height > 0:
+ pixbuf = pixbuf.scale_simple(
+ width, height, GdkPixbuf.InterpType.BILINEAR
+ )
+
+ if pixbuf:
+ texture = Gdk.Texture.new_for_pixbuf(pixbuf)
+ _image_cache[cache_key] = texture
+ GLib.idle_add(callback, texture)
+ else:
+ GLib.idle_add(callback, None)
+ except Exception:
+ GLib.idle_add(callback, None)
+
+ threading.Thread(target=_worker, daemon=True).start()
+
+
+def run_async(func, callback, *args):
+ """Run func(*args) in background thread, call callback(result, error) on main thread."""
+ def _worker():
+ try:
+ result = func(*args)
+ GLib.idle_add(callback, result, None)
+ except Exception as e:
+ GLib.idle_add(callback, None, e)
+
+ threading.Thread(target=_worker, daemon=True).start()
+
+
+def copy_to_clipboard(text):
+ """Copy text to clipboard using system tools."""
+ for cmd in (
+ ['xclip', '-selection', 'clipboard'],
+ ['xsel', '--clipboard', '--input'],
+ ['wl-copy'],
+ ):
+ try:
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
+ proc.communicate(text.encode())
+ if proc.returncode == 0:
+ return
+ except FileNotFoundError:
+ continue
+ raise RuntimeError('Не найдена утилита буфера обмена (xclip/xsel/wl-copy)')
diff --git a/src/kadr/views/__init__.py b/src/kadr/views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/kadr/views/detail.py b/src/kadr/views/detail.py
new file mode 100644
index 0000000..ec30946
--- /dev/null
+++ b/src/kadr/views/detail.py
@@ -0,0 +1,268 @@
+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, load_image_async, copy_to_clipboard
+from kadr.widgets.torrent_row import TorrentRow
+
+
+class DetailView:
+ """Detail view showing movie/TV info and torrent search results."""
+
+ def __init__(self, window, media_type, data):
+ self.window = window
+ self.media_type = media_type
+ self.data = data
+ self._build()
+ self._search_torrents()
+
+ @property
+ def page(self):
+ return self._page
+
+ # ── Build UI ──────────────────────────────────────────────
+
+ 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)
+
+ toolbar_view = Adw.ToolbarView()
+ self._page.set_child(toolbar_view)
+
+ header = Adw.HeaderBar()
+ 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)
+ scrolled.set_child(content)
+
+ # ── Info section (poster + details) ───────────────────
+
+ info_box = Gtk.Box(spacing=24)
+ content.append(info_box)
+
+ # Poster
+ poster_frame = Gtk.Frame()
+ poster_frame.add_css_class('card')
+ poster_frame.set_overflow(Gtk.Overflow.HIDDEN)
+ self._poster = Gtk.Picture()
+ self._poster.set_size_request(240, 360)
+ self._poster.set_content_fit(Gtk.ContentFit.COVER)
+ poster_frame.set_child(self._poster)
+ info_box.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)
+
+ # Details column
+ details = Gtk.Box(
+ orientation=Gtk.Orientation.VERTICAL,
+ spacing=12,
+ hexpand=True,
+ valign=Gtk.Align.START,
+ )
+ info_box.append(details)
+
+ # Title
+ title_label = Gtk.Label(label=title, xalign=0, wrap=True)
+ title_label.add_css_class('title-1')
+ details.append(title_label)
+
+ # Meta (rating, year)
+ meta = Gtk.Box(spacing=12)
+ details.append(meta)
+
+ 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)
+
+ if year_str:
+ year_lbl = Gtk.Label(label=year_str)
+ year_lbl.add_css_class('dim-label')
+ meta.append(year_lbl)
+
+ # Overview
+ overview = self.data.get('overview', '')
+ if overview:
+ desc = Gtk.Label(
+ label=overview, xalign=0, wrap=True, max_width_chars=80,
+ )
+ desc.set_valign(Gtk.Align.START)
+ details.append(desc)
+
+ # ── 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,
+ )
+ torrents_title.add_css_class('title-2')
+ torrents_header.append(torrents_title)
+
+ refresh_btn = Gtk.Button(
+ icon_name='view-refresh-symbolic',
+ tooltip_text='Обновить',
+ )
+ refresh_btn.connect('clicked', lambda *_: self._search_torrents())
+ torrents_header.append(refresh_btn)
+
+ 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(24)
+ self._torrents_spinner.set_margin_bottom(24)
+ content.append(self._torrents_spinner)
+
+ self._status_label = Gtk.Label(
+ halign=Gtk.Align.CENTER, visible=False,
+ )
+ self._status_label.add_css_class('dim-label')
+ content.append(self._status_label)
+
+ 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)
+
+ # ── Poster callback ───────────────────────────────────────
+
+ 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/home.py b/src/kadr/views/home.py
new file mode 100644
index 0000000..8ecf19a
--- /dev/null
+++ b/src/kadr/views/home.py
@@ -0,0 +1,466 @@
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Adw', '1')
+from gi.repository import Adw, Gtk, Gio, GObject, GLib
+
+from kadr.utils import run_async
+from kadr.widgets.media_card import MediaCard, _has_ru_title
+
+
+class HomeView:
+ """Home view with popular movies/TV grids and search."""
+
+ def __init__(self, window):
+ self.window = window
+ self._movies_page = 1
+ self._movies_total = 999
+ self._tv_page = 1
+ self._tv_total = 999
+ self._loading_movies = False
+ self._loading_tv = False
+ self._search_mode = False
+ self._search_query = ''
+ self._search_movies_page = 1
+ self._search_movies_total = 0
+ self._search_tv_page = 1
+ self._search_tv_total = 0
+ self._seen_movie_ids = set()
+ self._seen_tv_ids = set()
+ self._build()
+ self._load_popular_movies()
+ self._load_popular_tv()
+
+ @property
+ def page(self):
+ return self._page
+
+ def toggle_search(self):
+ active = self._search_btn.get_active()
+ self._search_btn.set_active(not active)
+
+ # ── Build UI ──────────────────────────────────────────────
+
+ def _build(self):
+ self._page = Adw.NavigationPage(title='Kadr')
+
+ toolbar_view = Adw.ToolbarView()
+ self._page.set_child(toolbar_view)
+
+ # Header bar
+ header = Adw.HeaderBar()
+
+ self._search_btn = Gtk.ToggleButton(icon_name='system-search-symbolic')
+ self._search_btn.set_tooltip_text('Поиск (Ctrl+F)')
+ header.pack_start(self._search_btn)
+
+ self._stack = Adw.ViewStack()
+ switcher = Adw.ViewSwitcherTitle(stack=self._stack)
+ header.set_title_widget(switcher)
+
+ menu = Gio.Menu()
+ menu.append('Настройки', 'app.preferences')
+ menu.append('О приложении', 'app.about')
+ menu_btn = Gtk.MenuButton(
+ icon_name='open-menu-symbolic',
+ menu_model=menu,
+ primary=True,
+ )
+ menu_btn.set_tooltip_text('Меню')
+ header.pack_end(menu_btn)
+
+ toolbar_view.add_top_bar(header)
+
+ # Search bar
+ self._search_bar = Gtk.SearchBar()
+ self._search_entry = Gtk.SearchEntry()
+ self._search_entry.set_hexpand(True)
+ self._search_entry.set_placeholder_text('Поиск фильмов и сериалов...')
+ self._search_bar.set_child(self._search_entry)
+ self._search_bar.connect_entry(self._search_entry)
+ self._search_btn.bind_property(
+ 'active', self._search_bar, 'search-mode-enabled',
+ GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE,
+ )
+ self._search_entry.connect('activate', self._on_search)
+ self._search_entry.connect('stop-search', self._on_search_stopped)
+ toolbar_view.add_top_bar(self._search_bar)
+
+ # Movies tab
+ movies_scroll = Gtk.ScrolledWindow(vexpand=True)
+ self._movies_scroll = movies_scroll
+ movies_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self._movies_flow = Gtk.FlowBox(
+ homogeneous=False,
+ min_children_per_line=2,
+ max_children_per_line=20,
+ row_spacing=12,
+ column_spacing=12,
+ selection_mode=Gtk.SelectionMode.NONE,
+ )
+ self._movies_flow.set_valign(Gtk.Align.START)
+ self._movies_flow.set_halign(Gtk.Align.CENTER)
+ self._movies_flow.set_margin_start(16)
+ self._movies_flow.set_margin_end(16)
+ self._movies_flow.set_margin_top(16)
+ self._movies_flow.set_margin_bottom(8)
+ movies_content.append(self._movies_flow)
+
+ self._movies_spinner = Gtk.Spinner(
+ spinning=True,
+ halign=Gtk.Align.CENTER,
+ )
+ self._movies_spinner.set_size_request(32, 32)
+ self._movies_spinner.set_margin_top(16)
+ self._movies_spinner.set_margin_bottom(24)
+ movies_content.append(self._movies_spinner)
+
+ movies_scroll.set_child(movies_content)
+ movies_scroll.get_vadjustment().connect(
+ 'value-changed', self._on_movies_scroll,
+ )
+ page = self._stack.add_titled(movies_scroll, 'movies', 'Фильмы')
+ page.set_icon_name('video-display-symbolic')
+
+ # TV tab
+ tv_scroll = Gtk.ScrolledWindow(vexpand=True)
+ self._tv_scroll = tv_scroll
+ tv_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self._tv_flow = Gtk.FlowBox(
+ homogeneous=False,
+ min_children_per_line=2,
+ max_children_per_line=20,
+ row_spacing=12,
+ column_spacing=12,
+ selection_mode=Gtk.SelectionMode.NONE,
+ )
+ self._tv_flow.set_valign(Gtk.Align.START)
+ self._tv_flow.set_halign(Gtk.Align.CENTER)
+ self._tv_flow.set_margin_start(16)
+ self._tv_flow.set_margin_end(16)
+ self._tv_flow.set_margin_top(16)
+ self._tv_flow.set_margin_bottom(8)
+ tv_content.append(self._tv_flow)
+
+ self._tv_spinner = Gtk.Spinner(
+ spinning=True,
+ halign=Gtk.Align.CENTER,
+ )
+ self._tv_spinner.set_size_request(32, 32)
+ self._tv_spinner.set_margin_top(16)
+ self._tv_spinner.set_margin_bottom(24)
+ tv_content.append(self._tv_spinner)
+
+ tv_scroll.set_child(tv_content)
+ tv_scroll.get_vadjustment().connect(
+ 'value-changed', self._on_tv_scroll,
+ )
+ page = self._stack.add_titled(tv_scroll, 'tv', 'Сериалы')
+ page.set_icon_name('tv-symbolic')
+
+ toolbar_view.set_content(self._stack)
+
+ # ── Data loading ──────────────────────────────────────────
+
+ @staticmethod
+ def _viewport_not_filled(scroll):
+ """Return True if content doesn't fill the visible area."""
+ adj = scroll.get_vadjustment()
+ return adj.get_upper() <= adj.get_page_size() + 100
+
+ def _load_popular_movies(self):
+ if self._loading_movies or self._movies_page > self._movies_total:
+ return
+ self._loading_movies = True
+ self._movies_spinner.set_spinning(True)
+ self._movies_spinner.set_visible(True)
+ run_async(
+ self.window.tmdb.popular_movies,
+ self._on_movies_loaded,
+ self._movies_page,
+ )
+
+ def _on_movies_loaded(self, data, error):
+ self._loading_movies = False
+ if error:
+ self._movies_spinner.set_spinning(False)
+ self._movies_spinner.set_visible(False)
+ self.window.show_toast(f'Ошибка: {error}')
+ return
+
+ self._movies_total = data.get('total_pages', 1)
+ results = data.get('results', [])
+
+ for movie in results:
+ mid = movie.get('id')
+ if mid in self._seen_movie_ids:
+ continue
+ if not _has_ru_title(movie, 'movie'):
+ continue
+ self._seen_movie_ids.add(mid)
+ card = MediaCard(
+ data=movie,
+ media_type='movie',
+ image_url=self.window.tmdb.image_url(
+ movie.get('poster_path'), 'w342',
+ ),
+ on_click=self._on_card_clicked,
+ )
+ self._movies_flow.append(card)
+ self._movies_page += 1
+
+ has_more = self._movies_page <= self._movies_total
+ self._movies_spinner.set_spinning(has_more)
+ self._movies_spinner.set_visible(has_more)
+
+ # Auto-load next page if viewport isn't filled yet
+ if has_more:
+ GLib.idle_add(self._maybe_load_more_movies)
+
+ def _maybe_load_more_movies(self):
+ if self._viewport_not_filled(self._movies_scroll):
+ if self._search_mode:
+ self._load_search_movies_next()
+ else:
+ self._load_popular_movies()
+ return False
+
+ def _load_popular_tv(self):
+ if self._loading_tv or self._tv_page > self._tv_total:
+ return
+ self._loading_tv = True
+ self._tv_spinner.set_spinning(True)
+ self._tv_spinner.set_visible(True)
+ run_async(
+ self.window.tmdb.popular_tv,
+ self._on_tv_loaded,
+ self._tv_page,
+ )
+
+ def _on_tv_loaded(self, data, error):
+ self._loading_tv = False
+ if error:
+ self._tv_spinner.set_spinning(False)
+ self._tv_spinner.set_visible(False)
+ self.window.show_toast(f'Ошибка: {error}')
+ return
+
+ self._tv_total = data.get('total_pages', 1)
+ results = data.get('results', [])
+
+ for show in results:
+ sid = show.get('id')
+ if sid in self._seen_tv_ids:
+ continue
+ if not _has_ru_title(show, 'tv'):
+ continue
+ self._seen_tv_ids.add(sid)
+ card = MediaCard(
+ data=show,
+ media_type='tv',
+ image_url=self.window.tmdb.image_url(
+ show.get('poster_path'), 'w342',
+ ),
+ on_click=self._on_card_clicked,
+ )
+ self._tv_flow.append(card)
+ self._tv_page += 1
+
+ has_more = self._tv_page <= self._tv_total
+ self._tv_spinner.set_spinning(has_more)
+ self._tv_spinner.set_visible(has_more)
+
+ # Auto-load next page if viewport isn't filled yet
+ if has_more:
+ GLib.idle_add(self._maybe_load_more_tv)
+
+ def _maybe_load_more_tv(self):
+ if self._viewport_not_filled(self._tv_scroll):
+ if self._search_mode:
+ self._load_search_tv_next()
+ else:
+ self._load_popular_tv()
+ return False
+
+ # ── Infinite scroll ───────────────────────────────────────
+
+ def _near_bottom(self, adj):
+ return adj.get_value() + adj.get_page_size() >= adj.get_upper() - 800
+
+ def _on_movies_scroll(self, adj):
+ if self._near_bottom(adj):
+ if self._search_mode:
+ self._load_search_movies_next()
+ elif not self._loading_movies:
+ self._load_popular_movies()
+
+ def _on_tv_scroll(self, adj):
+ if self._near_bottom(adj):
+ if self._search_mode:
+ self._load_search_tv_next()
+ elif not self._loading_tv:
+ self._load_popular_tv()
+
+ # ── Search ────────────────────────────────────────────────
+
+ def _on_search(self, entry):
+ query = entry.get_text().strip()
+ if not query:
+ return
+ self._search_mode = True
+ self._search_query = query
+ self._search_movies_page = 1
+ self._search_movies_total = 0
+ self._search_tv_page = 1
+ self._search_tv_total = 0
+ self._seen_movie_ids.clear()
+ self._seen_tv_ids.clear()
+ self._clear_flow(self._movies_flow)
+ self._clear_flow(self._tv_flow)
+ self._movies_spinner.set_spinning(True)
+ self._movies_spinner.set_visible(True)
+ self._tv_spinner.set_spinning(True)
+ self._tv_spinner.set_visible(True)
+ self._loading_movies = True
+ self._loading_tv = True
+
+ run_async(
+ self.window.tmdb.search_movies,
+ self._on_search_movies_done,
+ query,
+ )
+ run_async(
+ self.window.tmdb.search_tv,
+ self._on_search_tv_done,
+ query,
+ )
+
+ def _on_search_movies_done(self, data, error):
+ self._loading_movies = False
+ if error:
+ self._movies_spinner.set_spinning(False)
+ self._movies_spinner.set_visible(False)
+ self.window.show_toast(f'Ошибка поиска: {error}')
+ return
+
+ self._search_movies_total = data.get('total_pages', 1)
+ for movie in data.get('results', []):
+ mid = movie.get('id')
+ if mid in self._seen_movie_ids:
+ continue
+ if not _has_ru_title(movie, 'movie'):
+ continue
+ self._seen_movie_ids.add(mid)
+ card = MediaCard(
+ data=movie,
+ media_type='movie',
+ image_url=self.window.tmdb.image_url(
+ movie.get('poster_path'), 'w342',
+ ),
+ on_click=self._on_card_clicked,
+ )
+ self._movies_flow.append(card)
+ self._search_movies_page += 1
+
+ has_more = self._search_movies_page <= self._search_movies_total
+ self._movies_spinner.set_spinning(has_more)
+ self._movies_spinner.set_visible(has_more)
+
+ if has_more:
+ GLib.idle_add(self._maybe_load_more_movies)
+
+ def _on_search_tv_done(self, data, error):
+ self._loading_tv = False
+ if error:
+ self._tv_spinner.set_spinning(False)
+ self._tv_spinner.set_visible(False)
+ self.window.show_toast(f'Ошибка поиска: {error}')
+ return
+
+ self._search_tv_total = data.get('total_pages', 1)
+ for show in data.get('results', []):
+ sid = show.get('id')
+ if sid in self._seen_tv_ids:
+ continue
+ if not _has_ru_title(show, 'tv'):
+ continue
+ self._seen_tv_ids.add(sid)
+ card = MediaCard(
+ data=show,
+ media_type='tv',
+ image_url=self.window.tmdb.image_url(
+ show.get('poster_path'), 'w342',
+ ),
+ on_click=self._on_card_clicked,
+ )
+ self._tv_flow.append(card)
+ self._search_tv_page += 1
+
+ has_more = self._search_tv_page <= self._search_tv_total
+ self._tv_spinner.set_spinning(has_more)
+ self._tv_spinner.set_visible(has_more)
+
+ if has_more:
+ GLib.idle_add(self._maybe_load_more_tv)
+
+ def _load_search_movies_next(self):
+ if self._loading_movies:
+ return
+ if self._search_movies_page > self._search_movies_total:
+ return
+ self._loading_movies = True
+ self._movies_spinner.set_spinning(True)
+ self._movies_spinner.set_visible(True)
+ run_async(
+ self.window.tmdb.search_movies,
+ self._on_search_movies_done,
+ self._search_query,
+ self._search_movies_page,
+ )
+
+ def _load_search_tv_next(self):
+ if self._loading_tv:
+ return
+ if self._search_tv_page > self._search_tv_total:
+ return
+ self._loading_tv = True
+ self._tv_spinner.set_spinning(True)
+ self._tv_spinner.set_visible(True)
+ run_async(
+ self.window.tmdb.search_tv,
+ self._on_search_tv_done,
+ self._search_query,
+ self._search_tv_page,
+ )
+
+ def _on_search_stopped(self, entry):
+ self._search_btn.set_active(False)
+ if self._search_mode:
+ self._search_mode = False
+ self._refresh_popular()
+
+ def _refresh_popular(self):
+ self._clear_flow(self._movies_flow)
+ self._clear_flow(self._tv_flow)
+ self._seen_movie_ids.clear()
+ self._seen_tv_ids.clear()
+ self._movies_page = 1
+ self._movies_total = 999
+ self._tv_page = 1
+ self._tv_total = 999
+ self._load_popular_movies()
+ self._load_popular_tv()
+
+ # ── Events ────────────────────────────────────────────────
+
+ def _on_card_clicked(self, media_type, data):
+ self.window.show_detail(media_type, data)
+
+ # ── Helpers ───────────────────────────────────────────────
+
+ @staticmethod
+ def _clear_flow(flow):
+ while True:
+ child = flow.get_child_at_index(0)
+ if child is None:
+ break
+ flow.remove(child)
diff --git a/src/kadr/views/settings_dialog.py b/src/kadr/views/settings_dialog.py
new file mode 100644
index 0000000..75e425b
--- /dev/null
+++ b/src/kadr/views/settings_dialog.py
@@ -0,0 +1,218 @@
+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
+from kadr.services.tmdb import TMDB_PROXIES
+from kadr.services.jackett import JACKETT_SERVERS
+from kadr.services.downloads import DOWNLOAD_CLIENTS
+
+
+class SettingsDialog(Adw.PreferencesWindow):
+ """Application settings dialog."""
+
+ def __init__(self, window):
+ super().__init__(
+ title='Настройки',
+ transient_for=window,
+ modal=True,
+ )
+ self.window = window
+ self._server_statuses = {}
+ self._proxy_statuses = {}
+ self._build_servers_page()
+ self._build_clients_page()
+
+ # ── Servers page ──────────────────────────────────────────
+
+ def _build_servers_page(self):
+ page = Adw.PreferencesPage(
+ title='Серверы',
+ icon_name='network-server-symbolic',
+ )
+ self.add(page)
+
+ # ── Jackett server combo ──
+ server_group = Adw.PreferencesGroup(
+ title='Торрент сервер',
+ description='Публичные Jackett серверы для поиска',
+ )
+ page.add(server_group)
+
+ self._server_ids = ['auto'] + [s['id'] for s in JACKETT_SERVERS]
+ server_names = ['Автоматически'] + [s['name'] for s in JACKETT_SERVERS]
+ server_model = Gtk.StringList.new(server_names)
+
+ current_server = self.window.settings_manager.get('selected_server')
+ server_idx = (
+ self._server_ids.index(current_server)
+ if current_server in self._server_ids else 0
+ )
+
+ server_combo = Adw.ComboRow(
+ title='Сервер',
+ model=server_model,
+ )
+ server_combo.set_selected(server_idx)
+ server_combo.connect('notify::selected', self._on_server_selected)
+ server_group.add(server_combo)
+
+ # Compact health status row
+ self._server_status_row = Adw.ActionRow(title='Доступность')
+ self._server_status_box = Gtk.Box(
+ spacing=8, valign=Gtk.Align.CENTER,
+ )
+ self._server_status_row.add_suffix(self._server_status_box)
+ server_group.add(self._server_status_row)
+
+ for server in JACKETT_SERVERS:
+ item = Gtk.Box(spacing=4, valign=Gtk.Align.CENTER)
+ label = Gtk.Label(label=server['name'])
+ label.add_css_class('caption')
+ item.append(label)
+ spinner = Gtk.Spinner(spinning=True)
+ spinner.set_size_request(12, 12)
+ item.append(spinner)
+ icon = Gtk.Image(visible=False)
+ item.append(icon)
+ self._server_status_box.append(item)
+ self._server_statuses[server['id']] = (spinner, icon)
+
+ run_async(
+ self.window.jackett.check_health,
+ lambda ok, err, sid=server['id']: self._set_inline_status(
+ self._server_statuses.get(sid), ok and not err,
+ ),
+ server['id'],
+ )
+
+ # ── TMDB proxy combo ──
+ proxy_group = Adw.PreferencesGroup(
+ title='TMDB прокси',
+ description='Прокси для доступа к базе фильмов',
+ )
+ page.add(proxy_group)
+
+ self._proxy_ids = ['auto'] + list(TMDB_PROXIES.keys())
+ proxy_names = ['Автоматически'] + [
+ p['name'] for p in TMDB_PROXIES.values()
+ ]
+ proxy_model = Gtk.StringList.new(proxy_names)
+
+ current_proxy = self.window.settings_manager.get('tmdb_proxy')
+ proxy_idx = (
+ self._proxy_ids.index(current_proxy)
+ if current_proxy in self._proxy_ids else 0
+ )
+
+ proxy_combo = Adw.ComboRow(
+ title='Прокси',
+ model=proxy_model,
+ )
+ proxy_combo.set_selected(proxy_idx)
+ proxy_combo.connect('notify::selected', self._on_proxy_selected)
+ proxy_group.add(proxy_combo)
+
+ # Compact health status row
+ self._proxy_status_row = Adw.ActionRow(title='Доступность')
+ self._proxy_status_box = Gtk.Box(
+ spacing=8, valign=Gtk.Align.CENTER,
+ )
+ self._proxy_status_row.add_suffix(self._proxy_status_box)
+ proxy_group.add(self._proxy_status_row)
+
+ for proxy_id, proxy in TMDB_PROXIES.items():
+ item = Gtk.Box(spacing=4, valign=Gtk.Align.CENTER)
+ label = Gtk.Label(label=proxy['name'])
+ label.add_css_class('caption')
+ item.append(label)
+ spinner = Gtk.Spinner(spinning=True)
+ spinner.set_size_request(12, 12)
+ item.append(spinner)
+ icon = Gtk.Image(visible=False)
+ item.append(icon)
+ self._proxy_status_box.append(item)
+ self._proxy_statuses[proxy_id] = (spinner, icon)
+
+ run_async(
+ self.window.tmdb.check_health,
+ lambda ok, err, pid=proxy_id: self._set_inline_status(
+ self._proxy_statuses.get(pid), ok and not err,
+ ),
+ proxy_id,
+ )
+
+ # ── Download clients page ─────────────────────────────────
+
+ def _build_clients_page(self):
+ page = Adw.PreferencesPage(
+ title='Загрузки',
+ icon_name='folder-download-symbolic',
+ )
+ self.add(page)
+
+ group = Adw.PreferencesGroup(
+ title='Торрент-клиент',
+ description='Выберите клиент для загрузки торрентов',
+ )
+ page.add(group)
+
+ available = self.window.downloads.available_clients()
+ available_ids = {c['id'] for c in available}
+
+ self._client_ids = ['auto'] + [c['id'] for c in DOWNLOAD_CLIENTS]
+ client_names = ['Автоматически'] + [
+ c['name'] + ('' if c['id'] in available_ids else ' (не найден)')
+ for c in DOWNLOAD_CLIENTS
+ ]
+ client_model = Gtk.StringList.new(client_names)
+
+ current = self.window.settings_manager.get('download_client', 'auto')
+ client_idx = (
+ self._client_ids.index(current)
+ if current in self._client_ids else 0
+ )
+
+ client_combo = Adw.ComboRow(
+ title='Клиент',
+ subtitle='Приложение для загрузки торрентов',
+ model=client_model,
+ )
+ client_combo.set_selected(client_idx)
+ client_combo.connect('notify::selected', self._on_client_selected)
+ group.add(client_combo)
+
+ # ── Status helpers ────────────────────────────────────────
+
+ @staticmethod
+ def _set_inline_status(pair, is_online):
+ if not pair:
+ return
+ spinner, icon = pair
+ spinner.set_spinning(False)
+ spinner.set_visible(False)
+ icon.set_visible(True)
+ if is_online:
+ icon.set_from_icon_name('emblem-ok-symbolic')
+ icon.add_css_class('success-label')
+ else:
+ icon.set_from_icon_name('process-stop-symbolic')
+ icon.add_css_class('error-label')
+
+ # ── Callbacks ─────────────────────────────────────────────
+
+ def _on_server_selected(self, combo, _pspec):
+ idx = combo.get_selected()
+ if 0 <= idx < len(self._server_ids):
+ self.window.settings_manager.set('selected_server', self._server_ids[idx])
+
+ def _on_proxy_selected(self, combo, _pspec):
+ idx = combo.get_selected()
+ if 0 <= idx < len(self._proxy_ids):
+ self.window.settings_manager.set('tmdb_proxy', self._proxy_ids[idx])
+
+ def _on_client_selected(self, combo, _pspec):
+ idx = combo.get_selected()
+ if 0 <= idx < len(self._client_ids):
+ self.window.settings_manager.set('download_client', self._client_ids[idx])
diff --git a/src/kadr/widgets/__init__.py b/src/kadr/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/kadr/widgets/media_card.py b/src/kadr/widgets/media_card.py
new file mode 100644
index 0000000..efbf269
--- /dev/null
+++ b/src/kadr/widgets/media_card.py
@@ -0,0 +1,110 @@
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk, Pango
+
+from kadr.utils import load_image_async
+
+POSTER_WIDTH = 155
+POSTER_HEIGHT = 232
+COVER_RATIO = 1.5
+
+
+def _has_ru_title(data, media_type):
+ """Return True if the entry has a Russian localised title."""
+ lang = data.get('original_language') or ''
+ if lang == 'ru':
+ return True
+ if media_type == 'movie':
+ return (data.get('title') or '') != (data.get('original_title') or '')
+ return (data.get('name') or '') != (data.get('original_name') or '')
+
+
+def _resolve_title(data, media_type):
+ """Return (display_title, year_str)."""
+ if media_type == 'movie':
+ local = data.get('title') or ''
+ orig = data.get('original_title') or ''
+ date = data.get('release_date') or ''
+ else:
+ local = data.get('name') or ''
+ orig = data.get('original_name') or ''
+ date = data.get('first_air_date') or ''
+
+ title = local or orig
+ year = date[:4] if len(date) >= 4 else ''
+ return title, year
+
+
+class MediaCard(Gtk.Overlay):
+ """Card widget displaying a movie/TV show poster with title and rating."""
+
+ def __init__(self, data, media_type, image_url, on_click):
+ super().__init__()
+ self.data = data
+ self.media_type = media_type
+ self._on_click = on_click
+
+ self.set_size_request(POSTER_WIDTH, POSTER_HEIGHT)
+ self.set_overflow(Gtk.Overflow.HIDDEN)
+ self.add_css_class('media-card')
+
+ # Cover image fills the entire tile
+ self._picture = Gtk.Picture()
+ self._picture.set_content_fit(Gtk.ContentFit.COVER)
+ self._picture.set_size_request(POSTER_WIDTH, POSTER_HEIGHT)
+ self._picture.add_css_class('poster-image')
+ self.set_child(self._picture)
+
+ title, year_str = _resolve_title(data, media_type)
+
+ # Bottom info overlay with gradient
+ info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ info.add_css_class('card-info')
+ info.set_valign(Gtk.Align.END)
+ info.set_halign(Gtk.Align.FILL)
+
+ label = Gtk.Label(label=title)
+ label.set_ellipsize(Pango.EllipsizeMode.END)
+ label.set_max_width_chars(18)
+ label.set_lines(2)
+ label.set_wrap(True)
+ label.set_halign(Gtk.Align.START)
+ label.add_css_class('card-title')
+ info.append(label)
+
+ # Year
+ if year_str:
+ year_label = Gtk.Label(label=year_str)
+ year_label.set_halign(Gtk.Align.START)
+ year_label.add_css_class('card-year')
+ info.append(year_label)
+
+ self.add_overlay(info)
+
+ # Rating badge (top-left)
+ rating = data.get('vote_average', 0)
+ if rating > 0:
+ badge = Gtk.Label(label=f'★ {rating:.1f}')
+ badge.add_css_class('rating-badge')
+ badge.set_halign(Gtk.Align.START)
+ badge.set_valign(Gtk.Align.START)
+ badge.set_margin_start(6)
+ badge.set_margin_top(6)
+ self.add_overlay(badge)
+
+ # Click handler
+ click = Gtk.GestureClick()
+ click.connect('released', self._clicked)
+ self.add_controller(click)
+
+ # Load poster image
+ if image_url:
+ load_image_async(image_url, POSTER_WIDTH, POSTER_HEIGHT, self._on_image)
+
+ def _on_image(self, texture):
+ if texture:
+ self._picture.set_paintable(texture)
+
+ def _clicked(self, gesture, n_press, x, y):
+ if n_press == 1:
+ self._on_click(self.media_type, self.data)
diff --git a/src/kadr/widgets/torrent_row.py b/src/kadr/widgets/torrent_row.py
new file mode 100644
index 0000000..4bb0405
--- /dev/null
+++ b/src/kadr/widgets/torrent_row.py
@@ -0,0 +1,72 @@
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk
+
+
+class TorrentRow(Gtk.ListBoxRow):
+ """Row widget displaying a torrent search result."""
+
+ def __init__(self, torrent, on_download, on_copy):
+ super().__init__()
+ self.torrent = torrent
+
+ box = Gtk.Box(spacing=12)
+ box.set_margin_top(12)
+ box.set_margin_bottom(12)
+ box.set_margin_start(12)
+ box.set_margin_end(12)
+ self.set_child(box)
+
+ # Info column
+ info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, hexpand=True)
+ box.append(info)
+
+ # Title
+ title = Gtk.Label(
+ label=torrent['title'],
+ xalign=0,
+ wrap=True,
+ max_width_chars=80,
+ )
+ title.add_css_class('heading')
+ info.append(title)
+
+ # Meta row
+ meta = Gtk.Box(spacing=16)
+ info.append(meta)
+
+ size_lbl = Gtk.Label(label=torrent['size_str'])
+ size_lbl.add_css_class('dim-label')
+ meta.append(size_lbl)
+
+ seeders_lbl = Gtk.Label(label=f'▲ {torrent["seeders"]}')
+ seeders_lbl.add_css_class('success-label')
+ meta.append(seeders_lbl)
+
+ leechers_lbl = Gtk.Label(label=f'▼ {torrent["leechers"]}')
+ leechers_lbl.add_css_class('dim-label')
+ meta.append(leechers_lbl)
+
+ if torrent.get('tracker'):
+ tracker_lbl = Gtk.Label(label=torrent['tracker'])
+ tracker_lbl.add_css_class('dim-label')
+ meta.append(tracker_lbl)
+
+ # Actions column
+ actions = Gtk.Box(spacing=8, valign=Gtk.Align.CENTER)
+ box.append(actions)
+
+ dl_btn = Gtk.Button(
+ icon_name='folder-download-symbolic',
+ tooltip_text='Скачать',
+ )
+ dl_btn.add_css_class('suggested-action')
+ dl_btn.connect('clicked', lambda *_: on_download(torrent))
+ actions.append(dl_btn)
+
+ copy_btn = Gtk.Button(
+ icon_name='edit-copy-symbolic',
+ tooltip_text='Копировать магнет',
+ )
+ copy_btn.connect('clicked', lambda *_: on_copy(torrent))
+ actions.append(copy_btn)
diff --git a/src/kadr/window.py b/src/kadr/window.py
new file mode 100644
index 0000000..e0d82b4
--- /dev/null
+++ b/src/kadr/window.py
@@ -0,0 +1,49 @@
+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
+
+
+class KadrWindow(Adw.ApplicationWindow):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.set_default_size(870, 800)
+ self.set_title("Kadr")
+
+ # Backend services
+ self.settings_manager = SettingsManager()
+ self.tmdb = TMDBService(self.settings_manager)
+ self.jackett = JackettService(self.settings_manager)
+ self.downloads = DownloadManager(self.settings_manager)
+
+ # Toast overlay (wraps everything for notifications)
+ self.toast_overlay = Adw.ToastOverlay()
+ self.set_content(self.toast_overlay)
+
+ # Navigation
+ self.nav_view = Adw.NavigationView()
+ self.toast_overlay.set_child(self.nav_view)
+
+ # Home page
+ from kadr.views.home import HomeView
+ self.home = HomeView(self)
+ self.nav_view.push(self.home.page)
+
+ def show_detail(self, media_type, data):
+ from kadr.views.detail import DetailView
+ detail = DetailView(self, media_type, data)
+ self.nav_view.push(detail.page)
+
+ def show_toast(self, message):
+ from gi.repository import GLib
+ safe = GLib.markup_escape_text(str(message))
+ self.toast_overlay.add_toast(Adw.Toast(title=safe))
+
+ def toggle_search(self):
+ if hasattr(self, 'home'):
+ self.home.toggle_search()