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:
parent
83bc6dfa03
commit
f1c7264e75
2 changed files with 125 additions and 8 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.video.torttube"
|
||||
name="torttube"
|
||||
version="0.0.13"
|
||||
version="0.0.14"
|
||||
provider-name="Sulkta-Coop">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0"/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue