Initial commit: Kadr v2.0.0

This commit is contained in:
2026-03-20 10:57:27 +10:00
commit 3508a7c5dd
35 changed files with 2226 additions and 0 deletions
View File
+268
View File
@@ -0,0 +1,268 @@
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gtk
from kadr.utils import run_async, load_image_async, copy_to_clipboard
from kadr.widgets.torrent_row import TorrentRow
class DetailView:
"""Detail view showing movie/TV info and torrent search results."""
def __init__(self, window, media_type, data):
self.window = window
self.media_type = media_type
self.data = data
self._build()
self._search_torrents()
@property
def page(self):
return self._page
# ── Build UI ──────────────────────────────────────────────
def _build(self):
from kadr.widgets.media_card import _resolve_title
title, year_str = _resolve_title(self.data, self.media_type)
self._page = Adw.NavigationPage(title=title)
toolbar_view = Adw.ToolbarView()
self._page.set_child(toolbar_view)
header = Adw.HeaderBar()
toolbar_view.add_top_bar(header)
scrolled = Gtk.ScrolledWindow(vexpand=True)
toolbar_view.set_content(scrolled)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
content.set_margin_start(24)
content.set_margin_end(24)
content.set_margin_top(24)
content.set_margin_bottom(24)
scrolled.set_child(content)
# ── Info section (poster + details) ───────────────────
info_box = Gtk.Box(spacing=24)
content.append(info_box)
# Poster
poster_frame = Gtk.Frame()
poster_frame.add_css_class('card')
poster_frame.set_overflow(Gtk.Overflow.HIDDEN)
self._poster = Gtk.Picture()
self._poster.set_size_request(240, 360)
self._poster.set_content_fit(Gtk.ContentFit.COVER)
poster_frame.set_child(self._poster)
info_box.append(poster_frame)
poster_path = self.data.get('poster_path', '')
if poster_path:
url = self.window.tmdb.image_url(poster_path, 'w500')
load_image_async(url, 240, 360, self._on_poster_loaded)
# Details column
details = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=12,
hexpand=True,
valign=Gtk.Align.START,
)
info_box.append(details)
# Title
title_label = Gtk.Label(label=title, xalign=0, wrap=True)
title_label.add_css_class('title-1')
details.append(title_label)
# Meta (rating, year)
meta = Gtk.Box(spacing=12)
details.append(meta)
rating = self.data.get('vote_average', 0)
if rating > 0:
rating_lbl = Gtk.Label(label=f'{rating:.1f}')
rating_lbl.add_css_class('rating-badge')
meta.append(rating_lbl)
if year_str:
year_lbl = Gtk.Label(label=year_str)
year_lbl.add_css_class('dim-label')
meta.append(year_lbl)
# Overview
overview = self.data.get('overview', '')
if overview:
desc = Gtk.Label(
label=overview, xalign=0, wrap=True, max_width_chars=80,
)
desc.set_valign(Gtk.Align.START)
details.append(desc)
# ── Torrents section ──────────────────────────────────
content.append(Gtk.Separator())
torrents_header = Gtk.Box(spacing=12)
content.append(torrents_header)
torrents_title = Gtk.Label(
label='Торренты', xalign=0, hexpand=True,
)
torrents_title.add_css_class('title-2')
torrents_header.append(torrents_title)
refresh_btn = Gtk.Button(
icon_name='view-refresh-symbolic',
tooltip_text='Обновить',
)
refresh_btn.connect('clicked', lambda *_: self._search_torrents())
torrents_header.append(refresh_btn)
self._torrents_spinner = Gtk.Spinner(
spinning=True,
halign=Gtk.Align.CENTER,
)
self._torrents_spinner.set_size_request(32, 32)
self._torrents_spinner.set_margin_top(24)
self._torrents_spinner.set_margin_bottom(24)
content.append(self._torrents_spinner)
self._status_label = Gtk.Label(
halign=Gtk.Align.CENTER, visible=False,
)
self._status_label.add_css_class('dim-label')
content.append(self._status_label)
self._torrent_list = Gtk.ListBox(
selection_mode=Gtk.SelectionMode.NONE,
visible=False,
)
self._torrent_list.add_css_class('boxed-list')
content.append(self._torrent_list)
# ── Poster callback ───────────────────────────────────────
def _on_poster_loaded(self, texture):
if texture:
self._poster.set_paintable(texture)
# ── Torrent search ────────────────────────────────────────
def _search_torrents(self):
self._torrents_spinner.set_visible(True)
self._torrents_spinner.set_spinning(True)
self._status_label.set_visible(False)
self._torrent_list.set_visible(False)
self._clear_list()
queries = self._build_queries()
run_async(
self._search_with_fallback,
self._on_torrents_loaded,
queries,
)
def _build_queries(self):
queries = []
if self.media_type == 'movie':
orig = self.data.get('original_title', '')
title = self.data.get('title', '')
date = self.data.get('release_date', '')
else:
orig = self.data.get('original_name', '')
title = self.data.get('name', '')
date = self.data.get('first_air_date', '')
year = date[:4] if date and len(date) >= 4 else ''
if orig:
queries.append(orig)
if title and title != orig:
queries.append(title)
if orig and year:
queries.append(f'{orig} {year}')
return queries or [title or orig]
def _search_with_fallback(self, queries):
server_id = self.window.jackett.resolve_server_id()
last_err = None
for query in queries:
try:
results = self.window.jackett.search(server_id, query)
if results:
return results
except Exception as e:
last_err = e
continue
if last_err:
raise last_err
return []
def _on_torrents_loaded(self, results, error):
self._torrents_spinner.set_visible(False)
self._torrents_spinner.set_spinning(False)
if error:
self._status_label.set_label(f'Ошибка: {error}')
self._status_label.set_visible(True)
return
if not results:
self._status_label.set_label('Торренты не найдены')
self._status_label.set_visible(True)
return
self._torrent_list.set_visible(True)
for torrent in results:
row = TorrentRow(
torrent=torrent,
on_download=self._on_download,
on_copy=self._on_copy_magnet,
)
self._torrent_list.append(row)
# ── Torrent actions ───────────────────────────────────────
def _on_download(self, torrent):
magnet = torrent.get('magnet_uri', '')
link = torrent.get('link', '')
name = torrent.get('title', '')
try:
if magnet:
client = self.window.downloads.send_magnet(magnet, name)
elif link:
client = self.window.downloads.send_torrent_url(link, name)
else:
self.window.show_toast('Нет ссылки для скачивания')
return
self.window.show_toast(f'Отправлено в {client}')
except Exception as e:
self.window.show_toast(str(e))
def _on_copy_magnet(self, torrent):
magnet = torrent.get('magnet_uri', '')
if not magnet:
self.window.show_toast('Магнет-ссылка недоступна')
return
try:
copy_to_clipboard(magnet)
self.window.show_toast('Магнет-ссылка скопирована')
except Exception as e:
self.window.show_toast(str(e))
# ── Helpers ───────────────────────────────────────────────
def _clear_list(self):
while True:
child = self._torrent_list.get_first_child()
if child is None:
break
self._torrent_list.remove(child)
+466
View File
@@ -0,0 +1,466 @@
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gtk, Gio, GObject, GLib
from kadr.utils import run_async
from kadr.widgets.media_card import MediaCard, _has_ru_title
class HomeView:
"""Home view with popular movies/TV grids and search."""
def __init__(self, window):
self.window = window
self._movies_page = 1
self._movies_total = 999
self._tv_page = 1
self._tv_total = 999
self._loading_movies = False
self._loading_tv = False
self._search_mode = False
self._search_query = ''
self._search_movies_page = 1
self._search_movies_total = 0
self._search_tv_page = 1
self._search_tv_total = 0
self._seen_movie_ids = set()
self._seen_tv_ids = set()
self._build()
self._load_popular_movies()
self._load_popular_tv()
@property
def page(self):
return self._page
def toggle_search(self):
active = self._search_btn.get_active()
self._search_btn.set_active(not active)
# ── Build UI ──────────────────────────────────────────────
def _build(self):
self._page = Adw.NavigationPage(title='Kadr')
toolbar_view = Adw.ToolbarView()
self._page.set_child(toolbar_view)
# Header bar
header = Adw.HeaderBar()
self._search_btn = Gtk.ToggleButton(icon_name='system-search-symbolic')
self._search_btn.set_tooltip_text('Поиск (Ctrl+F)')
header.pack_start(self._search_btn)
self._stack = Adw.ViewStack()
switcher = Adw.ViewSwitcherTitle(stack=self._stack)
header.set_title_widget(switcher)
menu = Gio.Menu()
menu.append('Настройки', 'app.preferences')
menu.append('О приложении', 'app.about')
menu_btn = Gtk.MenuButton(
icon_name='open-menu-symbolic',
menu_model=menu,
primary=True,
)
menu_btn.set_tooltip_text('Меню')
header.pack_end(menu_btn)
toolbar_view.add_top_bar(header)
# Search bar
self._search_bar = Gtk.SearchBar()
self._search_entry = Gtk.SearchEntry()
self._search_entry.set_hexpand(True)
self._search_entry.set_placeholder_text('Поиск фильмов и сериалов...')
self._search_bar.set_child(self._search_entry)
self._search_bar.connect_entry(self._search_entry)
self._search_btn.bind_property(
'active', self._search_bar, 'search-mode-enabled',
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE,
)
self._search_entry.connect('activate', self._on_search)
self._search_entry.connect('stop-search', self._on_search_stopped)
toolbar_view.add_top_bar(self._search_bar)
# Movies tab
movies_scroll = Gtk.ScrolledWindow(vexpand=True)
self._movies_scroll = movies_scroll
movies_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._movies_flow = Gtk.FlowBox(
homogeneous=False,
min_children_per_line=2,
max_children_per_line=20,
row_spacing=12,
column_spacing=12,
selection_mode=Gtk.SelectionMode.NONE,
)
self._movies_flow.set_valign(Gtk.Align.START)
self._movies_flow.set_halign(Gtk.Align.CENTER)
self._movies_flow.set_margin_start(16)
self._movies_flow.set_margin_end(16)
self._movies_flow.set_margin_top(16)
self._movies_flow.set_margin_bottom(8)
movies_content.append(self._movies_flow)
self._movies_spinner = Gtk.Spinner(
spinning=True,
halign=Gtk.Align.CENTER,
)
self._movies_spinner.set_size_request(32, 32)
self._movies_spinner.set_margin_top(16)
self._movies_spinner.set_margin_bottom(24)
movies_content.append(self._movies_spinner)
movies_scroll.set_child(movies_content)
movies_scroll.get_vadjustment().connect(
'value-changed', self._on_movies_scroll,
)
page = self._stack.add_titled(movies_scroll, 'movies', 'Фильмы')
page.set_icon_name('video-display-symbolic')
# TV tab
tv_scroll = Gtk.ScrolledWindow(vexpand=True)
self._tv_scroll = tv_scroll
tv_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._tv_flow = Gtk.FlowBox(
homogeneous=False,
min_children_per_line=2,
max_children_per_line=20,
row_spacing=12,
column_spacing=12,
selection_mode=Gtk.SelectionMode.NONE,
)
self._tv_flow.set_valign(Gtk.Align.START)
self._tv_flow.set_halign(Gtk.Align.CENTER)
self._tv_flow.set_margin_start(16)
self._tv_flow.set_margin_end(16)
self._tv_flow.set_margin_top(16)
self._tv_flow.set_margin_bottom(8)
tv_content.append(self._tv_flow)
self._tv_spinner = Gtk.Spinner(
spinning=True,
halign=Gtk.Align.CENTER,
)
self._tv_spinner.set_size_request(32, 32)
self._tv_spinner.set_margin_top(16)
self._tv_spinner.set_margin_bottom(24)
tv_content.append(self._tv_spinner)
tv_scroll.set_child(tv_content)
tv_scroll.get_vadjustment().connect(
'value-changed', self._on_tv_scroll,
)
page = self._stack.add_titled(tv_scroll, 'tv', 'Сериалы')
page.set_icon_name('tv-symbolic')
toolbar_view.set_content(self._stack)
# ── Data loading ──────────────────────────────────────────
@staticmethod
def _viewport_not_filled(scroll):
"""Return True if content doesn't fill the visible area."""
adj = scroll.get_vadjustment()
return adj.get_upper() <= adj.get_page_size() + 100
def _load_popular_movies(self):
if self._loading_movies or self._movies_page > self._movies_total:
return
self._loading_movies = True
self._movies_spinner.set_spinning(True)
self._movies_spinner.set_visible(True)
run_async(
self.window.tmdb.popular_movies,
self._on_movies_loaded,
self._movies_page,
)
def _on_movies_loaded(self, data, error):
self._loading_movies = False
if error:
self._movies_spinner.set_spinning(False)
self._movies_spinner.set_visible(False)
self.window.show_toast(f'Ошибка: {error}')
return
self._movies_total = data.get('total_pages', 1)
results = data.get('results', [])
for movie in results:
mid = movie.get('id')
if mid in self._seen_movie_ids:
continue
if not _has_ru_title(movie, 'movie'):
continue
self._seen_movie_ids.add(mid)
card = MediaCard(
data=movie,
media_type='movie',
image_url=self.window.tmdb.image_url(
movie.get('poster_path'), 'w342',
),
on_click=self._on_card_clicked,
)
self._movies_flow.append(card)
self._movies_page += 1
has_more = self._movies_page <= self._movies_total
self._movies_spinner.set_spinning(has_more)
self._movies_spinner.set_visible(has_more)
# Auto-load next page if viewport isn't filled yet
if has_more:
GLib.idle_add(self._maybe_load_more_movies)
def _maybe_load_more_movies(self):
if self._viewport_not_filled(self._movies_scroll):
if self._search_mode:
self._load_search_movies_next()
else:
self._load_popular_movies()
return False
def _load_popular_tv(self):
if self._loading_tv or self._tv_page > self._tv_total:
return
self._loading_tv = True
self._tv_spinner.set_spinning(True)
self._tv_spinner.set_visible(True)
run_async(
self.window.tmdb.popular_tv,
self._on_tv_loaded,
self._tv_page,
)
def _on_tv_loaded(self, data, error):
self._loading_tv = False
if error:
self._tv_spinner.set_spinning(False)
self._tv_spinner.set_visible(False)
self.window.show_toast(f'Ошибка: {error}')
return
self._tv_total = data.get('total_pages', 1)
results = data.get('results', [])
for show in results:
sid = show.get('id')
if sid in self._seen_tv_ids:
continue
if not _has_ru_title(show, 'tv'):
continue
self._seen_tv_ids.add(sid)
card = MediaCard(
data=show,
media_type='tv',
image_url=self.window.tmdb.image_url(
show.get('poster_path'), 'w342',
),
on_click=self._on_card_clicked,
)
self._tv_flow.append(card)
self._tv_page += 1
has_more = self._tv_page <= self._tv_total
self._tv_spinner.set_spinning(has_more)
self._tv_spinner.set_visible(has_more)
# Auto-load next page if viewport isn't filled yet
if has_more:
GLib.idle_add(self._maybe_load_more_tv)
def _maybe_load_more_tv(self):
if self._viewport_not_filled(self._tv_scroll):
if self._search_mode:
self._load_search_tv_next()
else:
self._load_popular_tv()
return False
# ── Infinite scroll ───────────────────────────────────────
def _near_bottom(self, adj):
return adj.get_value() + adj.get_page_size() >= adj.get_upper() - 800
def _on_movies_scroll(self, adj):
if self._near_bottom(adj):
if self._search_mode:
self._load_search_movies_next()
elif not self._loading_movies:
self._load_popular_movies()
def _on_tv_scroll(self, adj):
if self._near_bottom(adj):
if self._search_mode:
self._load_search_tv_next()
elif not self._loading_tv:
self._load_popular_tv()
# ── Search ────────────────────────────────────────────────
def _on_search(self, entry):
query = entry.get_text().strip()
if not query:
return
self._search_mode = True
self._search_query = query
self._search_movies_page = 1
self._search_movies_total = 0
self._search_tv_page = 1
self._search_tv_total = 0
self._seen_movie_ids.clear()
self._seen_tv_ids.clear()
self._clear_flow(self._movies_flow)
self._clear_flow(self._tv_flow)
self._movies_spinner.set_spinning(True)
self._movies_spinner.set_visible(True)
self._tv_spinner.set_spinning(True)
self._tv_spinner.set_visible(True)
self._loading_movies = True
self._loading_tv = True
run_async(
self.window.tmdb.search_movies,
self._on_search_movies_done,
query,
)
run_async(
self.window.tmdb.search_tv,
self._on_search_tv_done,
query,
)
def _on_search_movies_done(self, data, error):
self._loading_movies = False
if error:
self._movies_spinner.set_spinning(False)
self._movies_spinner.set_visible(False)
self.window.show_toast(f'Ошибка поиска: {error}')
return
self._search_movies_total = data.get('total_pages', 1)
for movie in data.get('results', []):
mid = movie.get('id')
if mid in self._seen_movie_ids:
continue
if not _has_ru_title(movie, 'movie'):
continue
self._seen_movie_ids.add(mid)
card = MediaCard(
data=movie,
media_type='movie',
image_url=self.window.tmdb.image_url(
movie.get('poster_path'), 'w342',
),
on_click=self._on_card_clicked,
)
self._movies_flow.append(card)
self._search_movies_page += 1
has_more = self._search_movies_page <= self._search_movies_total
self._movies_spinner.set_spinning(has_more)
self._movies_spinner.set_visible(has_more)
if has_more:
GLib.idle_add(self._maybe_load_more_movies)
def _on_search_tv_done(self, data, error):
self._loading_tv = False
if error:
self._tv_spinner.set_spinning(False)
self._tv_spinner.set_visible(False)
self.window.show_toast(f'Ошибка поиска: {error}')
return
self._search_tv_total = data.get('total_pages', 1)
for show in data.get('results', []):
sid = show.get('id')
if sid in self._seen_tv_ids:
continue
if not _has_ru_title(show, 'tv'):
continue
self._seen_tv_ids.add(sid)
card = MediaCard(
data=show,
media_type='tv',
image_url=self.window.tmdb.image_url(
show.get('poster_path'), 'w342',
),
on_click=self._on_card_clicked,
)
self._tv_flow.append(card)
self._search_tv_page += 1
has_more = self._search_tv_page <= self._search_tv_total
self._tv_spinner.set_spinning(has_more)
self._tv_spinner.set_visible(has_more)
if has_more:
GLib.idle_add(self._maybe_load_more_tv)
def _load_search_movies_next(self):
if self._loading_movies:
return
if self._search_movies_page > self._search_movies_total:
return
self._loading_movies = True
self._movies_spinner.set_spinning(True)
self._movies_spinner.set_visible(True)
run_async(
self.window.tmdb.search_movies,
self._on_search_movies_done,
self._search_query,
self._search_movies_page,
)
def _load_search_tv_next(self):
if self._loading_tv:
return
if self._search_tv_page > self._search_tv_total:
return
self._loading_tv = True
self._tv_spinner.set_spinning(True)
self._tv_spinner.set_visible(True)
run_async(
self.window.tmdb.search_tv,
self._on_search_tv_done,
self._search_query,
self._search_tv_page,
)
def _on_search_stopped(self, entry):
self._search_btn.set_active(False)
if self._search_mode:
self._search_mode = False
self._refresh_popular()
def _refresh_popular(self):
self._clear_flow(self._movies_flow)
self._clear_flow(self._tv_flow)
self._seen_movie_ids.clear()
self._seen_tv_ids.clear()
self._movies_page = 1
self._movies_total = 999
self._tv_page = 1
self._tv_total = 999
self._load_popular_movies()
self._load_popular_tv()
# ── Events ────────────────────────────────────────────────
def _on_card_clicked(self, media_type, data):
self.window.show_detail(media_type, data)
# ── Helpers ───────────────────────────────────────────────
@staticmethod
def _clear_flow(flow):
while True:
child = flow.get_child_at_index(0)
if child is None:
break
flow.remove(child)
+218
View File
@@ -0,0 +1,218 @@
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gtk
from kadr.utils import run_async
from kadr.services.tmdb import TMDB_PROXIES
from kadr.services.jackett import JACKETT_SERVERS
from kadr.services.downloads import DOWNLOAD_CLIENTS
class SettingsDialog(Adw.PreferencesWindow):
"""Application settings dialog."""
def __init__(self, window):
super().__init__(
title='Настройки',
transient_for=window,
modal=True,
)
self.window = window
self._server_statuses = {}
self._proxy_statuses = {}
self._build_servers_page()
self._build_clients_page()
# ── Servers page ──────────────────────────────────────────
def _build_servers_page(self):
page = Adw.PreferencesPage(
title='Серверы',
icon_name='network-server-symbolic',
)
self.add(page)
# ── Jackett server combo ──
server_group = Adw.PreferencesGroup(
title='Торрент сервер',
description='Публичные Jackett серверы для поиска',
)
page.add(server_group)
self._server_ids = ['auto'] + [s['id'] for s in JACKETT_SERVERS]
server_names = ['Автоматически'] + [s['name'] for s in JACKETT_SERVERS]
server_model = Gtk.StringList.new(server_names)
current_server = self.window.settings_manager.get('selected_server')
server_idx = (
self._server_ids.index(current_server)
if current_server in self._server_ids else 0
)
server_combo = Adw.ComboRow(
title='Сервер',
model=server_model,
)
server_combo.set_selected(server_idx)
server_combo.connect('notify::selected', self._on_server_selected)
server_group.add(server_combo)
# Compact health status row
self._server_status_row = Adw.ActionRow(title='Доступность')
self._server_status_box = Gtk.Box(
spacing=8, valign=Gtk.Align.CENTER,
)
self._server_status_row.add_suffix(self._server_status_box)
server_group.add(self._server_status_row)
for server in JACKETT_SERVERS:
item = Gtk.Box(spacing=4, valign=Gtk.Align.CENTER)
label = Gtk.Label(label=server['name'])
label.add_css_class('caption')
item.append(label)
spinner = Gtk.Spinner(spinning=True)
spinner.set_size_request(12, 12)
item.append(spinner)
icon = Gtk.Image(visible=False)
item.append(icon)
self._server_status_box.append(item)
self._server_statuses[server['id']] = (spinner, icon)
run_async(
self.window.jackett.check_health,
lambda ok, err, sid=server['id']: self._set_inline_status(
self._server_statuses.get(sid), ok and not err,
),
server['id'],
)
# ── TMDB proxy combo ──
proxy_group = Adw.PreferencesGroup(
title='TMDB прокси',
description='Прокси для доступа к базе фильмов',
)
page.add(proxy_group)
self._proxy_ids = ['auto'] + list(TMDB_PROXIES.keys())
proxy_names = ['Автоматически'] + [
p['name'] for p in TMDB_PROXIES.values()
]
proxy_model = Gtk.StringList.new(proxy_names)
current_proxy = self.window.settings_manager.get('tmdb_proxy')
proxy_idx = (
self._proxy_ids.index(current_proxy)
if current_proxy in self._proxy_ids else 0
)
proxy_combo = Adw.ComboRow(
title='Прокси',
model=proxy_model,
)
proxy_combo.set_selected(proxy_idx)
proxy_combo.connect('notify::selected', self._on_proxy_selected)
proxy_group.add(proxy_combo)
# Compact health status row
self._proxy_status_row = Adw.ActionRow(title='Доступность')
self._proxy_status_box = Gtk.Box(
spacing=8, valign=Gtk.Align.CENTER,
)
self._proxy_status_row.add_suffix(self._proxy_status_box)
proxy_group.add(self._proxy_status_row)
for proxy_id, proxy in TMDB_PROXIES.items():
item = Gtk.Box(spacing=4, valign=Gtk.Align.CENTER)
label = Gtk.Label(label=proxy['name'])
label.add_css_class('caption')
item.append(label)
spinner = Gtk.Spinner(spinning=True)
spinner.set_size_request(12, 12)
item.append(spinner)
icon = Gtk.Image(visible=False)
item.append(icon)
self._proxy_status_box.append(item)
self._proxy_statuses[proxy_id] = (spinner, icon)
run_async(
self.window.tmdb.check_health,
lambda ok, err, pid=proxy_id: self._set_inline_status(
self._proxy_statuses.get(pid), ok and not err,
),
proxy_id,
)
# ── Download clients page ─────────────────────────────────
def _build_clients_page(self):
page = Adw.PreferencesPage(
title='Загрузки',
icon_name='folder-download-symbolic',
)
self.add(page)
group = Adw.PreferencesGroup(
title='Торрент-клиент',
description='Выберите клиент для загрузки торрентов',
)
page.add(group)
available = self.window.downloads.available_clients()
available_ids = {c['id'] for c in available}
self._client_ids = ['auto'] + [c['id'] for c in DOWNLOAD_CLIENTS]
client_names = ['Автоматически'] + [
c['name'] + ('' if c['id'] in available_ids else ' (не найден)')
for c in DOWNLOAD_CLIENTS
]
client_model = Gtk.StringList.new(client_names)
current = self.window.settings_manager.get('download_client', 'auto')
client_idx = (
self._client_ids.index(current)
if current in self._client_ids else 0
)
client_combo = Adw.ComboRow(
title='Клиент',
subtitle='Приложение для загрузки торрентов',
model=client_model,
)
client_combo.set_selected(client_idx)
client_combo.connect('notify::selected', self._on_client_selected)
group.add(client_combo)
# ── Status helpers ────────────────────────────────────────
@staticmethod
def _set_inline_status(pair, is_online):
if not pair:
return
spinner, icon = pair
spinner.set_spinning(False)
spinner.set_visible(False)
icon.set_visible(True)
if is_online:
icon.set_from_icon_name('emblem-ok-symbolic')
icon.add_css_class('success-label')
else:
icon.set_from_icon_name('process-stop-symbolic')
icon.add_css_class('error-label')
# ── Callbacks ─────────────────────────────────────────────
def _on_server_selected(self, combo, _pspec):
idx = combo.get_selected()
if 0 <= idx < len(self._server_ids):
self.window.settings_manager.set('selected_server', self._server_ids[idx])
def _on_proxy_selected(self, combo, _pspec):
idx = combo.get_selected()
if 0 <= idx < len(self._proxy_ids):
self.window.settings_manager.set('tmdb_proxy', self._proxy_ids[idx])
def _on_client_selected(self, combo, _pspec):
idx = combo.get_selected()
if 0 <= idx < len(self._client_ids):
self.window.settings_manager.set('download_client', self._client_ids[idx])