diff --git a/plugin.video.ciscolive/LICENSE b/plugin.video.ciscolive/LICENSE
new file mode 100644
index 000000000..b95aef4e5
--- /dev/null
+++ b/plugin.video.ciscolive/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Nox
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugin.video.ciscolive/README.md b/plugin.video.ciscolive/README.md
new file mode 100644
index 000000000..a86eb4143
--- /dev/null
+++ b/plugin.video.ciscolive/README.md
@@ -0,0 +1,34 @@
+# Cisco Live On-Demand - Kodi Video Plugin
+
+Browse and stream Cisco Live on-demand session recordings directly in Kodi.
+
+## Features
+
+- **6,500+ sessions** from Cisco Live events (2022-2026), plus 8,400+ legacy sessions (2018-2021)
+- **No account required** - all videos are freely accessible
+- **Browse by Event** with per-event session counts
+- **Multi-select filtering** by Technology, Technical Level, and keyword search within events
+- **Browse by Category** (Technology, Technical Level)
+- **Full-text search** across all sessions
+- **Watch history** tracking
+- **Wall view** optimized for TV remote navigation
+
+## Requirements
+
+- Kodi 21 (Omega) or later
+- inputstream.adaptive (for HLS/DASH streams)
+
+## Installation
+
+1. Download the latest release zip
+2. In Kodi: Settings > Add-ons > Install from zip file
+3. Or extract to `~/.kodi/addons/plugin.video.ciscolive/`
+
+## Architecture
+
+- **RainFocus API** for session catalog (sections-based event browsing, server-side filtering)
+- **Brightcove** for video resolution and HLS/DASH/MP4 streaming
+
+## License
+
+MIT
diff --git a/plugin.video.ciscolive/addon.py b/plugin.video.ciscolive/addon.py
new file mode 100644
index 000000000..d76865d93
--- /dev/null
+++ b/plugin.video.ciscolive/addon.py
@@ -0,0 +1,969 @@
+"""
+Cisco Live On-Demand - Kodi Video Plugin v2.0
+
+Browse and stream Cisco Live on-demand sessions with a TV-optimized UX.
+Features: in-category search, combined filters, watch history, smart sorting.
+"""
+
+import sys
+import os
+import time
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "resources", "lib"))
+
+try:
+ from urllib.parse import parse_qsl, urlencode, quote_plus
+except ImportError:
+ from urlparse import parse_qsl
+ from urllib import urlencode, quote_plus
+
+import xbmcgui
+import xbmcplugin
+import xbmcaddon
+import xbmc
+import xbmcvfs
+
+from resources.lib import rainfocus
+from resources.lib import brightcove
+from resources.lib import history
+
+ADDON = xbmcaddon.Addon()
+ADDON_URL = sys.argv[0]
+ADDON_HANDLE = int(sys.argv[1])
+ADDON_ARGS = sys.argv[2]
+
+# Settings helpers
+def _setting_bool(key, default=False):
+ try:
+ return ADDON.getSettingBool(key)
+ except Exception:
+ return default
+
+def _setting_int(key, default=0):
+ try:
+ return ADDON.getSettingInt(key)
+ except Exception:
+ return default
+
+SHOW_NO_VIDEO = _setting_bool("show_no_video", False)
+SHOW_CODES = _setting_bool("show_session_codes", False)
+
+
+def build_url(action, **kwargs):
+ params = {"action": action}
+ params.update(kwargs)
+ return "{}?{}".format(ADDON_URL, urlencode(params))
+
+
+def get_params():
+ return dict(parse_qsl(ADDON_ARGS.lstrip("?")))
+
+
+# ---------------------------------------------------------------------------
+# Level color badges
+# ---------------------------------------------------------------------------
+
+LEVEL_COLORS = {
+ "Introductory": ("green", "[COLOR green]\u25cf[/COLOR]"),
+ "Intermediate": ("yellow", "[COLOR yellow]\u25cf[/COLOR]"),
+ "Advanced": ("orange", "[COLOR orange]\u25cf[/COLOR]"),
+ "Expert": ("red", "[COLOR red]\u25cf[/COLOR]"),
+ "General": ("white", "[COLOR white]\u25cf[/COLOR]"),
+}
+
+
+def _level_badge(level):
+ entry = LEVEL_COLORS.get(level)
+ if entry:
+ return "{} {}".format(entry[1], level)
+ return level
+
+
+# ---------------------------------------------------------------------------
+# Main Menu (content-first)
+# ---------------------------------------------------------------------------
+
+def main_menu():
+ xbmcplugin.setContent(ADDON_HANDLE, "videos")
+
+ items = [
+ ("New Releases", build_url("new_releases"), "DefaultRecentlyAddedVideos.png"),
+ ("Browse by Event", build_url("events"), "DefaultVideoPlaylists.png"),
+ ("Browse by Category", build_url("categories"), "DefaultGenre.png"),
+ ("Search All Sessions", build_url("search"), "DefaultAddonsSearch.png"),
+ ("Continue Watching", build_url("history"), "DefaultRecentlyAddedVideos.png"),
+ ("About", build_url("about"), "DefaultAddonHelper.png"),
+ ]
+ for label, url, icon in items:
+ li = xbmcgui.ListItem(label)
+ li.setArt({"icon": icon})
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+# ---------------------------------------------------------------------------
+# New Releases
+# ---------------------------------------------------------------------------
+
+def show_new_releases():
+ show_session_list(filter_key="search.featuredsessions", filter_val="New_Releases")
+
+
+# ---------------------------------------------------------------------------
+# Browse by Event / Technology / Level / Session Type
+# ---------------------------------------------------------------------------
+
+def show_events():
+ # Try dynamic discovery first, fall back to hardcoded
+ events = rainfocus.discover_event_sections()
+ if not events:
+ events = rainfocus.get_events()
+
+ for ev in events:
+ name = ev["name"]
+ total = ev.get("total", "")
+ catalog = ev.get("catalog", "current")
+ section_id = ev.get("section_id", "")
+ label = "{} ({})".format(name, total) if total else name
+ li = xbmcgui.ListItem(label)
+ li.setArt({"icon": "DefaultVideoPlaylists.png"})
+ if section_id:
+ # Current catalog: use section-based browsing
+ url = build_url("event_section", section_id=section_id,
+ event_name=name, filter_label=name)
+ else:
+ # Legacy catalog: use text search + client-side filter
+ url = build_url("list", search_text=name,
+ event_name=name, filter_label=name,
+ catalog="legacy")
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+def show_technologies():
+ for t in rainfocus.get_technologies():
+ li = xbmcgui.ListItem(t["name"])
+ li.setArt({"icon": "DefaultGenre.png"})
+ url = build_url("list", filter_key="search.technology", filter_val=t["id"],
+ filter_label=t["name"])
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+def show_event_section(section_id, event_name="", page=0,
+ tech_filter="", level_filter="", keyword=""):
+ """Browse sessions from a specific event section (current catalog).
+
+ tech_filter and level_filter can be comma-separated lists of IDs
+ for multi-select filtering (e.g. "scpsSkillLevel_cadvanced,scpsSkillLevel_bintermediate").
+ """
+ xbmcplugin.setContent(ADDON_HANDLE, "videos")
+
+ # Parse multi-value filters
+ tech_ids = [t for t in tech_filter.split(",") if t]
+ level_ids = [l for l in level_filter.split(",") if l]
+
+ # Build common nav params for filter/pagination URLs
+ nav_base = {"section_id": section_id, "event_name": event_name}
+ if tech_filter:
+ nav_base["tech_filter"] = tech_filter
+ if level_filter:
+ nav_base["level_filter"] = level_filter
+ if keyword:
+ nav_base["keyword"] = keyword
+
+ # -- Sticky filter bar at top (page 0 only) --
+ if page == 0:
+ has_any_filter = bool(tech_ids or level_ids or keyword)
+
+ # Active filter summary + clear option
+ if has_any_filter:
+ parts = []
+ if tech_ids:
+ tech_lookup = {t["id"]: t["name"] for t in rainfocus.get_technologies()}
+ names = [tech_lookup.get(tid, tid) for tid in tech_ids]
+ parts.append("Tech: " + ", ".join(names))
+ if level_ids:
+ level_lookup = {lv["id"]: lv["name"] for lv in rainfocus.get_levels()}
+ names = [level_lookup.get(lid, lid) for lid in level_ids]
+ parts.append("Level: " + ", ".join(names))
+ if keyword:
+ parts.append('"{}"'.format(keyword))
+
+ clear_label = "[COLOR red]X Clear filter: {}[/COLOR]".format(
+ " + ".join(parts))
+ li = xbmcgui.ListItem(clear_label)
+ li.setArt({"icon": "DefaultIconError.png"})
+ url = build_url("event_section", section_id=section_id,
+ event_name=event_name)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # Technology filter (show current selection or "Filter by")
+ tech_label = "[COLOR cyan]Filter by Technology[/COLOR]"
+ if tech_ids:
+ tech_lookup = {t["id"]: t["name"] for t in rainfocus.get_technologies()}
+ names = [tech_lookup.get(tid, tid) for tid in tech_ids]
+ tech_label = "[COLOR cyan]Technology: {} (change)[/COLOR]".format(
+ ", ".join(names))
+ li = xbmcgui.ListItem(tech_label)
+ li.setArt({"icon": "DefaultGenre.png"})
+ url = build_url("event_filter_pick", filter_type="technology",
+ **nav_base)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # Level filter
+ level_label = "[COLOR cyan]Filter by Technical Level[/COLOR]"
+ if level_ids:
+ level_lookup = {lv["id"]: lv["name"] for lv in rainfocus.get_levels()}
+ names = [level_lookup.get(lid, lid) for lid in level_ids]
+ level_label = "[COLOR cyan]Level: {} (change)[/COLOR]".format(
+ ", ".join(names))
+ li = xbmcgui.ListItem(level_label)
+ li.setArt({"icon": "DefaultProgram.png"})
+ url = build_url("event_filter_pick", filter_type="level",
+ **nav_base)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # Keyword search
+ kw_label = "[COLOR cyan]Search within this event[/COLOR]"
+ if keyword:
+ kw_label = '[COLOR cyan]Search: "{}" (change)[/COLOR]'.format(keyword)
+ li = xbmcgui.ListItem(kw_label)
+ li.setArt({"icon": "DefaultAddonsSearch.png"})
+ url = build_url("event_keyword_search", **nav_base)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # -- Build API filters (repeated keys for multi-value) --
+ filters = {}
+ if tech_ids:
+ filters["search.technology"] = tech_ids
+ if level_ids:
+ filters["search.technicallevel"] = level_ids
+ if keyword:
+ filters["search"] = keyword
+
+ result = rainfocus.search_event_sessions(
+ section_id=section_id, page=page, page_size=rainfocus.PAGE_SIZE,
+ event_name=event_name, filters=filters)
+ items = result.get("items", [])
+ total = result.get("total", 0)
+
+ if not items and page == 0:
+ xbmcgui.Dialog().notification(
+ "Cisco Live", "No sessions found", xbmcgui.NOTIFICATION_INFO)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+ return
+
+ for item in items:
+ _add_session_item(item)
+
+ # Pagination
+ next_offset = (page + 1) * rainfocus.PAGE_SIZE
+ effective_total = min(total, rainfocus.MAX_RESULTS)
+ if next_offset < effective_total:
+ total_pages = (effective_total + rainfocus.PAGE_SIZE - 1) // rainfocus.PAGE_SIZE
+ current_page = page + 1
+ pg_label = "More sessions... (page {} of {})".format(
+ current_page + 1, total_pages)
+ li = xbmcgui.ListItem("[COLOR yellow]{}[/COLOR]".format(pg_label))
+ li.setArt({"icon": "DefaultFolderBack.png"})
+ pg_params = dict(nav_base)
+ pg_params["page"] = str(page + 1)
+ url = build_url("event_section", **pg_params)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ xbmcplugin.addSortMethod(ADDON_HANDLE, xbmcplugin.SORT_METHOD_UNSORTED)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+ xbmc.executebuiltin("Container.SetViewMode(500)")
+
+
+def _event_filter_pick(filter_type, section_id, event_name,
+ tech_filter="", level_filter="", keyword=""):
+ """Show a multiselect dialog to pick technology or level filters."""
+ dialog = xbmcgui.Dialog()
+
+ if filter_type == "technology":
+ options = rainfocus.get_technologies()
+ title = "Select Technologies (multi-select)"
+ current_ids = [t for t in tech_filter.split(",") if t]
+ elif filter_type == "level":
+ options = rainfocus.get_levels()
+ title = "Select Technical Levels (multi-select)"
+ current_ids = [l for l in level_filter.split(",") if l]
+ else:
+ return
+
+ names = [o["name"] for o in options]
+
+ # Pre-select currently active filters
+ preselect = []
+ for i, o in enumerate(options):
+ if o["id"] in current_ids:
+ preselect.append(i)
+
+ selected = dialog.multiselect(title, names, preselect=preselect)
+ if selected is None:
+ return # Cancelled
+
+ # Build new filter value (comma-separated IDs)
+ new_ids = ",".join(options[i]["id"] for i in selected)
+
+ if filter_type == "technology":
+ tech_filter = new_ids
+ elif filter_type == "level":
+ level_filter = new_ids
+
+ show_event_section(section_id=section_id, event_name=event_name,
+ page=0, tech_filter=tech_filter,
+ level_filter=level_filter, keyword=keyword)
+
+
+def _event_keyword_search(section_id, event_name,
+ tech_filter="", level_filter="", keyword=""):
+ """Prompt for a keyword search within an event."""
+ kb = xbmc.Keyboard(keyword, "Search within {}".format(event_name or "event"))
+ kb.doModal()
+ if not kb.isConfirmed():
+ return
+ query = kb.getText().strip()
+
+ show_event_section(section_id=section_id, event_name=event_name,
+ page=0, tech_filter=tech_filter,
+ level_filter=level_filter, keyword=query)
+
+
+def show_levels():
+ for lv in rainfocus.get_levels():
+ li = xbmcgui.ListItem(_level_badge(lv["name"]))
+ li.setArt({"icon": "DefaultProgram.png"})
+ url = build_url("list", filter_key="search.technicallevel", filter_val=lv["id"],
+ filter_label=lv["name"])
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+def show_session_types():
+ for st in rainfocus.get_session_types():
+ li = xbmcgui.ListItem(st["name"])
+ li.setArt({"icon": "DefaultVideoPlaylists.png"})
+ url = build_url("list", filter_key="search.sessiontype", filter_val=st["id"],
+ filter_label=st["name"])
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+def show_categories():
+ items = [
+ ("Technology", build_url("technologies"), "DefaultGenre.png"),
+ ("Technical Level", build_url("levels"), "DefaultProgram.png"),
+ ]
+ for label, url, icon in items:
+ li = xbmcgui.ListItem(label)
+ li.setArt({"icon": icon})
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+# ---------------------------------------------------------------------------
+# Search (global + in-category)
+# ---------------------------------------------------------------------------
+
+def do_search(filter_key=None, filter_val=None, filter_label=None):
+ """Search sessions. If filter params given, search within that category."""
+ if filter_label:
+ prompt = "Search within {}".format(filter_label)
+ else:
+ prompt = "Search Cisco Live Sessions"
+ kb = xbmc.Keyboard("", prompt)
+ kb.doModal()
+ if not kb.isConfirmed():
+ return
+ query = kb.getText().strip()
+ if not query:
+ return
+ show_session_list(search_text=query, filter_key=filter_key,
+ filter_val=filter_val, filter_label=filter_label)
+
+
+# ---------------------------------------------------------------------------
+# Refine filters (add filters on top of current)
+# ---------------------------------------------------------------------------
+
+def do_refine(filter_key=None, filter_val=None, filter_label=None,
+ search_text=None):
+ """Let user pick an additional filter to combine with current one."""
+ dialog = xbmcgui.Dialog()
+
+ # Build refine options based on what's NOT already filtered
+ options = []
+ option_data = []
+
+ if filter_key != "search.event":
+ options.append("Filter by Event")
+ option_data.append(("event", rainfocus.get_events()))
+ if filter_key != "search.technology":
+ options.append("Filter by Technology")
+ option_data.append(("tech", rainfocus.get_technologies()))
+ if filter_key != "search.technicallevel":
+ options.append("Filter by Technical Level")
+ option_data.append(("level", rainfocus.get_levels()))
+ if filter_key != "search.sessiontype":
+ options.append("Filter by Session Type")
+ option_data.append(("type", rainfocus.get_session_types()))
+
+ if not options:
+ dialog.notification("Cisco Live", "No additional filters available",
+ xbmcgui.NOTIFICATION_INFO)
+ return
+
+ choice = dialog.select("Refine: {}".format(filter_label or "Results"), options)
+ if choice < 0:
+ return
+
+ kind, items = option_data[choice]
+
+ # Show sub-choices
+ names = [i["name"] for i in items]
+ sub = dialog.select("Select", names)
+ if sub < 0:
+ return
+
+ selected = items[sub]
+
+ # Map kind to API filter key
+ key_map = {
+ "event": "search.event",
+ "tech": "search.technology",
+ "level": "search.technicallevel",
+ "type": "search.sessiontype",
+ }
+ new_key = key_map[kind]
+ new_val = selected["id"]
+ new_label = selected["name"]
+
+ # Build combined filter label
+ combined_label = filter_label or ""
+ if combined_label:
+ combined_label = "{} + {}".format(combined_label, new_label)
+ else:
+ combined_label = new_label
+
+ # Build combined filters as extra_filters string (key=val pairs)
+ # We pass the original filter as filter_key/filter_val and new one as extra
+ extra = "{}={}".format(new_key, new_val)
+
+ show_session_list(filter_key=filter_key, filter_val=filter_val,
+ filter_label=combined_label, search_text=search_text,
+ extra_filters=extra)
+
+
+# ---------------------------------------------------------------------------
+# Sort dialog
+# ---------------------------------------------------------------------------
+
+SORT_OPTIONS = [
+ ("Default", "default"),
+ ("Title A-Z", "title_asc"),
+ ("Title Z-A", "title_desc"),
+ ("Shortest first", "duration_asc"),
+ ("Longest first", "duration_desc"),
+]
+
+def do_sort(filter_key=None, filter_val=None, filter_label=None,
+ search_text=None, extra_filters=None):
+ """Show sort options and re-display list with chosen sort."""
+ dialog = xbmcgui.Dialog()
+ names = [s[0] for s in SORT_OPTIONS]
+ choice = dialog.select("Sort by", names)
+ if choice < 0:
+ return
+ sort_by = SORT_OPTIONS[choice][1]
+ show_session_list(filter_key=filter_key, filter_val=filter_val,
+ filter_label=filter_label, search_text=search_text,
+ extra_filters=extra_filters, sort_by=sort_by)
+
+
+# ---------------------------------------------------------------------------
+# Session List (the core browsing view)
+# ---------------------------------------------------------------------------
+
+def show_session_list(page=0, filter_key=None, filter_val=None,
+ filter_label=None, search_text=None,
+ extra_filters=None, sort_by="default",
+ event_name=None, catalog=None):
+ """List sessions with in-category search, refine, sort, and pagination."""
+ xbmcplugin.setContent(ADDON_HANDLE, "videos")
+
+ # Determine which API profile to use
+ profile_id = None
+ if catalog == "legacy":
+ profile_id = rainfocus.LEGACY_PROFILE_ID
+ elif catalog == "current":
+ profile_id = rainfocus.CURRENT_PROFILE_ID
+
+ # Build API filters
+ filters = {}
+ if filter_key and filter_val:
+ filters[filter_key] = filter_val
+ if search_text:
+ filters["search"] = search_text
+ if extra_filters:
+ for pair in extra_filters.split("&"):
+ if "=" in pair:
+ k, v = pair.split("=", 1)
+ filters[k] = v
+
+ # Common URL params for navigation items
+ nav_params = {}
+ if filter_key:
+ nav_params["filter_key"] = filter_key
+ nav_params["filter_val"] = filter_val
+ if filter_label:
+ nav_params["filter_label"] = filter_label
+ if search_text:
+ nav_params["search_text"] = search_text
+ if extra_filters:
+ nav_params["extra_filters"] = extra_filters
+ if event_name:
+ nav_params["event_name"] = event_name
+ if catalog:
+ nav_params["catalog"] = catalog
+
+ # -- Top navigation items --
+
+ # 1. Search within this category
+ if filter_key or search_text:
+ search_label = "Search within {}".format(
+ filter_label or "these results")
+ li = xbmcgui.ListItem(search_label)
+ li.setArt({"icon": "DefaultAddonsSearch.png"})
+ url = build_url("search_in", **nav_params)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # 2. Refine (add more filters)
+ if filter_key:
+ refine_label = "Refine results..."
+ li = xbmcgui.ListItem(refine_label)
+ li.setArt({"icon": "DefaultAddonService.png"})
+ url = build_url("refine", **nav_params)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # 3. Sort
+ current_sort = "Default"
+ for name, key in SORT_OPTIONS:
+ if key == sort_by:
+ current_sort = name
+ break
+ sort_label = "Sort: {}".format(current_sort)
+ li = xbmcgui.ListItem(sort_label)
+ li.setArt({"icon": "DefaultIconInfo.png"})
+ url = build_url("sort", **nav_params)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ # -- Fetch sessions --
+ result = rainfocus.search_sessions(
+ page=page, page_size=rainfocus.PAGE_SIZE, filters=filters,
+ profile_id=profile_id)
+ items = result.get("items", [])
+ total = result.get("total", 0)
+
+ # Client-side event filtering (API doesn't support search.event)
+ if event_name:
+ items = [i for i in items if i.get("event") == event_name]
+
+ if not items and page == 0:
+ xbmcgui.Dialog().notification(
+ "Cisco Live", "No sessions found", xbmcgui.NOTIFICATION_INFO)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+ return
+
+ # Client-side sort
+ if sort_by == "title_asc":
+ items.sort(key=lambda x: x.get("title", "").lower())
+ elif sort_by == "title_desc":
+ items.sort(key=lambda x: x.get("title", "").lower(), reverse=True)
+ elif sort_by == "duration_asc":
+ items.sort(key=lambda x: x.get("duration", 0))
+ elif sort_by == "duration_desc":
+ items.sort(key=lambda x: x.get("duration", 0), reverse=True)
+
+ # Add session items
+ for item in items:
+ _add_session_item(item)
+
+ # Pagination
+ next_offset = (page + 1) * rainfocus.PAGE_SIZE
+ effective_total = min(total, rainfocus.MAX_RESULTS)
+ if next_offset < effective_total:
+ total_pages = (effective_total + rainfocus.PAGE_SIZE - 1) // rainfocus.PAGE_SIZE
+ current_page = page + 1
+ pg_label = "\u25b6 More sessions... (page {} of {})".format(
+ current_page + 1, total_pages)
+ li = xbmcgui.ListItem("[COLOR yellow]{}[/COLOR]".format(pg_label))
+ li.setArt({"icon": "DefaultFolderBack.png"})
+ pg_params = dict(nav_params)
+ pg_params["page"] = str(page + 1)
+ if sort_by != "default":
+ pg_params["sort_by"] = sort_by
+ url = build_url("list", **pg_params)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ xbmcplugin.addSortMethod(ADDON_HANDLE, xbmcplugin.SORT_METHOD_UNSORTED)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+ # Set Wall view (500) as default for session lists
+ xbmc.executebuiltin("Container.SetViewMode(500)")
+
+def _add_session_item(item):
+ """Add a single session to the directory listing."""
+ title = item.get("title", "")
+ code = item.get("code", "")
+ has_video = item.get("has_video", False)
+
+ # Skip no-video sessions unless setting enabled
+ if not has_video and not SHOW_NO_VIDEO:
+ return
+
+ # Label: title only by default, or "CODE - Title" if setting enabled
+ if SHOW_CODES and code:
+ label = "{} - {}".format(code, title)
+ else:
+ label = title
+
+ if not has_video:
+ label = "[COLOR grey]{}[/COLOR]".format(label)
+
+ li = xbmcgui.ListItem(label)
+
+ # Build subtitle parts
+ sub_parts = []
+ if item.get("technologies"):
+ sub_parts.append(item["technologies"][0])
+ if item.get("level"):
+ sub_parts.append(_level_badge(item["level"]))
+ if item.get("duration") and item["duration"] > 0:
+ mins = int(item["duration"] / 60)
+ if mins >= 60:
+ sub_parts.append("{}h {}m".format(mins // 60, mins % 60))
+ else:
+ sub_parts.append("{} min".format(mins))
+ if sub_parts:
+ li.setLabel2(" \u2022 ".join(sub_parts))
+
+ # Build detailed plot
+ speakers = ", ".join(s for s in item.get("speakers", []) if s)
+ abstract = item.get("abstract", "")
+ event = item.get("event", "")
+ level = item.get("level", "")
+ techs = ", ".join(item.get("technologies", []))
+ stype = item.get("session_type", "")
+
+ plot_parts = []
+ if event:
+ plot_parts.append("[B]Event:[/B] {}".format(event))
+ if code:
+ plot_parts.append("[B]Session:[/B] {}".format(code))
+ if stype:
+ plot_parts.append("[B]Type:[/B] {}".format(stype))
+ if level:
+ plot_parts.append("[B]Level:[/B] {}".format(level))
+ if techs:
+ plot_parts.append("[B]Technology:[/B] {}".format(techs))
+ if speakers:
+ plot_parts.append("[B]Speaker(s):[/B] {}".format(speakers))
+ if not has_video:
+ plot_parts.append("")
+ plot_parts.append("[COLOR red]No video recording available[/COLOR]")
+ if abstract:
+ plot_parts.append("")
+ plot_parts.append(abstract)
+
+ info_tag = li.getVideoInfoTag()
+ info_tag.setTitle(title)
+ info_tag.setPlot("\n".join(plot_parts))
+ if item.get("duration"):
+ info_tag.setDuration(int(item["duration"]))
+ if event:
+ info_tag.setStudios([event])
+
+ # Thumbnails
+ photos = [p for p in item.get("speaker_photos", []) if p]
+ if photos:
+ li.setArt({"thumb": photos[0]})
+
+ # Fanart
+ fanart = os.path.join(
+ ADDON.getAddonInfo("path"), "resources", "media", "fanart.jpg")
+ if os.path.exists(fanart):
+ li.setArt({"fanart": fanart})
+
+ if has_video:
+ li.setProperty("IsPlayable", "true")
+ video_id = item["video_ids"][0]
+ url = build_url("play", video_id=video_id,
+ session_id=item.get("id", ""),
+ title=title, code=code,
+ event=item.get("event", ""))
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=False)
+ else:
+ url = build_url("info", session_id=item.get("id", ""))
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=False)
+
+
+# ---------------------------------------------------------------------------
+# Playback
+# ---------------------------------------------------------------------------
+
+def play_video(video_id, title="", session_id="", code="", event=""):
+ """Resolve and play a Brightcove video."""
+ xbmc.log("CiscoLive: Resolving video {} (session={})".format(
+ video_id, session_id), xbmc.LOGINFO)
+
+ # Record in watch history
+ history.add(session_id=session_id, title=title, video_id=video_id,
+ code=code, event=event)
+
+ # Resolve video stream via Brightcove
+ try:
+ stream_url, mime_type = brightcove.best_stream(video_id, prefer_hls=True)
+ except Exception as e:
+ xbmc.log("CiscoLive: Video resolution error: {}".format(e), xbmc.LOGERROR)
+ stream_url, mime_type = None, None
+
+ if not stream_url:
+ xbmcgui.Dialog().notification(
+ "Cisco Live",
+ "Video unavailable. Check your network connection.",
+ xbmcgui.NOTIFICATION_ERROR, 5000)
+ xbmcplugin.setResolvedUrl(ADDON_HANDLE, False, xbmcgui.ListItem())
+ return
+
+ li = xbmcgui.ListItem(path=stream_url)
+ if "m3u8" in stream_url or "mpegurl" in (mime_type or "").lower():
+ li.setMimeType("application/vnd.apple.mpegurl")
+ li.setProperty("inputstream", "inputstream.adaptive")
+ li.setProperty("inputstream.adaptive.manifest_type", "hls")
+ elif "dash" in stream_url or "dash" in (mime_type or "").lower():
+ li.setMimeType("application/dash+xml")
+ li.setProperty("inputstream", "inputstream.adaptive")
+ li.setProperty("inputstream.adaptive.manifest_type", "mpd")
+ else:
+ li.setMimeType(mime_type or "video/mp4")
+ li.setContentLookup(False)
+ xbmcplugin.setResolvedUrl(ADDON_HANDLE, True, li)
+
+
+# ---------------------------------------------------------------------------
+# Watch History
+# ---------------------------------------------------------------------------
+
+def show_history():
+ """Show recently watched sessions."""
+ xbmcplugin.setContent(ADDON_HANDLE, "videos")
+ entries = history.get_recent(50)
+
+ if not entries:
+ xbmcgui.Dialog().notification(
+ "Cisco Live", "No watch history yet", xbmcgui.NOTIFICATION_INFO)
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+ return
+
+ # Clear history option at top
+ li = xbmcgui.ListItem("[COLOR red]X Clear watch history[/COLOR]")
+ li.setArt({"icon": "DefaultIconError.png"})
+ url = build_url("clear_history")
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=True)
+
+ for entry in entries:
+ title = entry.get("title", "Unknown")
+ code = entry.get("code", "")
+ event = entry.get("event", "")
+ video_id = entry.get("video_id", "")
+
+ if SHOW_CODES and code:
+ label = "{} - {}".format(code, title)
+ else:
+ label = title
+
+ li = xbmcgui.ListItem(label)
+ if event:
+ li.setLabel2(event)
+ li.setProperty("IsPlayable", "true")
+
+ info_tag = li.getVideoInfoTag()
+ info_tag.setTitle(title)
+ if event:
+ info_tag.setPlot("[B]Event:[/B] {}\n[B]Session:[/B] {}".format(
+ event, code))
+
+ url = build_url("play", video_id=video_id,
+ session_id=entry.get("session_id", ""),
+ title=title, code=code, event=event)
+ xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, isFolder=False)
+
+ xbmcplugin.endOfDirectory(ADDON_HANDLE)
+
+
+def clear_history():
+ if xbmcgui.Dialog().yesno("Cisco Live", "Clear all watch history?"):
+ history.clear()
+ xbmcgui.Dialog().notification(
+ "Cisco Live", "History cleared", xbmcgui.NOTIFICATION_INFO)
+ xbmc.executebuiltin("Container.Refresh")
+
+
+# ---------------------------------------------------------------------------
+# Session Info
+# ---------------------------------------------------------------------------
+
+def show_info(session_id):
+ item = rainfocus.get_session(session_id)
+ if not item:
+ xbmcgui.Dialog().notification(
+ "Cisco Live", "Session not found", xbmcgui.NOTIFICATION_WARNING)
+ return
+ lines = [
+ "[B]{}[/B]".format(item.get("title", "")),
+ "",
+ "Event: {}".format(item.get("event", "N/A")),
+ "Session: {}".format(item.get("code", "N/A")),
+ "Type: {}".format(item.get("session_type", "N/A")),
+ "Level: {}".format(item.get("level", "N/A")),
+ "Speaker(s): {}".format(", ".join(item.get("speakers", [])) or "N/A"),
+ "",
+ item.get("abstract", "No description available."),
+ ]
+ xbmcgui.Dialog().textviewer(
+ item.get("code", "Session Info"), "\n".join(lines))
+
+
+# ---------------------------------------------------------------------------
+# About
+# ---------------------------------------------------------------------------
+
+def show_about():
+ """Show plugin information."""
+ version = ADDON.getAddonInfo("version")
+ lines = [
+ "[B]Cisco Live On-Demand[/B]",
+ "Version {}".format(version),
+ "",
+ "Browse and stream 14,000+ Cisco Live technical sessions",
+ "from events spanning 2018-2026.",
+ "",
+ "All videos are freely accessible without authentication.",
+ "",
+ "[B]Features:[/B]",
+ "• Browse by event, technology, and technical level",
+ "• Search across all sessions",
+ "• Multi-select filtering",
+ "• Watch history tracking",
+ "• Direct Brightcove streaming",
+ "",
+ "[B]Source:[/B]",
+ "github.com/martap79/plugin.video.ciscolive",
+ "",
+ "[B]Website:[/B]",
+ "ciscolive.com/on-demand",
+ ]
+ xbmcgui.Dialog().textviewer("About Cisco Live On-Demand", "\n".join(lines))
+
+
+# ---------------------------------------------------------------------------
+# Router
+# ---------------------------------------------------------------------------
+
+def router():
+ params = get_params()
+ action = params.get("action", "")
+
+ if action == "":
+ main_menu()
+ elif action == "new_releases":
+ show_new_releases()
+ elif action == "events":
+ show_events()
+ elif action == "event_section":
+ show_event_section(
+ section_id=params.get("section_id", ""),
+ event_name=params.get("event_name", ""),
+ page=int(params.get("page", "0")),
+ tech_filter=params.get("tech_filter", ""),
+ level_filter=params.get("level_filter", ""),
+ keyword=params.get("keyword", ""),
+ )
+ elif action == "event_filter_pick":
+ _event_filter_pick(
+ filter_type=params.get("filter_type", ""),
+ section_id=params.get("section_id", ""),
+ event_name=params.get("event_name", ""),
+ tech_filter=params.get("tech_filter", ""),
+ level_filter=params.get("level_filter", ""),
+ keyword=params.get("keyword", ""),
+ )
+ elif action == "event_keyword_search":
+ _event_keyword_search(
+ section_id=params.get("section_id", ""),
+ event_name=params.get("event_name", ""),
+ tech_filter=params.get("tech_filter", ""),
+ level_filter=params.get("level_filter", ""),
+ keyword=params.get("keyword", ""),
+ )
+ elif action == "technologies":
+ show_technologies()
+ elif action == "levels":
+ show_levels()
+ elif action == "session_types":
+ show_session_types()
+ elif action == "categories":
+ show_categories()
+ elif action == "search":
+ do_search()
+ elif action == "search_in":
+ do_search(filter_key=params.get("filter_key"),
+ filter_val=params.get("filter_val"),
+ filter_label=params.get("filter_label"))
+ elif action == "refine":
+ do_refine(filter_key=params.get("filter_key"),
+ filter_val=params.get("filter_val"),
+ filter_label=params.get("filter_label"),
+ search_text=params.get("search_text"))
+ elif action == "sort":
+ do_sort(filter_key=params.get("filter_key"),
+ filter_val=params.get("filter_val"),
+ filter_label=params.get("filter_label"),
+ search_text=params.get("search_text"),
+ extra_filters=params.get("extra_filters"))
+ elif action == "about":
+ show_about()
+ elif action == "history":
+ show_history()
+ elif action == "clear_history":
+ clear_history()
+ elif action == "list":
+ show_session_list(
+ page=int(params.get("page", "0")),
+ filter_key=params.get("filter_key"),
+ filter_val=params.get("filter_val"),
+ filter_label=params.get("filter_label"),
+ search_text=params.get("search_text"),
+ extra_filters=params.get("extra_filters"),
+ sort_by=params.get("sort_by", "default"),
+ event_name=params.get("event_name"),
+ catalog=params.get("catalog"),
+ )
+ elif action == "play":
+ play_video(
+ params.get("video_id", ""),
+ params.get("title", ""),
+ params.get("session_id", ""),
+ params.get("code", ""),
+ params.get("event", ""),
+ )
+ elif action == "info":
+ show_info(params.get("session_id", ""))
+ else:
+ main_menu()
+
+
+if __name__ == "__main__":
+ router()
diff --git a/plugin.video.ciscolive/addon.xml b/plugin.video.ciscolive/addon.xml
new file mode 100644
index 000000000..21af9d7f7
--- /dev/null
+++ b/plugin.video.ciscolive/addon.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ video
+
+
+ Browse and watch Cisco Live on-demand sessions
+ Browse the full Cisco Live on-demand library with 14,000+ technical sessions from 2018-2026. Filter by event, technology, technical level, or search by keyword. Features watch history tracking and multi-select filtering. Streams sessions directly via the RainFocus catalog API and Brightcove video platform. All videos are freely accessible without authentication.
+ all
+ MIT
+ en
+ https://github.com/martap79/plugin.video.ciscolive
+ https://www.ciscolive.com/on-demand/on-demand-library.html
+ v2.1.0 - Removed authentication requirement, all videos are freely accessible.
+
+ resources/media/icon.png
+ resources/media/fanart.jpg
+
+
+
diff --git a/plugin.video.ciscolive/resources/lib/__init__.py b/plugin.video.ciscolive/resources/lib/__init__.py
new file mode 100644
index 000000000..34dd04591
--- /dev/null
+++ b/plugin.video.ciscolive/resources/lib/__init__.py
@@ -0,0 +1 @@
+# Empty init to make this a package
diff --git a/plugin.video.ciscolive/resources/lib/brightcove.py b/plugin.video.ciscolive/resources/lib/brightcove.py
new file mode 100644
index 000000000..ad3e630e1
--- /dev/null
+++ b/plugin.video.ciscolive/resources/lib/brightcove.py
@@ -0,0 +1,199 @@
+"""
+Brightcove video resolver.
+
+Resolves a Brightcove video ID to a playable HLS/MP4 stream URL
+using the Brightcove Playback API (edge.api.brightcove.com).
+"""
+
+import json
+import threading
+
+try:
+ from urllib.request import Request, urlopen
+ from urllib.error import HTTPError
+except ImportError:
+ from urllib2 import Request, urlopen, HTTPError
+
+from . import rainfocus
+
+# Brightcove Playback API
+PLAYBACK_API = (
+ "https://edge.api.brightcove.com/playback/v1/accounts/{account}/videos/{video_id}"
+)
+
+_POLICY_KEY = None
+_POLICY_LOCK = threading.Lock()
+
+
+def _fetch_policy_key():
+ """
+ Fetch the Brightcove policy key from the player config.json endpoint.
+ The key lives at video_cloud.policy_key in the player configuration.
+ Response may be gzip-encoded.
+ """
+ global _POLICY_KEY
+ with _POLICY_LOCK:
+ if _POLICY_KEY:
+ return _POLICY_KEY
+
+ import gzip
+
+ config_url = (
+ "https://players.brightcove.net/{account}/{player}_default/config.json"
+ ).format(account=rainfocus.BRIGHTCOVE_ACCOUNT, player=rainfocus.BRIGHTCOVE_PLAYER)
+ try:
+ req = Request(config_url, headers={"Accept-Encoding": "gzip"})
+ resp = urlopen(req, timeout=15)
+ raw = resp.read()
+ try:
+ data = gzip.decompress(raw)
+ except (OSError, IOError):
+ data = raw # Response wasn't gzip-encoded despite header
+ config = json.loads(data)
+ pk = config.get("video_cloud", {}).get("policy_key")
+ if pk:
+ _POLICY_KEY = pk
+ return _POLICY_KEY
+ except Exception:
+ pass
+
+ # Fallback: try parsing the player JS for policyKey
+ import re
+ js_url = (
+ "https://players.brightcove.net/{account}/{player}_default/index.min.js"
+ ).format(account=rainfocus.BRIGHTCOVE_ACCOUNT, player=rainfocus.BRIGHTCOVE_PLAYER)
+ try:
+ req = Request(js_url, headers={"Accept-Encoding": "gzip"})
+ resp = urlopen(req, timeout=15)
+ raw = resp.read()
+ try:
+ js = gzip.decompress(raw).decode("utf-8", errors="replace")
+ except (OSError, IOError):
+ js = raw.decode("utf-8", errors="replace")
+ m = re.search(r'policyKey\s*:\s*["\']([^"\']+)', js)
+ if m:
+ _POLICY_KEY = m.group(1)
+ return _POLICY_KEY
+ except Exception:
+ pass
+
+ return None
+
+
+def resolve(video_id):
+ """
+ Resolve a Brightcove video ID to stream info.
+
+ Returns:
+ dict with keys:
+ - streams: list of {url, width, height, bitrate, codec} sorted by bitrate desc
+ - thumbnail: str (poster image URL)
+ - title: str
+ - duration: float (seconds)
+ or None if resolution fails
+ """
+ policy_key = _fetch_policy_key()
+ if not policy_key:
+ return None
+
+ url = PLAYBACK_API.format(
+ account=rainfocus.BRIGHTCOVE_ACCOUNT, video_id=video_id
+ )
+ headers = {
+ "Accept": "application/json;pk={}".format(policy_key),
+ }
+ req = Request(url, headers=headers)
+ try:
+ resp = urlopen(req, timeout=15)
+ data = json.loads(resp.read().decode("utf-8"))
+ except Exception:
+ return None
+
+ streams = []
+ for source in data.get("sources", []):
+ src_url = source.get("src", "")
+ src_type = source.get("type", "").lower()
+ if not src_url:
+ continue
+ # HLS: type contains mpegurl or m3u8, or URL ends with .m3u8
+ if "mpegurl" in src_type or "m3u8" in src_type or ".m3u8" in src_url:
+ streams.append({
+ "url": src_url,
+ "type": "hls",
+ "width": source.get("width", 0),
+ "height": source.get("height", 0),
+ "bitrate": source.get("avg_bitrate", 0),
+ "codec": source.get("codec", ""),
+ })
+ # DASH: type contains dash+xml
+ elif "dash" in src_type:
+ streams.append({
+ "url": src_url,
+ "type": "dash",
+ "width": source.get("width", 0),
+ "height": source.get("height", 0),
+ "bitrate": source.get("avg_bitrate", 0),
+ "codec": source.get("codec", ""),
+ })
+ # MP4: type contains mp4, or URL contains /pmp4/ or ends with .mp4
+ elif "mp4" in src_type or "/pmp4/" in src_url or src_url.endswith(".mp4"):
+ streams.append({
+ "url": src_url,
+ "type": "mp4",
+ "width": source.get("width", 0),
+ "height": source.get("height", 0),
+ "bitrate": source.get("avg_bitrate", 0),
+ "codec": source.get("codec", ""),
+ })
+
+ # Sort by bitrate descending (highest quality first)
+ streams.sort(key=lambda s: s.get("bitrate", 0), reverse=True)
+
+ poster = data.get("poster", "")
+ thumbnail = data.get("thumbnail", poster)
+
+ return {
+ "streams": streams,
+ "thumbnail": poster or thumbnail,
+ "title": data.get("name", ""),
+ "duration": data.get("duration", 0) / 1000.0, # ms to seconds
+ }
+
+
+def best_stream(video_id, prefer_hls=True):
+ """
+ Get the best playable stream URL for a video.
+
+ Args:
+ video_id: Brightcove video ID
+ prefer_hls: If True, prefer HLS over MP4 (Kodi handles HLS natively)
+
+ Returns:
+ tuple of (url, mime_type) or (None, None)
+ """
+ info = resolve(video_id)
+ if not info or not info["streams"]:
+ return None, None
+
+ def prefer_https(stream_list):
+ """Sort streams to prefer HTTPS over HTTP."""
+ return sorted(stream_list, key=lambda s: (0 if s["url"].startswith("https") else 1))
+
+ if prefer_hls:
+ hls = prefer_https([s for s in info["streams"] if s["type"] == "hls"])
+ if hls:
+ return hls[0]["url"], "application/vnd.apple.mpegurl"
+
+ mp4 = prefer_https([s for s in info["streams"] if s["type"] == "mp4"])
+ if mp4:
+ return mp4[0]["url"], "video/mp4"
+
+ # DASH fallback
+ dash = prefer_https([s for s in info["streams"] if s["type"] == "dash"])
+ if dash:
+ return dash[0]["url"], "application/dash+xml"
+
+ # Last resort: first available
+ s = info["streams"][0]
+ type_map = {"hls": "application/vnd.apple.mpegurl", "mp4": "video/mp4", "dash": "application/dash+xml"}
+ return s["url"], type_map.get(s["type"], "video/mp4")
diff --git a/plugin.video.ciscolive/resources/lib/history.py b/plugin.video.ciscolive/resources/lib/history.py
new file mode 100644
index 000000000..cdbb23368
--- /dev/null
+++ b/plugin.video.ciscolive/resources/lib/history.py
@@ -0,0 +1,75 @@
+"""
+Watch history tracking for Cisco Live Kodi plugin.
+
+Stores recently played sessions locally in the addon profile directory.
+Max 100 entries, auto-prunes oldest when exceeded.
+"""
+
+import json
+import os
+import time
+
+try:
+ import xbmcaddon
+ import xbmcvfs
+ _ADDON = xbmcaddon.Addon()
+ HISTORY_DIR = xbmcvfs.translatePath(_ADDON.getAddonInfo("profile"))
+except Exception:
+ HISTORY_DIR = os.path.join(os.path.dirname(__file__), ".profile")
+
+HISTORY_FILE = os.path.join(HISTORY_DIR, "history.json")
+MAX_ENTRIES = 100
+
+
+def _load():
+ """Load history from disk."""
+ if not os.path.exists(HISTORY_FILE):
+ return []
+ try:
+ with open(HISTORY_FILE, "r") as f:
+ return json.load(f)
+ except Exception:
+ return []
+
+
+def _save(entries):
+ """Save history to disk."""
+ os.makedirs(HISTORY_DIR, exist_ok=True)
+ try:
+ with open(HISTORY_FILE, "w") as f:
+ json.dump(entries, f, indent=2)
+ except Exception:
+ pass
+
+
+def add(session_id, title="", video_id="", code="", event=""):
+ """Record that a session was played."""
+ entries = _load()
+
+ # Remove existing entry for this session (we'll re-add at top)
+ entries = [e for e in entries if e.get("session_id") != session_id]
+
+ entry = {
+ "session_id": session_id,
+ "title": title,
+ "video_id": video_id,
+ "code": code,
+ "event": event,
+ "timestamp": time.time(),
+ }
+ entries.insert(0, entry)
+
+ # Prune to max size
+ entries = entries[:MAX_ENTRIES]
+ _save(entries)
+
+
+def get_recent(limit=50):
+ """Get recently played sessions, newest first."""
+ entries = _load()
+ return entries[:limit]
+
+
+def clear():
+ """Clear all watch history."""
+ _save([])
diff --git a/plugin.video.ciscolive/resources/lib/rainfocus.py b/plugin.video.ciscolive/resources/lib/rainfocus.py
new file mode 100644
index 000000000..5425bb277
--- /dev/null
+++ b/plugin.video.ciscolive/resources/lib/rainfocus.py
@@ -0,0 +1,530 @@
+"""
+RainFocus API client for Cisco Live on-demand library.
+
+Handles session catalog search, filtering, and detail fetching.
+Uses the website widget ID (M7n14I8sz0pklW1vybwVRdKrgdREj8sR) which returns
+the full 8,493-session catalog with proper pagination.
+
+API limits:
+- Max 50 items per page
+- Max 500 items per search (paginationMax=500)
+- Filtered searches (by event/tech/level) paginate within their result set
+"""
+
+import json
+import time
+import hashlib
+import os
+
+try:
+ from urllib.request import Request, urlopen
+ from urllib.parse import urlencode
+ from urllib.error import HTTPError, URLError
+except ImportError:
+ from urllib2 import Request, urlopen, HTTPError, URLError
+ from urllib import urlencode
+
+# Cache directory inside Kodi userdata
+try:
+ import xbmcaddon
+ import xbmcvfs
+ _ADDON = xbmcaddon.Addon()
+ CACHE_DIR = os.path.join(
+ xbmcvfs.translatePath(_ADDON.getAddonInfo("profile")), "cache"
+ )
+except Exception:
+ CACHE_DIR = os.path.join(os.path.dirname(__file__), ".cache")
+
+
+API_URL = "https://events.rainfocus.com/api/{endpoint}"
+
+# Two API profiles: legacy catalog (2018-2021) and current catalog (2022+)
+LEGACY_PROFILE_ID = "Na3vqYdAlJFSxhYTYQGuMbpafMqftalz"
+CURRENT_PROFILE_ID = "HEedDIRblcZk7Ld3KHm1T0VUtZog9eG9"
+API_PROFILE_ID = CURRENT_PROFILE_ID # default for new code
+
+WIDGET_ID = "M7n14I8sz0pklW1vybwVRdKrgdREj8sR"
+BRIGHTCOVE_ACCOUNT = "5647924234001"
+BRIGHTCOVE_PLAYER = "SyK2FdqjM"
+
+HEADERS = {
+ "Origin": "https://ciscolive.cisco.com",
+ "Referer": "https://www.ciscolive.com/on-demand/on-demand-library.html",
+ "rfApiProfileId": API_PROFILE_ID,
+ "rfWidgetId": WIDGET_ID,
+ "Content-Type": "application/x-www-form-urlencoded",
+}
+
+# Pagination limits enforced by RainFocus
+PAGE_SIZE = 50
+MAX_RESULTS = 500 # paginationMax
+
+# Cache TTL in seconds
+CACHE_TTL = 21600 # 6 hours (content doesn't change frequently)
+
+# Known events across both catalogs, sorted newest first
+# Current catalog (CURRENT_PROFILE_ID) has 2022+ events
+# Legacy catalog (LEGACY_PROFILE_ID) has 2018-2021 events
+# Event "name" matches the item["event"] field from the API
+EVENTS = [
+ {"name": "2026 Amsterdam", "catalog": "current"},
+ {"name": "2025 San Diego", "catalog": "current"},
+ {"name": "2025 Melbourne", "catalog": "current"},
+ {"name": "2025 Amsterdam", "catalog": "current"},
+ {"name": "2024 Las Vegas", "catalog": "current"},
+ {"name": "2024 Melbourne", "catalog": "current"},
+ {"name": "2024 Amsterdam", "catalog": "current"},
+ {"name": "2023 Las Vegas", "catalog": "current"},
+ {"name": "2023 Melbourne", "catalog": "current"},
+ {"name": "2023 Amsterdam", "catalog": "current"},
+ {"name": "2022 Las Vegas", "catalog": "current"},
+ {"name": "2022 Melbourne", "catalog": "current"},
+ {"name": "Cisco Live 2021", "catalog": "legacy"},
+ {"name": "Cisco Live US 2020", "catalog": "legacy"},
+ {"name": "Cisco Live EMEA 2020", "catalog": "legacy"},
+ {"name": "Cisco Live APJC 2020", "catalog": "legacy"},
+ {"name": "Cisco Live US 2019", "catalog": "legacy"},
+ {"name": "Cisco Live EMEA 2019", "catalog": "legacy"},
+ {"name": "Cisco Live ANZ 2019", "catalog": "legacy"},
+ {"name": "Cisco Live LATAM 2019", "catalog": "legacy"},
+ {"name": "Cisco Live US 2018", "catalog": "legacy"},
+ {"name": "Cisco Live EMEA 2018", "catalog": "legacy"},
+ {"name": "Cisco Live ANZ 2018", "catalog": "legacy"},
+ {"name": "Cisco Live LATAM 2018", "catalog": "legacy"},
+]
+
+# Known technology filter IDs
+TECHNOLOGIES = [
+ {"id": "scpsTechnology_5g", "name": "5G"},
+ {"id": "scpsTechnology_analtics1", "name": "Analytics"},
+ {"id": "scpsTechnology_analytics", "name": "Analytics & Automation"},
+ {"id": "scpsTechnology_automation", "name": "Automation"},
+ {"id": "scpsTechnology_cloud", "name": "Cloud"},
+ {"id": "scpsTechnology_collaboration", "name": "Collaboration"},
+ {"id": "scpsTechnology_dataCenter", "name": "Data Center"},
+ {"id": "scpsTechnology_dataCenterManagement", "name": "Data Center Management"},
+ {"id": "scpsTechnology_enterpriseArchitecture", "name": "Enterprise Architecture"},
+ {"id": "scpsTechnology_enterprisenetworks", "name": "Enterprise Networks"},
+ {"id": "scpsTechnology_internetofthingsiot", "name": "Internet of Things (IoT)"},
+ {"id": "scpsTechnology_mobility", "name": "Mobility"},
+ {"id": "scpsTechnology_nfv", "name": "NFV"},
+ {"id": "scpsTechnology_networkManagement", "name": "Network Management"},
+ {"id": "scpsTechnology_networkTransformation", "name": "Network Transformation"},
+ {"id": "scpsTechnology_programmability", "name": "Programmability"},
+ {"id": "scpsTechnology_routing", "name": "Routing"},
+ {"id": "1555524473529007Qkew", "name": "SD-WAN"},
+ {"id": "scpsTechnology_sdn", "name": "SDN"},
+ {"id": "scpsTechnology_security", "name": "Security"},
+ {"id": "scpsTechnology_serviceProvider", "name": "Service Provider"},
+ {"id": "scpsTechnology_softwareDefinedNetworking", "name": "Software Defined Networking"},
+ {"id": "scpsTechnology_softwareanalytics", "name": "Software and Analytics"},
+ {"id": "scpsTechnology_storageNetworking", "name": "Storage Networking"},
+ {"id": "scpsTechnology_videoContentDelivery", "name": "Video and Content Delivery"},
+ {"id": "scpsTechnology_voiceUnifiedCommunication", "name": "Voice and Unified Communication"},
+]
+
+# Known technical levels
+LEVELS = [
+ {"id": "scpsSkillLevel_aintroductory", "name": "Introductory"},
+ {"id": "scpsSkillLevel_bintermediate", "name": "Intermediate"},
+ {"id": "scpsSkillLevel_cadvanced", "name": "Advanced"},
+ {"id": "scpsSkillLevel_dgeneral", "name": "General"},
+]
+
+# Known session types
+SESSION_TYPES = [
+ {"id": "scpsSessionType_breakoutSession", "name": "Breakout Session"},
+ {"id": "scpsSessionType_devnetSession", "name": "DevNet Session"},
+ {"id": "scpsSessionType_instructorLed", "name": "Instructor-Led Lab"},
+ {"id": "scpsSessionType_keynotes", "name": "Keynote"},
+ {"id": "scpsSessionType_techSeminar", "name": "Technical Seminar"},
+ {"id": "scpsSessionType_walkInSelfPaced", "name": "Walk-in Self-Paced Lab"},
+]
+
+
+def _cache_path(key):
+ try:
+ os.makedirs(CACHE_DIR, exist_ok=True)
+ except OSError:
+ pass
+ h = hashlib.md5(key.encode()).hexdigest()
+ return os.path.join(CACHE_DIR, h + ".json")
+
+
+def _cache_get(key):
+ path = _cache_path(key)
+ if not os.path.exists(path):
+ return None
+ try:
+ with open(path, "r") as f:
+ data = json.load(f)
+ if time.time() - data.get("_ts", 0) > CACHE_TTL:
+ return None
+ return data.get("payload")
+ except Exception:
+ return None
+
+
+def _cache_set(key, payload):
+ path = _cache_path(key)
+ try:
+ with open(path, "w") as f:
+ json.dump({"_ts": time.time(), "payload": payload}, f)
+ except Exception:
+ pass
+
+
+def _cached_fetch(cache_key, fetch_fn):
+ """Check cache first, otherwise call fetch_fn(), cache the result, and return it."""
+ cached = _cache_get(cache_key)
+ if cached is not None:
+ return cached
+ result = fetch_fn()
+ if result is not None:
+ _cache_set(cache_key, result)
+ return result
+
+
+def _api_post(endpoint, params, profile_id=None):
+ """POST to RainFocus API and return parsed JSON.
+
+ params values can be strings or lists. Lists produce repeated keys
+ (e.g. {"search.technology": ["a", "b"]} -> "search.technology=a&search.technology=b").
+
+ Returns parsed JSON dict on success, or None on network/API failure.
+ """
+ url = API_URL.format(endpoint=endpoint)
+ # Build body with support for repeated keys (list values)
+ pairs = []
+ for k, v in params.items():
+ if isinstance(v, list):
+ for item in v:
+ pairs.append((k, item))
+ else:
+ pairs.append((k, v))
+ body = urlencode(pairs).encode("utf-8")
+ headers = dict(HEADERS)
+ if profile_id:
+ headers["rfApiProfileId"] = profile_id
+ req = Request(url, data=body, headers=headers)
+ try:
+ resp = urlopen(req, timeout=30)
+ return json.loads(resp.read().decode("utf-8"))
+ except Exception:
+ return None
+
+
+def search_sessions(page=0, page_size=PAGE_SIZE, filters=None, profile_id=None):
+ """
+ Search the session catalog.
+
+ Args:
+ page: Page number (0-indexed)
+ page_size: Results per page (max 50, enforced by API)
+ filters: dict of search filters, e.g. {"search": "network"}
+ profile_id: Override the API profile (for legacy vs current catalog)
+
+ Returns:
+ dict with keys: items (list), total (int), page, page_size
+ """
+ effective_size = min(page_size, PAGE_SIZE)
+ offset = page * effective_size
+
+ # API caps at 500 results
+ if offset >= MAX_RESULTS:
+ return {"items": [], "total": 0, "page": page, "page_size": effective_size}
+
+ params = {
+ "type": "session",
+ "size": str(effective_size),
+ "from": str(offset),
+ }
+ if filters:
+ params.update(filters)
+
+ cache_key = "search:{}:{}".format(profile_id or "default",
+ json.dumps(params, sort_keys=True))
+ cached = _cache_get(cache_key)
+ if cached:
+ return cached
+
+ data = _api_post("search", params, profile_id=profile_id)
+ if not data:
+ return {"items": [], "total": 0, "page": page, "page_size": effective_size}
+ section = data.get("sectionList", [{}])[0] if data.get("sectionList") else {}
+ items = section.get("items", [])
+ total = section.get("total", 0)
+
+ result = {
+ "items": [_parse_item(i) for i in items],
+ "total": total,
+ "page": page,
+ "page_size": effective_size,
+ }
+ _cache_set(cache_key, result)
+ return result
+
+
+def discover_event_sections():
+ """
+ Discover all events dynamically using sections=true.
+
+ The current catalog returns events as sections when sections=true is passed.
+ Each section represents one event (e.g. "2025 San Diego") with its own total.
+
+ Returns:
+ list of dicts with keys: name, section_id, total, catalog
+ """
+ cache_key = "event_sections"
+ cached = _cache_get(cache_key)
+ if cached:
+ return cached
+
+ events = []
+
+ # Current catalog (2022+) - requires sections=true
+ try:
+ data = _api_post("search", {
+ "type": "session", "size": "1", "sections": "true"
+ }, profile_id=CURRENT_PROFILE_ID)
+ if data:
+ for s in data.get("sectionList", []):
+ sid = s.get("sectionId", "")
+ total = s.get("total", 0)
+ items = s.get("items", [])
+ name = items[0].get("event", "") if items else ""
+ if name and sid != "otherItems":
+ events.append({
+ "name": name,
+ "section_id": sid,
+ "total": total,
+ "catalog": "current",
+ })
+ except Exception:
+ pass
+
+ # Legacy catalog (2018-2021) - no sections, discover from items
+ try:
+ legacy_events = {}
+ for offset in range(0, 500, 50):
+ data = _api_post("search", {
+ "type": "session", "size": "50", "from": str(offset)
+ }, profile_id=LEGACY_PROFILE_ID)
+ if not data:
+ break
+ for s in data.get("sectionList", []):
+ for item in s.get("items", []):
+ ev = item.get("event", "")
+ if ev and ev not in legacy_events:
+ legacy_events[ev] = 0
+ if ev:
+ legacy_events[ev] += 1
+ for name, count in sorted(legacy_events.items(), reverse=True):
+ events.append({
+ "name": name,
+ "section_id": "",
+ "total": count,
+ "catalog": "legacy",
+ })
+ except Exception:
+ pass
+
+ if events:
+ _cache_set(cache_key, events)
+ return events
+
+
+def search_event_sessions(section_id, page=0, page_size=PAGE_SIZE,
+ event_name=None, filters=None):
+ """
+ Fetch sessions from a specific event section (current catalog).
+
+ Uses sections=true with pagination. The API paginates within each section
+ independently, so from/size apply per-section.
+
+ Args:
+ section_id: The sectionId from discover_event_sections()
+ page: Page number (0-indexed)
+ page_size: Results per page
+ event_name: Event name for cache key / fallback filtering
+ filters: Additional search filters
+
+ Returns:
+ dict with keys: items (list), total (int), page, page_size
+ """
+ effective_size = min(page_size, PAGE_SIZE)
+ offset = page * effective_size
+
+ if offset >= MAX_RESULTS:
+ return {"items": [], "total": 0, "page": page, "page_size": effective_size}
+
+ params = {
+ "type": "session",
+ "size": str(effective_size),
+ "from": str(offset),
+ "sections": "true",
+ }
+ if filters:
+ params.update(filters)
+
+ # Build a stable cache key (convert lists to sorted comma-joined strings)
+ cache_params = {}
+ for k, v in params.items():
+ cache_params[k] = ",".join(sorted(v)) if isinstance(v, list) else v
+ cache_key = "eventsec:{}:{}".format(section_id,
+ json.dumps(cache_params, sort_keys=True))
+ cached = _cache_get(cache_key)
+ if cached:
+ return cached
+
+ data = _api_post("search", params, profile_id=CURRENT_PROFILE_ID)
+ if not data:
+ return {"items": [], "total": 0, "page": page, "page_size": effective_size}
+
+ # Find the matching section
+ target_items = []
+ target_total = 0
+ for s in data.get("sectionList", []):
+ if s.get("sectionId") == section_id:
+ target_items = s.get("items", [])
+ target_total = s.get("total", 0)
+ break
+
+ result = {
+ "items": [_parse_item(i) for i in target_items],
+ "total": target_total,
+ "page": page,
+ "page_size": effective_size,
+ }
+ _cache_set(cache_key, result)
+ return result
+
+
+def get_session(session_id):
+ """Fetch a single session by its RainFocus ID."""
+ cache_key = "session:" + session_id
+ cached = _cache_get(cache_key)
+ if cached:
+ return cached
+
+ data = _api_post("session", {"id": session_id})
+ if not data:
+ return None
+ items = data.get("items", [])
+ if not items:
+ return None
+ result = _parse_item(items[0])
+ _cache_set(cache_key, result)
+ return result
+
+
+def get_events():
+ """Return the list of known Cisco Live events."""
+ return EVENTS
+
+
+def get_technologies():
+ """Return the list of technology filter categories."""
+ return TECHNOLOGIES
+
+
+def get_levels():
+ """Return the list of technical level filters."""
+ return LEVELS
+
+
+def get_session_types():
+ """Return the list of session type filters."""
+ return SESSION_TYPES
+
+
+def brightcove_url(video_id):
+ """Build a Brightcove player URL for the given video ID."""
+ return (
+ "https://players.brightcove.net/{account}/{player}_default/"
+ "index.html?videoId={vid}"
+ ).format(account=BRIGHTCOVE_ACCOUNT, player=BRIGHTCOVE_PLAYER, vid=video_id)
+
+
+def discover_events():
+ """
+ Try to discover events dynamically from API search results.
+ Falls back to hardcoded EVENTS list if discovery fails.
+
+ Returns:
+ List of event dicts with id, name, sessions
+ """
+ try:
+ # Do a search with no filters to get all events
+ result = search_sessions(page=0, page_size=1, filters={})
+
+ # Extract unique events from section headers
+ events_found = []
+ data = _api_post("search", {"type": "session", "size": "1", "from": "0"})
+ if not data:
+ return EVENTS
+
+ for section in data.get("sectionList", []):
+ heading = section.get("sectionHeading", "")
+ if heading and heading not in [e["name"] for e in events_found]:
+ # Try to infer an ID from the name
+ event_id = heading.lower().replace(" ", "").replace("-", "")
+ events_found.append({
+ "id": event_id,
+ "name": heading,
+ "sessions": section.get("total", 0)
+ })
+
+ if events_found:
+ return events_found
+ except Exception:
+ pass
+
+ # Fallback to hardcoded list
+ return EVENTS
+
+
+def _parse_item(item):
+ """Extract the fields we care about from a raw RainFocus session item."""
+ videos = item.get("videos", [])
+ video_ids = [v["url"] for v in videos if v.get("url")]
+
+ participants = item.get("participants", [])
+ speakers = [p.get("fullName", p.get("globalFullName", "")) for p in participants]
+ speaker_photos = [p.get("photoURL", p.get("globalPhotoURL", "")) for p in participants]
+
+ # Get technology/level from attributevalues
+ techs = []
+ level = ""
+ session_type = ""
+ for av in item.get("attributevalues", []):
+ attr = av.get("attribute", "")
+ val = av.get("value", "")
+ if attr == "Technology":
+ techs.append(val)
+ elif attr == "Technical Level":
+ level = val
+ elif attr in ("Session Type", "Type"):
+ session_type = val
+
+ duration = 0.0
+ if item.get("times"):
+ duration = float(item["times"][0].get("length", 0)) * 60 # minutes to seconds
+
+ return {
+ "id": item.get("sessionID", item.get("externalID", "")),
+ "code": item.get("code", ""),
+ "title": item.get("title", ""),
+ "abstract": item.get("abstract", ""),
+ "event": item.get("event", ""),
+ "event_label": item.get("eventLabel", ""),
+ "event_code": item.get("eventCode", ""),
+ "speakers": speakers,
+ "speaker_photos": speaker_photos,
+ "technologies": techs,
+ "level": level,
+ "session_type": session_type or item.get("type", ""),
+ "video_ids": video_ids,
+ "duration": duration,
+ "has_video": len(video_ids) > 0,
+ }
diff --git a/plugin.video.ciscolive/resources/media/fanart.jpg b/plugin.video.ciscolive/resources/media/fanart.jpg
new file mode 100644
index 000000000..d52668942
Binary files /dev/null and b/plugin.video.ciscolive/resources/media/fanart.jpg differ
diff --git a/plugin.video.ciscolive/resources/media/icon.png b/plugin.video.ciscolive/resources/media/icon.png
new file mode 100644
index 000000000..efd8a1fa8
Binary files /dev/null and b/plugin.video.ciscolive/resources/media/icon.png differ
diff --git a/plugin.video.ciscolive/resources/settings.xml b/plugin.video.ciscolive/resources/settings.xml
new file mode 100644
index 000000000..e08f05eea
--- /dev/null
+++ b/plugin.video.ciscolive/resources/settings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+