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 @@ + + + + + + + + + +