Initial commit: Kadr v2.0.0
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 886 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 886 KiB |
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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='© 2024–2026 Cheviiot',
|
||||
)
|
||||
about.present()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 'Не удалось подключиться к серверу')
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||