From bd4f231198edf14e2e4524b8418333261e873422 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:33:19 +0200 Subject: [PATCH 01/22] Add watchlist functionality with database support --- src/gucken/gucken.py | 102 +++++++++++++++++++++++++++++++- src/gucken/resources/gucken.css | 61 +++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index b2086af..183f2ab 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -62,7 +62,48 @@ from .utils import detect_player, is_android, set_default_vlc_interface_cfg, get_vlc_intf_user_path from .networking import AsyncClient from . import __version__ - +import sqlite3 + +WATCHLIST_DB = user_config_path("gucken").joinpath("watchlist.db") + +def init_watchlist_db(): + conn = sqlite3.connect(WATCHLIST_DB) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS watchlist + (name TEXT, provider TEXT, PRIMARY KEY (name, provider))''') + conn.commit() + conn.close() + +def add_to_watchlist(series: SearchResult): + conn = sqlite3.connect(WATCHLIST_DB) + c = conn.cursor() + c.execute("INSERT OR IGNORE INTO watchlist VALUES (?, ?)", + (series.name, series.provider_name)) + conn.commit() + conn.close() + +def remove_from_watchlist(series: SearchResult): + conn = sqlite3.connect(WATCHLIST_DB) + c = conn.cursor() + c.execute("DELETE FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) + conn.commit() + conn.close() + +def is_in_watchlist(series: SearchResult) -> bool: + conn = sqlite3.connect(WATCHLIST_DB) + c = conn.cursor() + c.execute("SELECT 1 FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) + result = c.fetchone() + conn.close() + return result is not None + +def get_watchlist() -> list: + conn = sqlite3.connect(WATCHLIST_DB) + c = conn.cursor() + c.execute("SELECT name, provider FROM watchlist") + rows = c.fetchall() + conn.close() + return rows # [(name, provider_name), ...] def sort_favorite_lang( language_list: List[Language], pio_list: List[str] @@ -250,6 +291,7 @@ class GuckenApp(App): Binding("q", "quit", "Quit", show=False, priority=False), ] + init_watchlist_db() # TODO: theme_changed_signal def __init__(self, debug: bool, search: str): @@ -318,13 +360,19 @@ def compose(self) -> ComposeResult: Markdown(id="markdown"), id="res_con_2" ) + yield Button( + "Zur Watchlist hinzufügen", + id="watchlist_btn", + variant="success" + ) yield Select.from_values( [], # Leere Liste zu Beginn id="season_filter", prompt="Alle Staffeln" ) yield ClickableDataTable(id="season_list") - + with TabPane("Watchlist", id="watchlist"): + yield ListView(id="watchlist_view") with TabPane("Settings", id="setting"): # Settings "⚙" # TODO: dont show unneeded on android with ScrollableContainer(id="settings_container"): @@ -725,9 +773,18 @@ async def get_series(self, series_search_result: SearchResult): @work(exclusive=True) async def open_info(self) -> None: + watchlist_btn = self.query_one("#watchlist_btn", Button) series_search_result: SearchResult = self.current[ self.app.query_one("#results", ListView).index ] + + if is_in_watchlist(series_search_result): + watchlist_btn.label = "Aus Watchlist entfernen" + watchlist_btn.variant = "error" + else: + watchlist_btn.label = "Zur Watchlist hinzufügen" + watchlist_btn.variant = "success" + info_tab = self.query_one("#info", TabPane) info_tab.disabled = False info_tab.set_loading(True) @@ -811,6 +868,45 @@ async def open_info(self) -> None: ) info_tab.set_loading(False) + @on(Button.Pressed) + def on_watchlist_btn(self, event): + if event.button.id == "watchlist_btn": + index = self.app.query_one("#results", ListView).index + series = self.current[index] + if is_in_watchlist(series): + remove_from_watchlist(series) + event.button.label = "Zur Watchlist hinzufügen" + event.button.variant = "success" + else: + add_to_watchlist(series) + event.button.label = "Aus Watchlist entfernen" + event.button.variant = "error" + self.update_watchlist_view() + + def update_watchlist_view(self): + watchlist_view = self.query_one("#watchlist_view", ListView) + watchlist_view.clear() + for name, provider_name in get_watchlist(): + item = ClickableListItem(Markdown(f"##### {name} [{provider_name}]")) + item.anime_name = name + item.anime_provider_name = provider_name + watchlist_view.append(item) + + @on(ListView.Selected, "#watchlist_view") + async def on_watchlist_selected(self, event): + item = event.item + name = getattr(item, "anime_name", None) + provider_name = getattr(item, "anime_provider_name", None) + if name and provider_name: + results = await gather(self.aniworld_search(name), self.serienstream_search(name)) + for result_list in results: + if result_list: + for series in result_list: + if series.name == name and series.provider_name == provider_name: + self.current = [series] + self.call_later(lambda: self.open_info()) + return + @work(exclusive=True, thread=True) async def update_check(self): res = await check() @@ -1119,4 +1215,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/gucken/resources/gucken.css b/src/gucken/resources/gucken.css index d08432d..3f2f375 100644 --- a/src/gucken/resources/gucken.css +++ b/src/gucken/resources/gucken.css @@ -135,6 +135,14 @@ Tab { width: 1fr; } +.watchlist-button { + width: 100%; + height: 3; + margin: 1; + content-align: center middle; +} + + /* * This is a workaround for the horizontal scrollable container * to have a margin at the bottom, so it does not touch the next element. */ @@ -144,4 +152,57 @@ Tab { ScrollableContainer > Horizontal#res_con_2 { margin-bottom: 1; +} + +#watchlist-main-container { + width: 100%; + height: 100%; + padding: 1; + background: #1e1e1e; +} + +#watchlist-title { + text-align: center; + background: #2c3e50; + color: #ffffff; + padding: 1; + border-bottom: solid #3498db; +} + +#watchlist-content { + height: 100%; + margin: 1; +} + +#table-container { + width: 100%; + height: 100%; + border: solid #333333; + background: #252525; +} + +.watchlist-table { + width: 100%; + height: 100%; + color: #ffffff; +} + +.watchlist-table > .datatable--header { + background: #2c3e50; + padding: 1; + text-style: bold; +} + +.watchlist-table > .datatable--row { + padding: 1; + border-bottom: solid #333333; +} + +.watchlist-table > .datatable--row:hover { + background: #2c3e50; +} + +.watchlist-table > .datatable--row-selected { + background: #3498db; + color: #ffffff; } \ No newline at end of file From e01284f1b01814f58f6dacbe4ef29688fb887951 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:04:06 +0200 Subject: [PATCH 02/22] Add watchtime and watched episodes tracking functionality --- src/gucken/gucken.py | 315 +++++++++++++++++++++++++++---------------- 1 file changed, 197 insertions(+), 118 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 183f2ab..fbfe766 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -65,6 +65,53 @@ import sqlite3 WATCHLIST_DB = user_config_path("gucken").joinpath("watchlist.db") +WATCHTIME_DB = user_config_path("gucken").joinpath("watchtime.db") +WATCHED_EPISODES_DB = user_config_path("gucken").joinpath("watched_episodes.db") + +def init_watched_episodes_db(): + conn = sqlite3.connect(WATCHED_EPISODES_DB) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS watched_episodes + (series TEXT, season INTEGER, episode INTEGER, provider TEXT, + PRIMARY KEY (series, season, episode, provider))''') + conn.commit() + conn.close() + +def mark_episode_watched(series, season, episode, provider): + conn = sqlite3.connect(WATCHED_EPISODES_DB) + c = conn.cursor() + c.execute('''INSERT OR REPLACE INTO watched_episodes (series, season, episode, provider) + VALUES (?, ?, ?, ?)''', (series, season, episode, provider)) + conn.commit() + conn.close() + + + +def is_episode_watched(series, season, episode, provider): + conn = sqlite3.connect(WATCHED_EPISODES_DB) + c = conn.cursor() + c.execute('''SELECT 1 FROM watched_episodes WHERE series=? AND season=? AND episode=? AND provider=?''', + (series, season, episode, provider)) + result = c.fetchone() + conn.close() + return result is not None + +def init_watchtime_db(): + conn = sqlite3.connect(WATCHTIME_DB) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS watchtime + (series TEXT, season INTEGER, episode INTEGER, provider TEXT, time TEXT, + PRIMARY KEY (series, season, episode, provider))''') + conn.commit() + conn.close() + +def save_watchtime(series, season, episode, provider, time_str): + conn = sqlite3.connect(WATCHTIME_DB) + c = conn.cursor() + c.execute('''INSERT OR REPLACE INTO watchtime (series, season, episode, provider, time) + VALUES (?, ?, ?, ?, ?)''', (series, season, episode, provider, time_str)) + conn.commit() + conn.close() def init_watchlist_db(): conn = sqlite3.connect(WATCHLIST_DB) @@ -74,6 +121,22 @@ def init_watchlist_db(): conn.commit() conn.close() +def get_unfinished_watchtime(): + conn = sqlite3.connect(WATCHTIME_DB) + c = conn.cursor() + c.execute('SELECT series, season, episode, provider, time FROM watchtime') + rows = c.fetchall() + conn.close() + return rows + +def remove_watchtime(series, season, episode, provider): + conn = sqlite3.connect(WATCHTIME_DB) + c = conn.cursor() + c.execute('DELETE FROM watchtime WHERE series=? AND season=? AND episode=? AND provider=?', + (series, season, episode, provider)) + conn.commit() + conn.close() + def add_to_watchlist(series: SearchResult): conn = sqlite3.connect(WATCHLIST_DB) c = conn.cursor() @@ -280,6 +343,28 @@ def move_item(lst: list, from_index: int, to_index: int) -> list: CLIENT_ID = "1238219157464416266" +class ContinueWatchScreen(ModalScreen): + def __init__(self, question, callback): + super().__init__() + self.question = question + self.callback = callback + + def compose(self): + with Container(): + yield Label(self.question) + with Horizontal(): + yield Button("Nein", id="no") + yield Button("Ja", id="yes") + + @on(Button.Pressed) + def handle_button(self, event): + if event.button.id == "yes": + self.dismiss(True) + else: + self.dismiss(False) + + def on_dismiss(self, result): + self.callback(result) class GuckenApp(App): TITLE = f"Gucken {__version__}" @@ -292,6 +377,9 @@ class GuckenApp(App): ] init_watchlist_db() + init_watchtime_db() + init_watched_episodes_db() + # TODO: theme_changed_signal def __init__(self, debug: bool, search: str): @@ -303,6 +391,10 @@ def __init__(self, debug: bool, search: str): self.current_info: Union[Series, None] = None self.detected_player = detect_player() self.RPC: Union[AioPresence, None] = None + self.unfinished = get_unfinished_watchtime() + + if self.unfinished: + self.ask_next_unfinished() language: list = gucken_settings_manager.settings["settings"]["language"] language = remove_none_lang_keys(language) @@ -326,6 +418,22 @@ def __init__(self, debug: bool, search: str): ) self.hoster = gucken_settings_manager.settings["settings"]["hoster"] + def ask_next_unfinished(self, i=0): + if i >= len(self.unfinished): + return + series, season, episode, provider, time_str = self.unfinished[i] + question = f"Du hast '{series}' S{season}E{episode} [{provider}] noch nicht zu Ende geschaut. Weiterschauen ab {time_str}?" + + def on_answer(result): + if result: + # Hier Wiedergabe starten, z.B.: + # self.play_from_watchtime(series, season, episode, provider, time_str) + pass + else: + self.ask_next_unfinished(i + 1) + + self.push_screen(ContinueWatchScreen(question, on_answer)) + def compose(self) -> ComposeResult: settings = gucken_settings_manager.settings["settings"] providers = settings["providers"] @@ -935,19 +1043,18 @@ async def play( ) return - if p != "AutomaticPlayer": - if not _player.is_available(): - self.notify( - "Your configured player has not been found!", - title="Player not found", - severity="error", - ) - return + if p != "AutomaticPlayer" and not _player.is_available(): + self.notify( + "Your configured player has not been found!", + title="Player not found", + severity="error", + ) + return episode: Episode = episodes[index] processed_hoster = await episode.process_hoster() - if len(episode.available_language) <= 0: + if not episode.available_language: self.notify( "The episode you are trying to watch has no stream available.", title="No stream available", @@ -958,8 +1065,14 @@ async def play( lang = sort_favorite_lang(episode.available_language, self.language)[0] sorted_hoster = sort_favorite_hoster(processed_hoster.get(lang), self.hoster) direct_link = await get_working_direct_link(sorted_hoster, self) + if not direct_link: + self.notify( + "No working stream found.", + title="No stream available", + severity="error", + ) + return - # TODO: check for header support syncplay = gucken_settings_manager.settings["settings"]["syncplay"] fullscreen = gucken_settings_manager.settings["settings"]["fullscreen"] @@ -969,51 +1082,31 @@ async def play( if self.RPC and self.RPC.sock_writer: async def update(): await self.RPC.update( - # state="00:20:00 / 00:25:00 57% complete", details=title[:128], large_text=title, large_image=series_search_result.cover, - # small_image as playing or stopped ? - # small_image="https://jooinn.com/images/lonely-tree-reflection-3.jpg", - # small_text="ff 15", - # start=time.time(), # for paused - # end=time.time() + timedelta(minutes=20).seconds # for time left ) self.app.call_later(update) - # Picture-in-Picture mode if gucken_settings_manager.settings["settings"]["pip"]: if isinstance(_player, MPVPlayer): - args.append("--ontop") - args.append("--no-border") - args.append("--snap-window") - + args += ["--ontop", "--no-border", "--snap-window"] if isinstance(_player, VLCPlayer): - args.append("--video-on-top") - args.append("--qt-minimal-view") - args.append("--no-video-deco") + args += ["--video-on-top", "--qt-minimal-view", "--no-video-deco"] - if direct_link.force_hls: - # TODO: make work for vlc and others - if isinstance(_player, MPVPlayer): - args.append("--demuxer=lavf") - args.append("--demuxer-lavf-format=hls") + if direct_link.force_hls and isinstance(_player, MPVPlayer): + args += ["--demuxer=lavf", "--demuxer-lavf-format=hls"] if self._debug: logs_path = user_log_path("gucken", ensure_exists=True) if isinstance(_player, MPVPlayer): args.append("--log-file=" + str(logs_path.joinpath("mpv.log"))) elif isinstance(_player, VLCPlayer): - args.append("--file-logging") - args.append("--log-verbose=3") - args.append("--logfile=" + str(logs_path.joinpath("vlc.log"))) + args += ["--file-logging", "--log-verbose=3", "--logfile=" + str(logs_path.joinpath("vlc.log"))] chapters_file = None - # TODO: cache more - # TODO: Support based on mpv - # TODO: recover start --start=00:56 if isinstance(_player, MPVPlayer) or isinstance(_player, VLCPlayer): ani_skip_opening = gucken_settings_manager.settings["settings"]["ani_skip"]["skip_opening"] ani_skip_ending = gucken_settings_manager.settings["settings"]["ani_skip"]["skip_ending"] @@ -1036,53 +1129,40 @@ def delete_chapters_file(): register_atexit(delete_chapters_file) args.append(f"--chapters-file={chapters_file.name}") - script_opts = [] if ani_skip_opening: - script_opts.append(f"skip-op_start={timings.op_start}") - script_opts.append(f"skip-op_end={timings.op_end}") + script_opts += [f"skip-op_start={timings.op_start}", f"skip-op_end={timings.op_end}"] if ani_skip_ending: - script_opts.append(f"skip-ed_start={timings.ed_start}") - script_opts.append(f"skip-ed_end={timings.ed_end}") - if len(script_opts) > 0: + script_opts += [f"skip-ed_start={timings.ed_start}", f"skip-ed_end={timings.ed_end}"] + if script_opts: args.append(f"--script-opts={','.join(script_opts)}") - args.append( "--scripts-append=" + str(Path(__file__).parent.joinpath("resources", "mpv_gucken.lua"))) - if isinstance(_player, VLCPlayer): prepend_data = [] if ani_skip_opening: - prepend_data.append(set_default_vlc_interface_cfg("op_start", timings.op_start)) - prepend_data.append(set_default_vlc_interface_cfg("op_end", timings.op_end)) + prepend_data += [set_default_vlc_interface_cfg("op_start", timings.op_start), + set_default_vlc_interface_cfg("op_end", timings.op_end)] if ani_skip_ending: - prepend_data.append(set_default_vlc_interface_cfg("ed_start", timings.ed_start)) - prepend_data.append(set_default_vlc_interface_cfg("ed_end", timings.ed_end)) - + prepend_data += [set_default_vlc_interface_cfg("ed_start", timings.ed_start), + set_default_vlc_interface_cfg("ed_end", timings.ed_end)] vlc_intf_user_path = get_vlc_intf_user_path(_player.executable).vlc_intf_user_path Path(vlc_intf_user_path).mkdir(mode=0o755, parents=True, exist_ok=True) - vlc_skip_plugin = Path(__file__).parent.joinpath("resources", "vlc_gucken.lua") copy_to = join(vlc_intf_user_path, "vlc_gucken.lua") - with open(vlc_skip_plugin, 'r') as f: original_content = f.read() - with open(copy_to, 'w') as f: f.write("\n".join(prepend_data) + original_content) - args.append("--control=luaintf{intf=vlc_gucken}") if syncplay: - # TODO: make work with flatpak - # TODO: make work with android syncplay_path = None if which("syncplay"): syncplay_path = "syncplay" - if not syncplay_path: - if os_name == "nt": - if which(r"C:\Program Files (x86)\Syncplay\Syncplay.exe"): - syncplay_path = r"C:\Program Files (x86)\Syncplay\Syncplay.exe" + if not syncplay_path and os_name == "nt": + if which(r"C:\Program Files (x86)\Syncplay\Syncplay.exe"): + syncplay_path = r"C:\Program Files (x86)\Syncplay\Syncplay.exe" if not syncplay_path: self.notify( "Syncplay not found", @@ -1090,20 +1170,10 @@ def delete_chapters_file(): severity="error", ) else: - # TODO: add mpv.net, IINA, MPC-BE, MPC-HE, celluloid ? if isinstance(_player, MPVPlayer) or isinstance(_player, VLCPlayer): player_path = which(args[0]) url = args[1] - args.pop(0) - args.pop(0) - args = [ - syncplay_path, - "--player-path", - player_path, - # "--debug", - url, - "--", - ] + args + args = [syncplay_path, "--player-path", player_path, url, "--"] + args[2:] else: self.notify( "Your player is not supported by Syncplay", @@ -1112,64 +1182,73 @@ def delete_chapters_file(): ) logging.info("Running: %s", args) - # TODO: detach on linux - # multiprocessing - # child_pid = os.fork() - # if child_pid == 0: process = Popen(args, stderr=PIPE, stdout=DEVNULL, stdin=DEVNULL) - while not self.app._exit: - sleep(0.1) - - resume_time = None + resume_time = None - # only if mpv WIP + try: while not self.app._exit: - output = process.stderr.readline() + sleep(0.1) if process.poll() is not None: break + output = process.stderr.readline() if output: - out_s = output.strip().decode() - # AV: 00:11:57 / 00:24:38 (49%) A-V: 0.000 Cache: 89s/22MB + out_s = output.strip().decode(errors="ignore") if out_s.startswith("AV:"): sp = out_s.split(" ") - resume_time = sp[1] - - if resume_time: - logging.info("Resume: %s", resume_time) - - exit_code = process.poll() - - if exit_code is not None: - if chapters_file: - try: - remove(chapters_file.name) - except FileNotFoundError: - pass - if self.RPC and self.RPC.sock_writer: - self.app.call_later(self.RPC.clear) - - async def push_next_screen(): - async def play_next(should_next): - if should_next: - self.play( - series_search_result, - episodes, - index + 1, - ) - - await self.app.push_screen( - Next("Playing next episode in", no_time=is_android), - callback=play_next, - ) - - autoplay = gucken_settings_manager.settings["settings"]["autoplay"]["enabled"] - if not len(episodes) <= index + 1: - if autoplay is True: - self.app.call_later(push_next_screen) - else: - # TODO: ask to mark as completed + if len(sp) > 1: + resume_time = sp[1] # Format: HH:MM:SS + + if resume_time is None or resume_time in ("00:00:00", "0:00:00"): + remove_watchtime( + series_search_result.name, + episode.season, + episode.episode_number, + series_search_result.provider_name + ) + mark_episode_watched( + series_search_result.name, + episode.season, + episode.episode_number, + series_search_result.provider_name + ) + logging.info("Episode als gesehen markiert und Watchtime entfernt.") + else: + save_watchtime( + series_search_result.name, + episode.season, + episode.episode_number, + series_search_result.provider_name, + resume_time + ) + logging.info("Resume-Time gespeichert: %s", resume_time) + finally: + if chapters_file: + try: + remove(chapters_file.name) + except FileNotFoundError: pass - return + if self.RPC and self.RPC.sock_writer: + self.app.call_later(self.RPC.clear) + + exit_code = process.poll() + if exit_code is not None: + async def push_next_screen(): + async def play_next(should_next): + if should_next: + self.play( + series_search_result, + episodes, + index + 1, + ) + + await self.app.push_screen( + Next("Playing next episode in", no_time=is_android), + callback=play_next, + ) + + autoplay = gucken_settings_manager.settings["settings"]["autoplay"]["enabled"] + if len(episodes) > index + 1 and autoplay is True: + self.app.call_later(push_next_screen) exit_quotes = [ From 459623aab54e9e25316c14eb6e6e4a03727a2842 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:13:17 +0200 Subject: [PATCH 03/22] Enhance CSS styles for season filter and watchlist button --- src/gucken/resources/gucken.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/gucken/resources/gucken.css b/src/gucken/resources/gucken.css index 3f2f375..380af78 100644 --- a/src/gucken/resources/gucken.css +++ b/src/gucken/resources/gucken.css @@ -205,4 +205,15 @@ ScrollableContainer > Horizontal#res_con_2 { .watchlist-table > .datatable--row-selected { background: #3498db; color: #ffffff; +} + +.season-filter { + margin-bottom: 2; /* Erhöhe den Abstand nach unten */ +} + +#watchlist_btn { + width: auto; + min-width: 20; /* gleiche Breite wie das Bild, falls das Bild 20 breit ist */ + margin: 1 0; + content-align: center middle; } \ No newline at end of file From 32b27302c0926f95dd82bbaa40c23ecb579c80a6 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:16:24 +0200 Subject: [PATCH 04/22] Add loop for graceful exit during process execution --- src/gucken/gucken.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index fbfe766..27a930e 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -1183,6 +1183,10 @@ def delete_chapters_file(): logging.info("Running: %s", args) process = Popen(args, stderr=PIPE, stdout=DEVNULL, stdin=DEVNULL) + while not self.app._exit: + sleep(0.1) + + resume_time = None resume_time = None try: From b7fe661026429a3abb406671dbe83b128ca980d5 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 19:15:59 +0200 Subject: [PATCH 05/22] Refactor database initialization to use a single database for watchlist, watchtime, and watched episodes --- src/gucken/gucken.py | 54 +++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 27a930e..8f33481 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -64,31 +64,32 @@ from . import __version__ import sqlite3 -WATCHLIST_DB = user_config_path("gucken").joinpath("watchlist.db") -WATCHTIME_DB = user_config_path("gucken").joinpath("watchtime.db") -WATCHED_EPISODES_DB = user_config_path("gucken").joinpath("watched_episodes.db") +GUCKEN_DB = user_config_path("gucken").joinpath("gucken.db") -def init_watched_episodes_db(): - conn = sqlite3.connect(WATCHED_EPISODES_DB) +def init_db(): + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('''CREATE TABLE IF NOT EXISTS watched_episodes (series TEXT, season INTEGER, episode INTEGER, provider TEXT, PRIMARY KEY (series, season, episode, provider))''') + c.execute('''CREATE TABLE IF NOT EXISTS watchtime + (series TEXT, season INTEGER, episode INTEGER, provider TEXT, time TEXT, + PRIMARY KEY (series, season, episode, provider))''') + c.execute('''CREATE TABLE IF NOT EXISTS watchlist + (name TEXT, provider TEXT, PRIMARY KEY (name, provider))''') conn.commit() conn.close() def mark_episode_watched(series, season, episode, provider): - conn = sqlite3.connect(WATCHED_EPISODES_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('''INSERT OR REPLACE INTO watched_episodes (series, season, episode, provider) VALUES (?, ?, ?, ?)''', (series, season, episode, provider)) conn.commit() conn.close() - - def is_episode_watched(series, season, episode, provider): - conn = sqlite3.connect(WATCHED_EPISODES_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('''SELECT 1 FROM watched_episodes WHERE series=? AND season=? AND episode=? AND provider=?''', (series, season, episode, provider)) @@ -96,33 +97,16 @@ def is_episode_watched(series, season, episode, provider): conn.close() return result is not None -def init_watchtime_db(): - conn = sqlite3.connect(WATCHTIME_DB) - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS watchtime - (series TEXT, season INTEGER, episode INTEGER, provider TEXT, time TEXT, - PRIMARY KEY (series, season, episode, provider))''') - conn.commit() - conn.close() - def save_watchtime(series, season, episode, provider, time_str): - conn = sqlite3.connect(WATCHTIME_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('''INSERT OR REPLACE INTO watchtime (series, season, episode, provider, time) VALUES (?, ?, ?, ?, ?)''', (series, season, episode, provider, time_str)) conn.commit() conn.close() -def init_watchlist_db(): - conn = sqlite3.connect(WATCHLIST_DB) - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS watchlist - (name TEXT, provider TEXT, PRIMARY KEY (name, provider))''') - conn.commit() - conn.close() - def get_unfinished_watchtime(): - conn = sqlite3.connect(WATCHTIME_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('SELECT series, season, episode, provider, time FROM watchtime') rows = c.fetchall() @@ -130,7 +114,7 @@ def get_unfinished_watchtime(): return rows def remove_watchtime(series, season, episode, provider): - conn = sqlite3.connect(WATCHTIME_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute('DELETE FROM watchtime WHERE series=? AND season=? AND episode=? AND provider=?', (series, season, episode, provider)) @@ -138,7 +122,7 @@ def remove_watchtime(series, season, episode, provider): conn.close() def add_to_watchlist(series: SearchResult): - conn = sqlite3.connect(WATCHLIST_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute("INSERT OR IGNORE INTO watchlist VALUES (?, ?)", (series.name, series.provider_name)) @@ -146,14 +130,14 @@ def add_to_watchlist(series: SearchResult): conn.close() def remove_from_watchlist(series: SearchResult): - conn = sqlite3.connect(WATCHLIST_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute("DELETE FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) conn.commit() conn.close() def is_in_watchlist(series: SearchResult) -> bool: - conn = sqlite3.connect(WATCHLIST_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute("SELECT 1 FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) result = c.fetchone() @@ -161,7 +145,7 @@ def is_in_watchlist(series: SearchResult) -> bool: return result is not None def get_watchlist() -> list: - conn = sqlite3.connect(WATCHLIST_DB) + conn = sqlite3.connect(GUCKEN_DB) c = conn.cursor() c.execute("SELECT name, provider FROM watchlist") rows = c.fetchall() @@ -376,9 +360,7 @@ class GuckenApp(App): Binding("q", "quit", "Quit", show=False, priority=False), ] - init_watchlist_db() - init_watchtime_db() - init_watched_episodes_db() + init_db() # TODO: theme_changed_signal From 9c9cb9c5a233944a176ab3ad774334eee22563f6 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sat, 7 Jun 2025 19:51:04 +0200 Subject: [PATCH 06/22] Refactor episode sorting and add watched status check in display logic --- src/gucken/gucken.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 8f33481..fa83bac 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -888,7 +888,6 @@ async def open_info(self) -> None: season_filter = self.query_one("#season_filter", Select) unique_seasons = sorted(set(ep.season for ep in series.episodes)) - # Sortiere die Staffeln so, dass Filme (Staffel 0) am Ende erscheint regular_seasons = [s for s in unique_seasons if s != 0] movies_season = [s for s in unique_seasons if s == 0] sorted_seasons = regular_seasons + movies_season @@ -908,39 +907,25 @@ async def open_info(self) -> None: response = await client.get(series.cover) img.image = BytesIO(response.content) - # make sure to reset colum spacing table.clear(columns=True) table.add_columns("FT", "S", "F", "Title", "Hoster", "Sprache") - # Sortiere die Episoden entsprechend - - # Sortiere die Episoden entsprechend der gewünschten Reihenfolge sorted_episodes = [] - # Zuerst Specials (S) for ep in series.episodes: if ep.season == "S": sorted_episodes.append(ep) - # Dann numerische Staffeln for ep in series.episodes: if isinstance(ep.season, (int, str)) and ep.season not in ["S", 0]: sorted_episodes.append(ep) - # Zum Schluss Filme (F) for ep in series.episodes: if ep.season == 0: sorted_episodes.append(ep) c = 0 for ep in sorted_episodes: - hl = [] - for h in ep.available_hoster: - hl.append(hoster.get_key(h)) - - ll = [] - for l in sort_favorite_lang(ep.available_language, self.language): - ll.append(l.name) - + hl = [hoster.get_key(h) for h in ep.available_hoster] + ll = [l.name for l in sort_favorite_lang(ep.available_language, self.language)] c += 1 - # Zeige die Staffeln in der gewünschten Reihenfolge if ep.season == "S": season_display = "S" elif ep.season == 0: @@ -948,6 +933,14 @@ async def open_info(self) -> None: else: season_display = ep.season + # Prüfe, ob die Episode gesehen wurde + watched = is_episode_watched( + series_search_result.name, + ep.season, + ep.episode_number, + series_search_result.provider_name + ) + table.add_row( c, season_display, @@ -956,7 +949,7 @@ async def open_info(self) -> None: " ".join(sort_favorite_hoster_by_key(hl, self.hoster)), " ".join(ll), ) - info_tab.set_loading(False) + info_tab.set_loading(False) @on(Button.Pressed) def on_watchlist_btn(self, event): From 459cd7ac2021dfaa47aa75dc3232f60b8333c261 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:05:38 +0200 Subject: [PATCH 07/22] Watchlist Quick Fix --- src/gucken/gucken.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index fa83bac..beb36b3 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -658,6 +658,8 @@ def select_changed(self, event: Select.Changed) -> None: async def on_mount(self) -> None: self.theme = getenv("TEXTUAL_THEME") or gucken_settings_manager.settings["settings"]["ui"]["theme"] + self.update_watchlist_view() + def on_theme_change(old_value: str, new_value: str) -> None: gucken_settings_manager.settings["settings"]["ui"]["theme"] = new_value From 7ce664a1c7a157b347e39753f4c00a6dcfe2f021 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:08:48 +0200 Subject: [PATCH 08/22] Watchlist Quick Fix --- src/gucken/gucken.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index beb36b3..03f28bc 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -866,6 +866,10 @@ async def get_series(self, series_search_result: SearchResult): @work(exclusive=True) async def open_info(self) -> None: watchlist_btn = self.query_one("#watchlist_btn", Button) + index = self.app.query_one("#results", ListView).index + if index is None or not self.current or index >= len(self.current): + return + series_search_result: SearchResult = self.current[ self.app.query_one("#results", ListView).index ] From ebf7405c316023d25de7404e65695fdc947d0428 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:10:11 +0200 Subject: [PATCH 09/22] Watchlist Quick Fix --- src/gucken/gucken.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 03f28bc..8d3e574 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -871,7 +871,7 @@ async def open_info(self) -> None: return series_search_result: SearchResult = self.current[ - self.app.query_one("#results", ListView).index + index ] if is_in_watchlist(series_search_result): From 52fa685262738375744fb723b69aa586207f5142 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:18:02 +0200 Subject: [PATCH 10/22] Watchlist Quick Fix Final Fix --- src/gucken/gucken.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 8d3e574..0656b8c 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -864,16 +864,41 @@ async def get_series(self, series_search_result: SearchResult): return await series_search_result.get_series() @work(exclusive=True) - async def open_info(self) -> None: + async def open_info(self, name=None, provider=None) -> None: watchlist_btn = self.query_one("#watchlist_btn", Button) - index = self.app.query_one("#results", ListView).index - if index is None or not self.current or index >= len(self.current): - return - series_search_result: SearchResult = self.current[ - index - ] + # Falls name und provider übergeben werden, suche das passende SearchResult + if name and provider: + # Suche in self.current + series_search_result = None + if self.current: + for s in self.current: + if s.name == name and s.provider_name == provider: + series_search_result = s + break + # Falls nicht gefunden, suche per Provider + if not series_search_result: + results = await gather(self.aniworld_search(name), self.serienstream_search(name)) + for result_list in results: + if result_list: + for s in result_list: + if s.name == name and s.provider_name == provider: + series_search_result = s + break + if series_search_result: + break + if not series_search_result: + return + # Setze self.current auf das gefundene Ergebnis, damit alles wie gewohnt funktioniert + self.current = [series_search_result] + index = 0 + else: + index = self.app.query_one("#results", ListView).index + if index is None or not self.current or index >= len(self.current): + return + series_search_result = self.current[index] + # Rest wie gehabt ... if is_in_watchlist(series_search_result): watchlist_btn.label = "Aus Watchlist entfernen" watchlist_btn.variant = "error" @@ -939,7 +964,6 @@ async def open_info(self) -> None: else: season_display = ep.season - # Prüfe, ob die Episode gesehen wurde watched = is_episode_watched( series_search_result.name, ep.season, @@ -993,7 +1017,7 @@ async def on_watchlist_selected(self, event): for series in result_list: if series.name == name and series.provider_name == provider_name: self.current = [series] - self.call_later(lambda: self.open_info()) + self.call_later(lambda: self.open_info(name, provider_name)) return @work(exclusive=True, thread=True) From aabcc5714a6f44bee65cea8ac3c3c4b5ecd52312 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:20:21 +0200 Subject: [PATCH 11/22] Watchlist Quick Fix Final Fix --- src/gucken/gucken.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 0656b8c..3d1162e 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -896,7 +896,9 @@ async def open_info(self, name=None, provider=None) -> None: index = self.app.query_one("#results", ListView).index if index is None or not self.current or index >= len(self.current): return - series_search_result = self.current[index] + series_search_result: SearchResult = self.current[ + self.app.query_one("#results", ListView).index + ] # Rest wie gehabt ... if is_in_watchlist(series_search_result): From b475a67dc6f17b215430cd7b756f41e9017ad8af Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:21:25 +0200 Subject: [PATCH 12/22] Watchlist Quick Fix Final Fix --- src/gucken/gucken.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 3d1162e..b1dadd0 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -987,7 +987,13 @@ async def open_info(self, name=None, provider=None) -> None: def on_watchlist_btn(self, event): if event.button.id == "watchlist_btn": index = self.app.query_one("#results", ListView).index - series = self.current[index] + if index is None: + # Fallback: erstes Element aus self.current verwenden + if not self.current: + return + series = self.current[0] + else: + series = self.current[index] if is_in_watchlist(series): remove_from_watchlist(series) event.button.label = "Zur Watchlist hinzufügen" From e5410108ff315753dd3a180afb9008843ddda903 Mon Sep 17 00:00:00 2001 From: Commandcracker <49335821+Commandcracker@users.noreply.github.com> Date: Sun, 8 Jun 2025 22:27:38 +0200 Subject: [PATCH 13/22] move db stuff to seperate file --- src/gucken/db.py | 91 +++++++++++++++++++++++++++++++++++++ src/gucken/gucken.py | 105 +++++++------------------------------------ 2 files changed, 106 insertions(+), 90 deletions(-) create mode 100644 src/gucken/db.py diff --git a/src/gucken/db.py b/src/gucken/db.py new file mode 100644 index 0000000..0298dce --- /dev/null +++ b/src/gucken/db.py @@ -0,0 +1,91 @@ +from sqlite3 import connect +from platformdirs import user_config_path +from .provider.common import SearchResult + +GUCKEN_DB = user_config_path("gucken").joinpath("gucken.db") + +def init_db(): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS watched_episodes + (series TEXT, season INTEGER, episode INTEGER, provider TEXT, + PRIMARY KEY (series, season, episode, provider))''') + c.execute('''CREATE TABLE IF NOT EXISTS watchtime + (series TEXT, season INTEGER, episode INTEGER, provider TEXT, time TEXT, + PRIMARY KEY (series, season, episode, provider))''') + c.execute('''CREATE TABLE IF NOT EXISTS watchlist + (name TEXT, provider TEXT, PRIMARY KEY (name, provider))''') + conn.commit() + conn.close() + +def mark_episode_watched(series, season, episode, provider): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('''INSERT OR REPLACE INTO watched_episodes (series, season, episode, provider) + VALUES (?, ?, ?, ?)''', (series, season, episode, provider)) + conn.commit() + conn.close() + +def is_episode_watched(series, season, episode, provider): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('''SELECT 1 FROM watched_episodes WHERE series=? AND season=? AND episode=? AND provider=?''', + (series, season, episode, provider)) + result = c.fetchone() + conn.close() + return result is not None + +def save_watchtime(series, season, episode, provider, time_str): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('''INSERT OR REPLACE INTO watchtime (series, season, episode, provider, time) + VALUES (?, ?, ?, ?, ?)''', (series, season, episode, provider, time_str)) + conn.commit() + conn.close() + +def get_unfinished_watchtime(): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('SELECT series, season, episode, provider, time FROM watchtime') + rows = c.fetchall() + conn.close() + return rows + +def remove_watchtime(series, season, episode, provider): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute('DELETE FROM watchtime WHERE series=? AND season=? AND episode=? AND provider=?', + (series, season, episode, provider)) + conn.commit() + conn.close() + +def add_to_watchlist(series: SearchResult): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute("INSERT OR IGNORE INTO watchlist VALUES (?, ?)", + (series.name, series.provider_name)) + conn.commit() + conn.close() + +def remove_from_watchlist(series: SearchResult): + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute("DELETE FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) + conn.commit() + conn.close() + +def is_in_watchlist(series: SearchResult) -> bool: + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute("SELECT 1 FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) + result = c.fetchone() + conn.close() + return result is not None + +def get_watchlist() -> list: + conn = connect(GUCKEN_DB) + c = conn.cursor() + c.execute("SELECT name, provider FROM watchlist") + rows = c.fetchall() + conn.close() + return rows # [(name, provider_name), ...] diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index b1dadd0..4a5097a 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -21,7 +21,7 @@ from textual import events, on, work from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Center, Container, Horizontal, ScrollableContainer, Vertical, Grid +from textual.containers import Center, Container, Horizontal, ScrollableContainer from textual.reactive import reactive from textual.screen import ModalScreen from textual.widgets import ( @@ -43,6 +43,19 @@ from textual.worker import get_current_worker from textual_image.widget import Image from rich_argparse import RichHelpFormatter + +from .db import ( + is_in_watchlist, + remove_from_watchlist, + add_to_watchlist, + get_watchlist, + remove_watchtime, + mark_episode_watched, + save_watchtime, + init_db, + get_unfinished_watchtime, + is_episode_watched +) from .aniskip import ( generate_chapters_file, get_timings_from_search @@ -62,95 +75,7 @@ from .utils import detect_player, is_android, set_default_vlc_interface_cfg, get_vlc_intf_user_path from .networking import AsyncClient from . import __version__ -import sqlite3 - -GUCKEN_DB = user_config_path("gucken").joinpath("gucken.db") - -def init_db(): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS watched_episodes - (series TEXT, season INTEGER, episode INTEGER, provider TEXT, - PRIMARY KEY (series, season, episode, provider))''') - c.execute('''CREATE TABLE IF NOT EXISTS watchtime - (series TEXT, season INTEGER, episode INTEGER, provider TEXT, time TEXT, - PRIMARY KEY (series, season, episode, provider))''') - c.execute('''CREATE TABLE IF NOT EXISTS watchlist - (name TEXT, provider TEXT, PRIMARY KEY (name, provider))''') - conn.commit() - conn.close() - -def mark_episode_watched(series, season, episode, provider): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('''INSERT OR REPLACE INTO watched_episodes (series, season, episode, provider) - VALUES (?, ?, ?, ?)''', (series, season, episode, provider)) - conn.commit() - conn.close() - -def is_episode_watched(series, season, episode, provider): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('''SELECT 1 FROM watched_episodes WHERE series=? AND season=? AND episode=? AND provider=?''', - (series, season, episode, provider)) - result = c.fetchone() - conn.close() - return result is not None - -def save_watchtime(series, season, episode, provider, time_str): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('''INSERT OR REPLACE INTO watchtime (series, season, episode, provider, time) - VALUES (?, ?, ?, ?, ?)''', (series, season, episode, provider, time_str)) - conn.commit() - conn.close() - -def get_unfinished_watchtime(): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('SELECT series, season, episode, provider, time FROM watchtime') - rows = c.fetchall() - conn.close() - return rows - -def remove_watchtime(series, season, episode, provider): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute('DELETE FROM watchtime WHERE series=? AND season=? AND episode=? AND provider=?', - (series, season, episode, provider)) - conn.commit() - conn.close() - -def add_to_watchlist(series: SearchResult): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute("INSERT OR IGNORE INTO watchlist VALUES (?, ?)", - (series.name, series.provider_name)) - conn.commit() - conn.close() - -def remove_from_watchlist(series: SearchResult): - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute("DELETE FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) - conn.commit() - conn.close() - -def is_in_watchlist(series: SearchResult) -> bool: - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute("SELECT 1 FROM watchlist WHERE name=? AND provider=?", (series.name, series.provider_name)) - result = c.fetchone() - conn.close() - return result is not None - -def get_watchlist() -> list: - conn = sqlite3.connect(GUCKEN_DB) - c = conn.cursor() - c.execute("SELECT name, provider FROM watchlist") - rows = c.fetchall() - conn.close() - return rows # [(name, provider_name), ...] + def sort_favorite_lang( language_list: List[Language], pio_list: List[str] From 778428ab932992c0745fe31101f22170df99b9af Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 18:23:15 +0200 Subject: [PATCH 14/22] Refactor watchtime handling and improve episode transition logic --- src/gucken/gucken.py | 111 +++++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index b1dadd0..ed5b94e 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -1200,72 +1200,69 @@ def delete_chapters_file(): sleep(0.1) resume_time = None - resume_time = None - try: + # only if mpv WIP while not self.app._exit: - sleep(0.1) + output = process.stderr.readline() if process.poll() is not None: break - output = process.stderr.readline() if output: - out_s = output.strip().decode(errors="ignore") + out_s = output.strip().decode() + # AV: 00:11:57 / 00:24:38 (49%) A-V: 0.000 Cache: 89s/22MB if out_s.startswith("AV:"): sp = out_s.split(" ") - if len(sp) > 1: - resume_time = sp[1] # Format: HH:MM:SS - - if resume_time is None or resume_time in ("00:00:00", "0:00:00"): - remove_watchtime( - series_search_result.name, - episode.season, - episode.episode_number, - series_search_result.provider_name - ) - mark_episode_watched( - series_search_result.name, - episode.season, - episode.episode_number, - series_search_result.provider_name - ) - logging.info("Episode als gesehen markiert und Watchtime entfernt.") - else: - save_watchtime( - series_search_result.name, - episode.season, - episode.episode_number, - series_search_result.provider_name, - resume_time - ) - logging.info("Resume-Time gespeichert: %s", resume_time) - finally: - if chapters_file: - try: - remove(chapters_file.name) - except FileNotFoundError: - pass - if self.RPC and self.RPC.sock_writer: - self.app.call_later(self.RPC.clear) - - exit_code = process.poll() - if exit_code is not None: - async def push_next_screen(): - async def play_next(should_next): - if should_next: - self.play( - series_search_result, - episodes, - index + 1, - ) + resume_time = sp[1] + + if resume_time: + logging.info("Resume: %s", resume_time) + + exit_code = process.poll() + + if exit_code is not None: + if chapters_file: + try: + remove(chapters_file.name) + except FileNotFoundError: + pass + if self.RPC and self.RPC.sock_writer: + self.app.call_later(self.RPC.clear) + + async def push_next_screen(): + async def play_next(should_next): + if should_next: + self.play( + series_search_result, + episodes, + index + 1, + ) + + await self.app.push_screen( + Next("Playing next episode in", no_time=is_android), + callback=play_next, + ) - await self.app.push_screen( - Next("Playing next episode in", no_time=is_android), - callback=play_next, - ) + autoplay = gucken_settings_manager.settings["settings"]["autoplay"]["enabled"] + if not len(episodes) <= index + 1: + if autoplay is True: + self.app.call_later(push_next_screen) - autoplay = gucken_settings_manager.settings["settings"]["autoplay"]["enabled"] - if len(episodes) > index + 1 and autoplay is True: - self.app.call_later(push_next_screen) + remove_watchtime( + series_search_result.name, + episode.season, + episode.episode_number, + series_search_result.provider_name + ) + mark_episode_watched( + series_search_result.name, + episode.season, + episode.episode_number, + series_search_result.provider_name + ) + + else: + # TODO: ask to mark as completed + pass + return exit_quotes = [ From 840cb303e6b174c5a469c24ef2150e67edd8a4de Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:00:57 +0200 Subject: [PATCH 15/22] Add popular anime and series fetching functionality --- src/gucken/gucken.py | 120 +++++++++++++++++++++++++++- src/gucken/provider/aniworld.py | 25 +++++- src/gucken/provider/serienstream.py | 23 ++++++ src/gucken/resources/gucken.css | 56 +++++++++++++ 4 files changed, 221 insertions(+), 3 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 03507cd..bc7f7c9 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -21,7 +21,7 @@ from textual import events, on, work from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Center, Container, Horizontal, ScrollableContainer +from textual.containers import Center, Container, Horizontal, Vertical, ScrollableContainer from textual.reactive import reactive from textual.screen import ModalScreen from textual.widgets import ( @@ -112,7 +112,6 @@ def hoster_sort_key(_hoster: str) -> int: return sorted(hoster_list, key=hoster_sort_key) - async def get_working_direct_link(hosters: list[Hoster], app: "GuckenApp") -> Union[DirectLink, None]: for hoster in hosters: name = type(hoster).__name__ @@ -202,6 +201,80 @@ def on_click(self) -> None: self.app.open_info() self.last_click = time() +class PopularContainer(Container): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_loading(True) + self.app.call_later(self.load_popular) + + async def load_popular(self): + self.remove_children() + aniworld_results = await AniWorldProvider.get_popular() + serienstream_results = await SerienStreamProvider.get_popular() + + # AniWorld-Karten + anime_cards = [] + for entry in aniworld_results: + img_url = entry["img"] + img_widget = Image() + async with AsyncClient(verify=False) as client: + response = await client.get(img_url) + img_widget.image = BytesIO(response.content) + card = Container( + Vertical( + img_widget, + Label(entry["name"]), + Label(entry["genre"]), + ), + classes="popular_card" + ) + card.anime_name = entry["name"] + card.anime_provider_name = "aniworld.to" + card._last_click = None + anime_cards.append(card) + + # SerienStream-Karten + serien_cards = [] + for entry in serienstream_results: + img_url = entry["img"] + img_widget = Image() + async with AsyncClient(verify=False) as client: + response = await client.get(img_url) + img_widget.image = BytesIO(response.content) + card = Container( + Vertical( + img_widget, + Label(entry["name"]), + Label(entry["genre"]), + ), + classes="popular_card" + ) + card.anime_name = entry["name"] + card.anime_provider_name = "serienstream.to" + card._last_click = None + serien_cards.append(card) + + # Überschriften und Karten montieren + self.mount( + Label("Anime", classes="popular_title"), + Container(*anime_cards, classes="popular_section"), + Label("Serien", classes="popular_title"), + Container(*serien_cards, classes="popular_section"), + ) + self.set_loading(False) + + def on_click(self, event: events.Click) -> None: + # Finde das nächste Eltern-Widget mit der Klasse "popular_card" + card = event.control + while card and "popular_card" not in getattr(card, "classes", []): + card = getattr(card, "parent", None) + if not card: + return + if "popular_card" in card.classes: + name = getattr(card, "anime_name", None) + provider = getattr(card, "anime_provider_name", None) + if name and provider: + self.app.open_info(name=name, provider=provider) class ClickableDataTable(DataTable): def __init__(self, *args, **kwargs): @@ -388,6 +461,8 @@ def compose(self) -> ComposeResult: yield ClickableDataTable(id="season_list") with TabPane("Watchlist", id="watchlist"): yield ListView(id="watchlist_view") + with TabPane("Popular", id="popular"): + yield PopularContainer(id="popular_container") with TabPane("Settings", id="setting"): # Settings "⚙" # TODO: dont show unneeded on android with ScrollableContainer(id="settings_container"): @@ -453,6 +528,46 @@ def compose(self) -> ComposeResult: with Center(id="footer"): yield Label("Made by Commandcracker with [red]❤[/red]") + @work(exclusive=True, thread=True) + async def load_popular(self): + popular_list_view = self.query_one("#popular_list", ListView) + self.call_from_thread(popular_list_view.clear) + self.call_from_thread(popular_list_view.set_loading, True) + results = await AniWorldProvider.get_popular() + items = [] + for entry in results: + # Bild laden + img_url = f"{entry['img']}" if entry['img'].startswith("/") else entry['img'] + img_widget = Image() + async with AsyncClient(verify=False) as client: + response = await client.get(img_url) + img_widget.image = BytesIO(response.content) + # Karte zusammenbauen + card = ListItem( + Horizontal( + img_widget, + Markdown(f"**{entry['name']}**\n*{entry['genre']}*"), + ) + ) + card.anime_name = entry["name"] + card.anime_provider_name = "aniworld.to" + items.append(card) + self.call_from_thread(popular_list_view.extend, items) + self.call_from_thread(popular_list_view.set_loading, False) + + @on(ListView.Selected, "#popular_list") + async def on_popular_selected(self, event): + item = event.item + # Doppelklick-Erkennung: Zeitstempel am Item speichern + now = time() + last_click = getattr(item, "_last_click", None) + setattr(item, "_last_click", now) + if last_click and now - last_click < 0.5: + name = getattr(item, "anime_name", None) + provider = getattr(item, "anime_provider_name", None) + if name and provider: + await self.open_info(name, provider) + @on(Input.Changed) async def input_changed(self, event: Input.Changed): if event.control.id == "input": @@ -584,6 +699,7 @@ async def on_mount(self) -> None: self.theme = getenv("TEXTUAL_THEME") or gucken_settings_manager.settings["settings"]["ui"]["theme"] self.update_watchlist_view() + self.load_popular() def on_theme_change(old_value: str, new_value: str) -> None: gucken_settings_manager.settings["settings"]["ui"]["theme"] = new_value diff --git a/src/gucken/provider/aniworld.py b/src/gucken/provider/aniworld.py index 1717f07..407d4b9 100644 --- a/src/gucken/provider/aniworld.py +++ b/src/gucken/provider/aniworld.py @@ -249,6 +249,29 @@ async def get_series(search_result: AniWorldSearchResult) -> AniWorldSeries: cover=f"https://{search_result.host}" + soup.find("img", attrs={"itemprop": "image"}).attrs.get("data-src") ) + @staticmethod + async def get_popular() -> list[dict]: + async with AsyncClient(accept_language=AcceptLanguage.DE) as client: + response = await client.get(f"https://{AniWorldProvider.host}/beliebte-animes") + soup = BeautifulSoup(response.text, "html.parser") + container = soup.find("div", class_="seriesListContainer") + results = [] + for entry in container.find_all("div", class_="col-md-15 col-sm-3 col-xs-6"): + a_tag = entry.find("a") + link = a_tag["href"] + img_tag = a_tag.find("img") + img = img_tag.get("data-src") or img_tag.get("src") + name = a_tag.find("h3").text.strip() + genre_tag = a_tag.find("small") + genre = genre_tag.text.strip() if genre_tag else "" + results.append({ + "link": link, + "img": "https://" + AniWorldProvider.host + img, + "name": name, + "genre": genre + }) + return results + async def get_episodes_from_url(staffel: int, url: str) -> list[Episode]: async with AsyncClient(accept_language=AcceptLanguage.DE) as client: @@ -307,4 +330,4 @@ async def get_episodes_from_soup( ) ) - return episodes + return episodes \ No newline at end of file diff --git a/src/gucken/provider/serienstream.py b/src/gucken/provider/serienstream.py index c6ecf13..3596ae4 100644 --- a/src/gucken/provider/serienstream.py +++ b/src/gucken/provider/serienstream.py @@ -258,6 +258,29 @@ async def get_series(search_result: SerienStreamSearchResult) -> SerienStreamSer cover=f"https://{search_result.host}" + soup.find("img", attrs={"itemprop": "image"}).attrs.get("data-src") ) + @staticmethod + async def get_popular() -> list[dict]: + async with AsyncClient(accept_language=AcceptLanguage.DE, verify=False) as client: + response = await client.get(f"http://{SerienStreamProvider.host}/beliebte-serien") + soup = BeautifulSoup(response.text, "html.parser") + container = soup.find("div", class_="seriesListContainer") + results = [] + for entry in container.find_all("div", class_="col-md-15 col-sm-3 col-xs-6"): + a_tag = entry.find("a") + link = a_tag["href"] + img_tag = a_tag.find("img") + img = img_tag.get("data-src") or img_tag.get("src") + name = a_tag.find("h3").text.strip() + genre_tag = a_tag.find("small") + genre = genre_tag.text.strip() if genre_tag else "" + results.append({ + "link": link, + "img": "http://" + SerienStreamProvider.host + img, + "name": name, + "genre": genre + }) + return results + async def get_episodes_from_url(staffel: int, url: str) -> list[Episode]: async with AsyncClient(accept_language=AcceptLanguage.DE) as client: diff --git a/src/gucken/resources/gucken.css b/src/gucken/resources/gucken.css index 380af78..3380e64 100644 --- a/src/gucken/resources/gucken.css +++ b/src/gucken/resources/gucken.css @@ -216,4 +216,60 @@ ScrollableContainer > Horizontal#res_con_2 { min-width: 20; /* gleiche Breite wie das Bild, falls das Bild 20 breit ist */ margin: 1 0; content-align: center middle; +} + +#popular_container { + layout: vertical; + overflow: auto; + margin-bottom: 2; +} + +.popular_title { + height: 2; + width: 100%; + text-align: left; + margin: 1 0 1 0; + color: $accent; + content-align: left middle; +} + +.popular_section { + layout: grid; + grid-size: 8; + grid-rows: auto; + grid-gutter: 1; + margin-bottom: 2; + height: auto; +} + +.popular_card { + height: auto; + max-height: 20; + width: 100%; + margin: 0; + content-align: center top; + layout: vertical; +} + +.popular_card Image { + height: 16; + width: 24; + content-align: center top; + background: $background; +} + +.popular_card Label { + height: 1; + width: 24; + max-width: 100%; + text-align: center; + overflow: hidden; +} + +/* Platzhalter für leere Listen */ +.popular_card.placeholder { + background: $surface; + content-align: center middle; + min-height: 6; + margin: 2 0 2 0; } \ No newline at end of file From 0cf7c1f6aba6249d8ac610d367e544d8d578bbc3 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:01:15 +0200 Subject: [PATCH 16/22] Add popular anime and series fetching functionality --- test/test_request.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/test_request.py diff --git a/test/test_request.py b/test/test_request.py new file mode 100644 index 0000000..2a96896 --- /dev/null +++ b/test/test_request.py @@ -0,0 +1,21 @@ +import asyncio +import sys +import os + +# Füge das src-Verzeichnis zum sys.path hinzu, damit die Imports funktionieren +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +from gucken.provider.aniworld import AniWorldProvider + +async def test_get_popular(): + results = await AniWorldProvider.get_popular() + assert isinstance(results, list), "Ergebnis ist keine Liste" + assert len(results) > 0, "Keine populären Animes gefunden" + for entry in results: + assert "name" in entry, "Feld 'name' fehlt" + assert "img" in entry, "Feld 'img' fehlt" + assert "link" in entry, "Feld 'link' fehlt" + print(entry) + +if __name__ == "__main__": + asyncio.run(test_get_popular()) \ No newline at end of file From a12abb2c654cff73fe98a6e807488b2b86513fef Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:16:37 +0200 Subject: [PATCH 17/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 61 ++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index bc7f7c9..efeb0f4 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -910,38 +910,36 @@ async def open_info(self, name=None, provider=None) -> None: # Falls name und provider übergeben werden, suche das passende SearchResult if name and provider: - # Suche in self.current - series_search_result = None - if self.current: - for s in self.current: - if s.name == name and s.provider_name == provider: - series_search_result = s - break - # Falls nicht gefunden, suche per Provider - if not series_search_result: - results = await gather(self.aniworld_search(name), self.serienstream_search(name)) - for result_list in results: - if result_list: - for s in result_list: - if s.name == name and s.provider_name == provider: - series_search_result = s - break - if series_search_result: - break - if not series_search_result: + # Suche das passende SearchResult über beide Provider + search_results = [] + if provider == "aniworld.to": + search_results = await self.aniworld_search(name) + elif provider == "serienstream.to": + search_results = await self.serienstream_search(name) + if not search_results: return - # Setze self.current auf das gefundene Ergebnis, damit alles wie gewohnt funktioniert - self.current = [series_search_result] - index = 0 + # Nimm das beste Ergebnis + series_search_result = search_results[0] + # Setze self.current und aktualisiere die ListView + self.current = search_results + results_list_view = self.query_one("#results", ListView) + items = [] + for series in search_results: + items.append(ClickableListItem( + Markdown( + f"##### {series.name} {getattr(series, 'production_year', '')} [{series.provider_name}]" + f"\n{series.description}" + ) + )) + results_list_view.clear() + results_list_view.extend(items) + results_list_view.index = 0 else: index = self.app.query_one("#results", ListView).index if index is None or not self.current or index >= len(self.current): return - series_search_result: SearchResult = self.current[ - self.app.query_one("#results", ListView).index - ] + series_search_result: SearchResult = self.current[index] - # Rest wie gehabt ... if is_in_watchlist(series_search_result): watchlist_btn.label = "Aus Watchlist entfernen" watchlist_btn.variant = "error" @@ -1007,13 +1005,6 @@ async def open_info(self, name=None, provider=None) -> None: else: season_display = ep.season - watched = is_episode_watched( - series_search_result.name, - ep.season, - ep.episode_number, - series_search_result.provider_name - ) - table.add_row( c, season_display, @@ -1134,10 +1125,12 @@ async def play( args = _player.play(direct_link.url, title, fullscreen, direct_link.headers) if self.RPC and self.RPC.sock_writer: + max_length = 128 + large_text = title[:max_length] async def update(): await self.RPC.update( details=title[:128], - large_text=title, + large_text=large_text, large_image=series_search_result.cover, ) From 17e927bd3a19d14999e7e2449537a29e3026be35 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:34:40 +0200 Subject: [PATCH 18/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index efeb0f4..cb170ce 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -75,6 +75,7 @@ from .utils import detect_player, is_android, set_default_vlc_interface_cfg, get_vlc_intf_user_path from .networking import AsyncClient from . import __version__ +import asyncio def sort_favorite_lang( @@ -207,19 +208,24 @@ def __init__(self, *args, **kwargs): self.set_loading(True) self.app.call_later(self.load_popular) + import asyncio + async def load_popular(self): self.remove_children() aniworld_results = await AniWorldProvider.get_popular() serienstream_results = await SerienStreamProvider.get_popular() + async def load_image(img_widget, url): + async with AsyncClient(verify=False) as client: + response = await client.get(url) + img_widget.image = BytesIO(response.content) + # AniWorld-Karten anime_cards = [] + anime_image_tasks = [] for entry in aniworld_results: img_url = entry["img"] - img_widget = Image() - async with AsyncClient(verify=False) as client: - response = await client.get(img_url) - img_widget.image = BytesIO(response.content) + img_widget = Image() # Platzhalter card = Container( Vertical( img_widget, @@ -232,15 +238,14 @@ async def load_popular(self): card.anime_provider_name = "aniworld.to" card._last_click = None anime_cards.append(card) + anime_image_tasks.append(load_image(img_widget, img_url)) # SerienStream-Karten serien_cards = [] + serien_image_tasks = [] for entry in serienstream_results: img_url = entry["img"] - img_widget = Image() - async with AsyncClient(verify=False) as client: - response = await client.get(img_url) - img_widget.image = BytesIO(response.content) + img_widget = Image() # Platzhalter card = Container( Vertical( img_widget, @@ -253,6 +258,7 @@ async def load_popular(self): card.anime_provider_name = "serienstream.to" card._last_click = None serien_cards.append(card) + serien_image_tasks.append(load_image(img_widget, img_url)) # Überschriften und Karten montieren self.mount( @@ -261,6 +267,9 @@ async def load_popular(self): Label("Serien", classes="popular_title"), Container(*serien_cards, classes="popular_section"), ) + + # Bilder parallel laden, UI bleibt responsiv + await asyncio.gather(*anime_image_tasks, *serien_image_tasks) self.set_loading(False) def on_click(self, event: events.Click) -> None: From df4a8d974fe36fa3fcd691492a1b1f85f367777e Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:55:07 +0200 Subject: [PATCH 19/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 64 +++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index cb170ce..52fb676 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -206,26 +206,26 @@ class PopularContainer(Container): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.set_loading(True) - self.app.call_later(self.load_popular) + self.app.call_later(lambda: self.run_worker(self.load_popular, exclusive=True, thread=True)) - import asyncio - - async def load_popular(self): - self.remove_children() + async def load_popular(self, worker=None): + self.app.call_from_thread(self.remove_children) aniworld_results = await AniWorldProvider.get_popular() serienstream_results = await SerienStreamProvider.get_popular() + anime_cards = [] + serien_cards = [] + image_tasks = [] + + # Hilfsfunktion für das Nachladen der Bilder async def load_image(img_widget, url): async with AsyncClient(verify=False) as client: response = await client.get(url) - img_widget.image = BytesIO(response.content) + self.app.call_from_thread(lambda: setattr(img_widget, "image", BytesIO(response.content))) - # AniWorld-Karten - anime_cards = [] - anime_image_tasks = [] + # Karten ohne Bilder sofort bauen for entry in aniworld_results: - img_url = entry["img"] - img_widget = Image() # Platzhalter + img_widget = Image() card = Container( Vertical( img_widget, @@ -238,14 +238,10 @@ async def load_image(img_widget, url): card.anime_provider_name = "aniworld.to" card._last_click = None anime_cards.append(card) - anime_image_tasks.append(load_image(img_widget, img_url)) + image_tasks.append(load_image(img_widget, entry["img"])) - # SerienStream-Karten - serien_cards = [] - serien_image_tasks = [] for entry in serienstream_results: - img_url = entry["img"] - img_widget = Image() # Platzhalter + img_widget = Image() card = Container( Vertical( img_widget, @@ -258,22 +254,22 @@ async def load_image(img_widget, url): card.anime_provider_name = "serienstream.to" card._last_click = None serien_cards.append(card) - serien_image_tasks.append(load_image(img_widget, img_url)) - - # Überschriften und Karten montieren - self.mount( - Label("Anime", classes="popular_title"), - Container(*anime_cards, classes="popular_section"), - Label("Serien", classes="popular_title"), - Container(*serien_cards, classes="popular_section"), - ) + image_tasks.append(load_image(img_widget, entry["img"])) + + def mount_cards(): + self.mount( + Label("Anime", classes="popular_title"), + Container(*anime_cards, classes="popular_section"), + Label("Serien", classes="popular_title"), + Container(*serien_cards, classes="popular_section"), + ) + self.app.call_from_thread(mount_cards) - # Bilder parallel laden, UI bleibt responsiv - await asyncio.gather(*anime_image_tasks, *serien_image_tasks) - self.set_loading(False) + # Bilder im Hintergrund nachladen, UI bleibt sofort nutzbar + await asyncio.gather(*image_tasks) + self.app.call_from_thread(lambda: self.set_loading(False)) def on_click(self, event: events.Click) -> None: - # Finde das nächste Eltern-Widget mit der Klasse "popular_card" card = event.control while card and "popular_card" not in getattr(card, "classes", []): card = getattr(card, "parent", None) @@ -703,12 +699,18 @@ def select_changed(self, event: Select.Changed) -> None: else: settings["player"]["player"] = event.value + @on(TabbedContent.TabActivated) + async def on_tab_activated(self, event): + if event.tab.id == "popular": # Passe die ID an dein Tab an + if not hasattr(self, "_popular_loaded") or not self._popular_loaded: + await self.load_popular() + self._popular_loaded = True + # TODO: dont lock - no async async def on_mount(self) -> None: self.theme = getenv("TEXTUAL_THEME") or gucken_settings_manager.settings["settings"]["ui"]["theme"] self.update_watchlist_view() - self.load_popular() def on_theme_change(old_value: str, new_value: str) -> None: gucken_settings_manager.settings["settings"]["ui"]["theme"] = new_value From 40793224ad684ede92d9bb06e0fd732dcae380a2 Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 21:57:36 +0200 Subject: [PATCH 20/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 52fb676..e4e98c2 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -216,12 +216,21 @@ async def load_popular(self, worker=None): anime_cards = [] serien_cards = [] image_tasks = [] + semaphore = asyncio.Semaphore(20) # Maximal 20 Bilder gleichzeitig laden + + # Gemeinsamer HTTP-Client für alle Requests + client = AsyncClient(verify=False) + image_cache = {} - # Hilfsfunktion für das Nachladen der Bilder async def load_image(img_widget, url): - async with AsyncClient(verify=False) as client: - response = await client.get(url) - self.app.call_from_thread(lambda: setattr(img_widget, "image", BytesIO(response.content))) + async with semaphore: + if url in image_cache: + img_data = image_cache[url] + else: + response = await client.get(url) + img_data = BytesIO(response.content) + image_cache[url] = img_data + self.app.call_from_thread(lambda: setattr(img_widget, "image", img_data)) # Karten ohne Bilder sofort bauen for entry in aniworld_results: @@ -267,6 +276,7 @@ def mount_cards(): # Bilder im Hintergrund nachladen, UI bleibt sofort nutzbar await asyncio.gather(*image_tasks) + await client.aclose() self.app.call_from_thread(lambda: self.set_loading(False)) def on_click(self, event: events.Click) -> None: From 0ee2f2e92309d4d95c83ec64d5a97c313869581c Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 22:29:08 +0200 Subject: [PATCH 21/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 9 +++++++++ src/gucken/resources/gucken.css | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index e4e98c2..41547c4 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -690,6 +690,15 @@ async def radio_button_changed(self, event: RadioButton.Changed): img: Image = self.query_one("#image", Image) img.image = None + if id == "image_display": + img: Image = self.query_one("#image", Image) + btn: Button = self.query_one("#watchlist_btn", Button) + if event.value is False: + img.image = None + btn.add_class("no_image") + else: + btn.remove_class("no_image") + settings[id] = event.value if id == "discord_presence": diff --git a/src/gucken/resources/gucken.css b/src/gucken/resources/gucken.css index 3380e64..e5dab10 100644 --- a/src/gucken/resources/gucken.css +++ b/src/gucken/resources/gucken.css @@ -218,6 +218,11 @@ ScrollableContainer > Horizontal#res_con_2 { content-align: center middle; } +#watchlist_btn.no_image { + width: 100%; + min-width: 0; +} + #popular_container { layout: vertical; overflow: auto; From c1bbee09bc9cea6cdebbf23993c467eea47d85bf Mon Sep 17 00:00:00 2001 From: FundyJo <76965020+FundyJo@users.noreply.github.com> Date: Mon, 9 Jun 2025 22:31:18 +0200 Subject: [PATCH 22/22] Refactor search result handling for anime and series providers --- src/gucken/gucken.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 41547c4..f03aba0 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -938,6 +938,11 @@ async def get_series(self, series_search_result: SearchResult): async def open_info(self, name=None, provider=None) -> None: watchlist_btn = self.query_one("#watchlist_btn", Button) + if not gucken_settings_manager.settings["settings"]["image_display"]: + watchlist_btn.add_class("no_image") + else: + watchlist_btn.remove_class("no_image") + # Falls name und provider übergeben werden, suche das passende SearchResult if name and provider: # Suche das passende SearchResult über beide Provider