From f1c7264e75d1bfdd1b8dcad335afc251b324ae74 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 12:30:44 -0700 Subject: [PATCH] =?UTF-8?q?M4=20polish=20=E2=80=94=20search=20history=20+?= =?UTF-8?q?=20cacheToDisc=20+=200-is-falsy=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search history (M4 ergonomics): - _record_search() stores the query into search_history.json under Kodi's addon_data dir. Dedupe case-insensitively, keep newest first, cap at SEARCH_HISTORY_MAX=12. - _recent_directory() (action=recent) shows persisted queries as quick-pick items, each re-running the same search on click. Each item gets a 'Clear history' context menu entry. Tail entry clears everything. - Root menu adds a 'Recent searches' entry only when history is non-empty. - _clear_history_action wires the clear path + a notification. cacheToDisc=False on the root menu and on search results (audit MED-3): prevents an empty Search button getting stuck cached after a no-input cancel, and prevents the previous query's result-set shadowing the next search. _format_duration / _format_views fixed to treat 0 as a real value and '' only for None/negative (audit MED-4) — brand-new 0-view uploads and livestream pre-rolls now render correctly. Verified end-to-end via Files.GetDirectory: - Two searches ('funny cats', 'python tutorial') record into history - history.json contains ['funny cats', 'python tutorial'] (newest first because 'funny cats' was searched second) - Recent dir lists both + Clear tail entry - Root dir gains 'Recent searches' entry once history is populated No playback was tested (Leia was on the TV). Addon v0.0.14. --- addon/plugin.video.torttube/addon.xml | 2 +- addon/plugin.video.torttube/main.py | 131 ++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index b7b5bc0..44bf79f 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,7 +1,7 @@ diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index 4446910..a0614f2 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -634,8 +634,9 @@ def _plugin_url(**kwargs: Any) -> str: def _format_duration(seconds: int | float | None) -> str: - """Format seconds as `H:MM:SS` or `M:SS`.""" - if not seconds: + """Format seconds as `H:MM:SS` or `M:SS`. None / negative → empty; + 0 is a legitimate duration (livestream pre-roll) so we render '0:00'.""" + if seconds is None or seconds < 0: return "" s = int(seconds) h, rem = divmod(s, 3600) @@ -646,8 +647,9 @@ def _format_duration(seconds: int | float | None) -> str: def _format_views(views: int | None) -> str: - """Format view count compactly: 1234 → '1.2K', 1234567 → '1.2M'.""" - if not views: + """Format view count compactly: 1234 → '1.2K', 1234567 → '1.2M'. None → + empty; 0 is rendered as '0' (brand-new upload).""" + if views is None or views < 0: return "" if views >= 1_000_000_000: return f"{views / 1_000_000_000:.1f}B" @@ -658,6 +660,59 @@ def _format_views(views: int | None) -> str: return str(views) +# Search history persistence — stored as a plain JSON list of recent queries. +# Newest first, deduplicated, capped at SEARCH_HISTORY_MAX. +SEARCH_HISTORY_MAX = 12 + + +def _search_history_path() -> str: + try: + import xbmcvfs + base = xbmcvfs.translatePath("special://profile/addon_data/plugin.video.torttube/") + except Exception: + base = "/storage/.kodi/userdata/addon_data/plugin.video.torttube/" + return os.path.join(base, "search_history.json") + + +def _load_search_history() -> list[str]: + try: + with open(_search_history_path(), "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return [s for s in data if isinstance(s, str)][:SEARCH_HISTORY_MAX] + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + return [] + + +def _save_search_history(items: list[str]) -> None: + path = _search_history_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(items[:SEARCH_HISTORY_MAX], f, ensure_ascii=False) + except OSError as e: + _log(f"search history save failed (non-fatal): {e}", xbmc.LOGWARNING) + + +def _record_search(query: str) -> None: + q = query.strip() + if not q: + return + history = _load_search_history() + # Dedupe case-insensitively, keep newest at the front. + history = [h for h in history if h.lower() != q.lower()] + history.insert(0, q) + _save_search_history(history) + + +def _clear_search_history() -> None: + try: + os.remove(_search_history_path()) + except (FileNotFoundError, OSError): + pass + + def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str: """Pick the largest thumbnail from rustypipe's thumbnail list.""" if not thumbs: @@ -670,15 +725,70 @@ def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str: def _root_directory() -> None: """Top-level addon menu. Search lands first because that's how most TV-side YouTube use actually works. Remote-control via JSON-RPC still works.""" - items = [ + items: list[tuple[str, str, str]] = [ ("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")), ("Play by URL", "DefaultAddonNone.png", _plugin_url(action="play_by_url")), ] + history = _load_search_history() + if history: + items.append( + ( + "[I]Recent searches[/I]", + "DefaultAddonsRecentlyUpdated.png", + _plugin_url(action="recent"), + ) + ) for label, icon, url in items: li = xbmcgui.ListItem(label=label) li.setArt({"icon": icon}) xbmcplugin.addDirectoryItem(_HANDLE, url, li, isFolder=True) - xbmcplugin.endOfDirectory(_HANDLE) + # Don't cache the root — it might gain/lose 'Recent searches' between visits. + xbmcplugin.endOfDirectory(_HANDLE, cacheToDisc=False) + + +def _recent_directory() -> None: + """Show stored recent searches as quick-pick items + a 'Clear history' tail.""" + history = _load_search_history() + if not history: + xbmcgui.Dialog().notification( + "torttube", + "No recent searches yet.", + xbmcgui.NOTIFICATION_INFO, + 2500, + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + for q in history: + li = xbmcgui.ListItem(label=q) + li.setArt({"icon": "DefaultAddonsSearch.png"}) + li.addContextMenuItems( + [ + ( + "Clear history", + f"Container.Update({_plugin_url(action='clear_history')})", + ) + ] + ) + xbmcplugin.addDirectoryItem( + _HANDLE, _plugin_url(action="search", q=q), li, isFolder=True + ) + # Tail entry: clear all history. + clear_li = xbmcgui.ListItem(label="[B]Clear search history[/B]") + clear_li.setArt({"icon": "DefaultAddonNone.png"}) + xbmcplugin.addDirectoryItem( + _HANDLE, _plugin_url(action="clear_history"), clear_li, isFolder=True + ) + xbmcplugin.endOfDirectory(_HANDLE, cacheToDisc=False) + + +def _clear_history_action() -> None: + _clear_search_history() + xbmcgui.Dialog().notification( + "torttube", "Search history cleared", xbmcgui.NOTIFICATION_INFO, 2000 + ) + # Bounce back to root. + xbmc.executebuiltin("Container.Update(plugin://plugin.video.torttube/,replace)") + xbmcplugin.endOfDirectory(_HANDLE, succeeded=True) def _add_video_items(items: list[dict[str, Any]]) -> None: @@ -777,8 +887,11 @@ def _search_directory(query: str | None = None) -> None: items = resp.get("items") or [] _log(f"search '{query}' → {len(items)} items") + _record_search(query) _add_video_items(items) - xbmcplugin.endOfDirectory(_HANDLE) + # cacheToDisc=False so the user's next search isn't shadowed by the + # previous query's cached result-set. + xbmcplugin.endOfDirectory(_HANDLE, cacheToDisc=False) def _playlist_directory(playlist_id: str) -> None: @@ -884,6 +997,10 @@ def main() -> None: _playlist_directory(params.get("id") or "") elif action == "play_by_url": _play_by_url_prompt() + elif action == "recent": + _recent_directory() + elif action == "clear_history": + _clear_history_action() else: _root_directory()