mirror of
https://github.com/Cheviiot/Kadr.git
synced 2026-08-03 15:57:22 +00:00
Initial commit: Kadr v2.0.0
This commit is contained in:
@@ -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])
|
||||
Reference in New Issue
Block a user