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"?>
|
<?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"/>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue