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()
|
||||||