M4 polish — search history + cacheToDisc + 0-is-falsy fixes

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.
This commit is contained in:
Kayos 2026-05-23 12:30:44 -07:00
parent 83bc6dfa03
commit f1c7264e75
2 changed files with 125 additions and 8 deletions

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.torttube" <addon id="plugin.video.torttube"
name="torttube" name="torttube"
version="0.0.13" version="0.0.14"
provider-name="Sulkta-Coop"> provider-name="Sulkta-Coop">
<requires> <requires>
<import addon="xbmc.python" version="3.0.0"/> <import addon="xbmc.python" version="3.0.0"/>

View file

@ -634,8 +634,9 @@ def _plugin_url(**kwargs: Any) -> str:
def _format_duration(seconds: int | float | None) -> str: def _format_duration(seconds: int | float | None) -> str:
"""Format seconds as `H:MM:SS` or `M:SS`.""" """Format seconds as `H:MM:SS` or `M:SS`. None / negative → empty;
if not seconds: 0 is a legitimate duration (livestream pre-roll) so we render '0:00'."""
if seconds is None or seconds < 0:
return "" return ""
s = int(seconds) s = int(seconds)
h, rem = divmod(s, 3600) h, rem = divmod(s, 3600)
@ -646,8 +647,9 @@ def _format_duration(seconds: int | float | None) -> str:
def _format_views(views: int | None) -> str: def _format_views(views: int | None) -> str:
"""Format view count compactly: 1234 → '1.2K', 1234567 → '1.2M'.""" """Format view count compactly: 1234 → '1.2K', 1234567 → '1.2M'. None →
if not views: empty; 0 is rendered as '0' (brand-new upload)."""
if views is None or views < 0:
return "" return ""
if views >= 1_000_000_000: if views >= 1_000_000_000:
return f"{views / 1_000_000_000:.1f}B" return f"{views / 1_000_000_000:.1f}B"
@ -658,6 +660,59 @@ def _format_views(views: int | None) -> str:
return str(views) 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: def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str:
"""Pick the largest thumbnail from rustypipe's thumbnail list.""" """Pick the largest thumbnail from rustypipe's thumbnail list."""
if not thumbs: if not thumbs:
@ -670,15 +725,70 @@ def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str:
def _root_directory() -> None: def _root_directory() -> None:
"""Top-level addon menu. Search lands first because that's how most TV-side """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.""" 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")), ("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")),
("Play by URL", "DefaultAddonNone.png", _plugin_url(action="play_by_url")), ("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: for label, icon, url in items:
li = xbmcgui.ListItem(label=label) li = xbmcgui.ListItem(label=label)
li.setArt({"icon": icon}) li.setArt({"icon": icon})
xbmcplugin.addDirectoryItem(_HANDLE, url, li, isFolder=True) 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: 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 [] items = resp.get("items") or []
_log(f"search '{query}'{len(items)} items") _log(f"search '{query}'{len(items)} items")
_record_search(query)
_add_video_items(items) _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: def _playlist_directory(playlist_id: str) -> None:
@ -884,6 +997,10 @@ def main() -> None:
_playlist_directory(params.get("id") or "") _playlist_directory(params.get("id") or "")
elif action == "play_by_url": elif action == "play_by_url":
_play_by_url_prompt() _play_by_url_prompt()
elif action == "recent":
_recent_directory()
elif action == "clear_history":
_clear_history_action()
else: else:
_root_directory() _root_directory()