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

+ +

+ 🎬 Элегантное приложение для поиска торрентов фильмов и сериалов +

+ +

+ Release + License +

+ +

+ Установка • + Особенности • + Запуск • + Лицензия +

+ +--- + +## ✨ Особенности + +- 🔍 **Поиск фильмов и сериалов** — интеграция с 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()