Initial commit: Kadr v2.0.0

This commit is contained in:
2026-03-20 10:57:27 +10:00
commit 3508a7c5dd
35 changed files with 2226 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+58
View File
@@ -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
+21
View File
@@ -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.
+127
View File
@@ -0,0 +1,127 @@
<h1 align="center">Kadr</h1>
<p align="center">
<strong>🎬 Элегантное приложение для поиска торрентов фильмов и сериалов</strong>
</p>
<p align="center">
<a href="https://github.com/Cheviiot/Kadr/releases"><img src="https://img.shields.io/github/v/release/Cheviiot/Kadr?style=flat-square&color=blue" alt="Release"></a>
<a href="https://github.com/Cheviiot/Kadr/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Cheviiot/Kadr?style=flat-square" alt="License"></a>
</p>
<p align="center">
<a href="#-установка">Установка</a> •
<a href="#-особенности">Особенности</a> •
<a href="#-запуск">Запуск</a> •
<a href="#-лицензия">Лицензия</a>
</p>
---
## ✨ Особенности
- 🔍 **Поиск фильмов и сериалов** — интеграция с 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)
---
<p align="center">
Made with ❤️ by <a href="https://github.com/Cheviiot">Cheviiot</a>
</p>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

+11
View File
@@ -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
Executable
+14
View File
@@ -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()
+37
View File
@@ -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"]
+23
View File
@@ -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
+54
View File
@@ -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()
+91
View File
@@ -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', ['<Control>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', ['<Control>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', ['<Control>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='© 20242026 Cheviiot',
)
about.present()
+90
View File
@@ -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;
}
View File
+125
View File
@@ -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()
+121
View File
@@ -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 'Не удалось подключиться к серверу')
+38
View File
@@ -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()
+110
View File
@@ -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
+121
View File
@@ -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)')
View File
+268
View File
@@ -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)
+466
View File
@@ -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)
+218
View File
@@ -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])
View File
+110
View File
@@ -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)
+72
View File
@@ -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)
+49
View File
@@ -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()