# 060 — Widget endpoints in the addon

Status: approved 2026-09-30. Built in F2.

## 1. Requirements

- Home must feel as fast as Estuary: a row appears within the time Estuary
  needs for a library row (measured in F0/F2, 120 §3).
- A widget call **never** waits on the network. It serves a cache file; a
  missing or stale cache is refreshed in the background by the service.
- Widgets work only when the addon is enabled; they return an empty listing
  (not an error) when the source is not configured (no TMDb key, not logged
  in to Kraska, no AI key).

## 2. Fast entry

`main.py` checks the action **before** importing `wcs.router` (which imports
nearly every module, 010 §1):

```python
params = dict(parse_qsl(sys.argv[2][1:]))
if params.get('action', '').startswith('widget_'):
    from wcs.skin import widgets
    widgets.run(int(sys.argv[1]), params)
else:
    from wcs import router
    router.router(sys.argv[2][1:])
```

`wcs/skin/widgets.py` imports only `json`, `os`, `xbmc*` at module level and
builds listings from cache files. Budget: ≤ 150 ms from plugin start to
`endOfDirectory` per row with a warm cache (measure with `time.perf_counter`
logged at `LOGDEBUG`, and Kodi's own plugin timing lines).

## 3. Item format

- `xbmcgui.ListItem` with `setArt` (`poster`, `fanart`, `landscape`, `thumb`,
  `clearlogo` when known), `InfoTagVideo` (`title`, `year`, `plot`, `rating`,
  `mediatype`, `tvshowtitle`, `season`, `episode`, `duration`,
  `uniqueid {'tmdb': …}`), so Estuary widget layouts show the same labels as
  library items.
- Items that open a folder (Kraska category, ČSFD chart): `isFolder=True`,
  path = the existing listing action; Estuary opens them in Videos
  (`widget_target=videos`).
- Items that run an action (open MyTV on a channel, play, start the game,
  resume): `isFolder=False`, property `milionar_run` =
  `plugin://plugin.video.milionar/?action=…`. The skin row sets
  `onclick_condition = !String.IsEmpty(ListItem.Property(milionar_run))` and
  `onclick_action = RunPlugin($INFO[ListItem.Property(milionar_run)])` (Estuary
  widget include params), so Kodi never tries to "play" a dialog URL.
- Library items in `widget_in_progress` run
  `action=lib_resume&dbtype=movie|episode&dbid=<id>`: the addon calls
  JSON-RPC `Player.Open {"item": {"movieid"|"episodeid": id}, "options":
  {"resume": true}}`, so Kodi resumes from its own bookmark and the `.strm`
  goes through the normal addon playback path.

## 4. Cache and refresh

- Cache files: `addon_data/plugin.video.milionar/skin_cache/<widget>.json`
  (`{"built": <epoch>, "items": [...]}`), written atomically.
- Builders live next to the data they read (`wcs/skin/widgets.py` calls
  existing modules: `MediaChannelManager`, `ProgressManager`,
  `user_data.load_recently_played`, `SCBrowser.browse_root`, TMDb/ČSFD
  clients). Builders run in the **service** (network allowed) or inline in
  the addon right after a local data change (no network).
- Refresh token: after writing any cache the writer sets
  `Window(10000).setProperty('milionar.widgets.token', <epoch ms>)`; rows put
  the token in their path, so Kodi reloads them.
- Invalidation hooks (call `wcs.skin.widgets.invalidate([...])`): channel
  created/renamed/deleted/edited, media added/removed from a channel, playback
  stopped (PlayerMonitor), recently played changed, settings changed
  (provider login/logout, `onSettingsChanged` in the service monitor).
- A cache miss in a widget call returns the last file (or empty) and sends
  `NotifyAll(plugin.video.milionar, skin_widget_refresh, {"names": [...]})`;
  the service monitor (`onNotification`) rebuilds and bumps the token.
- Service schedule for network widgets: TMDb popular 6 h, Kraska root 24 h,
  ČSFD charts static, MyCinema recommended 24 h; never while video plays.

## 5. Widget catalogue

| Action | Items | Source | Click | Network |
|--------|-------|--------|-------|---------|
| `widget_mytv_channels` | channels (label, composite as `landscape`/`thumb`) | `MediaChannelManager(addon,'tv')`, `generate_channel_composite` | `my_tv_dialog&channel_id=<id>` (new param: open focused on that channel) | no |
| `widget_mytv_next` | next episode per series in MyTV channels (max 20) | `ProgressManager.get_progress` (→ `ChannelHistory`) + channel series; episode metadata from the TMDb cache | play that episode (existing `play_episode_from_addon` action) | no (missing episode metadata → service) |
| `widget_mycinema_channels` | cinemas | `MediaChannelManager(addon,'movie')` | `my_cinema_dialog&channel_id=<id>` | no |
| `widget_mycinema_recommended` | decided: TMDb `/movie/{id}/recommendations` for every film in all cinemas, merged by how many source films recommend a title, then TMDb vote average; films already in a cinema removed; top 20 | TMDb, cache 24 h | `default_movie_action` like addon listings | service |
| `widget_in_progress` | addon progress + library in-progress, by last played | `user_data.load_recently_played` entries with a resume point (`ChannelHistory.get_resume_point`), JSON-RPC `VideoLibrary.GetMovies/GetEpisodes` filter `inprogress` | library item → `lib_resume` (§3); addon item → the action the Continue watching dialog uses | no |
| `widget_recently_played` | recently played | `user_data.load_recently_played` | as in the addon listing | no |
| `widget_game_topics` | titles to play about: recently played + random library titles (max 20) | the two above | `play_text_game&title=…&tmdb_id=…&media_type=…` | no |
| `widget_ai_recent` | "AI chat" tile first, then the last 30 AI-recommended titles, newest first | new store (§6) | tile: `ai_chat_recommendation`; title: `default_movie_action` / series detail like addon listings | no |
| `widget_kraska_root` | Kraska categories | `SCBrowser.browse_root`, cached | folder → `kraska_browse_path` | service |
| `widget_tmdb_popular_movies` / `_series` | popular titles (20) | TMDb, cached | `default_movie_action` like addon listings | service |
| `widget_csfd_charts` | chart entries (static) | existing `csfd_*` actions | folder | no |

## 6. AI recommendation history (new store)

Decided: the addon keeps no history of AI recommendations today (only
`ai_usage_stats.json` and channel histories), so F2 adds one.

- `wcs/ai/RecommendationHistory.py`: `add(items)` / `load()`; file
  `addon_data/plugin.video.milionar/ai_recommendations.json`, list of
  `{tmdb_id, media_type, title, year, poster, fanart, plot, added}`, newest
  first, de-duplicated by `(media_type, tmdb_id)`, capped at 30.
- Written in `AIChatRecommendationDialog._tmdb_worker`
  (`wcs/ai/DialogAIChatRecommendation.py`): every AI answer reaches the UI
  through `fetch_and_display_recommendations` (called only from the AI
  response handler, ~line 1854; `DialogMyMovies`/`DialogMySeries` override
  it and call `super`, the Hub subclasses the dialog), and `_tmdb_worker`
  matches each title on TMDb. Collect the matched items (movie/tv only, not
  collections) and call `RecommendationHistory.add(items)` once after the
  loop, then `widgets.invalidate(['widget_ai_recent'])`.
- No API calls are made to test it (no keys, `AGENTS.md`); verify with a
  hand-written fixture file and the widget capture.

## 7. Result (F2, 2026-09-30)

- `main.py` fast path (`widget_*`, `skin_run`) -> `wcs/skin/widgets.py`; local
  rows (MyTV/MyCinema channels, next episodes, in progress, recently played,
  game topics, AI history) are built on demand from the profile JSON and
  JSON-RPC, network rows read `skin_cache/<widget>.json` written by the service
  (`wcs/skin/refresher.py`: TMDb popular 6 h, Kraska root 24 h, MyCinema
  recommendations 24 h, never during playback, first run 20 s after start).
- Timing inside Kodi (`Files.GetDirectory`, 4 calls each): channels 50-85 ms,
  next episodes 58-65 ms, in progress 57-102 ms, game topics 60-128 ms, AI
  52-82 ms, TMDb 54-61 ms, ČSFD 51-59 ms; all within the 150 ms budget.
- Refresh token: cache writes and `widgets.invalidate()` set
  `Window(Home).Property(milionar.widgets.token)`. Invalidation hooks: channel
  saves (`MediaChannelManager._save_channels`), AI recommendations
  (`DialogAIChatRecommendation._tmdb_worker` -> `RecommendationHistory.add`),
  and the service monitor on `Player.OnStop` / `VideoLibrary.OnUpdate`.
  Provider properties `milionar.provider.{kraska,ai,webshare}` are set by the
  service and refreshed on settings change.
- New actions: `skin_run`, `skin_browse`, `skin_game`, `skin_search`,
  `lib_resume`, and a `channel_id` parameter for `my_tv_dialog` /
  `my_cinema_dialog` (`SetFocus(2000,idx,absolute)` because `selectItem` is
  ignored while the window initialises).

## 8. Loading order and freshness (2026-10-04)

- Kodi starts the fetch of every skin list when the skin loads. To keep the first screen fast, `gen_slots.py` gives every
  section except `cont` a gated content variable (`MilionarContent_<sec>_<n>`): until the service sets
  `Window(Home).Property(milionar.go.<sec>)` (or the user focuses the menu item) its URL is a native, empty smart
  playlist (`playlists/milionar-empty.xsp`). It must not be an empty string: Kodi does not fetch when a list's URL
  changes from empty to a real URL.
- `wcs/skin/warm.py` (service thread): waits until the first screen finished loading, then per section opens the gate and
  shows the group off-screen (`milionar.warm`, Home.xml) until the lists report they are done; the same for every
  section whose row revision changed (`refresh.publish` -> `milionar.warm.queue`). Only while Home is active, nothing plays.
- The row URL carries `reload=` from the persistent skin string `mil.<sec>.<n>.rev` (written by `refresh.publish`), so after a
  restart an unchanged row has the same URL as before and is fetched once.
- The row snapshot cache identity no longer depends on Window properties or credentials (`provider_keys` flags file).

## 9. First screen from skin strings (2026-10-04)

- Section `cont` ("Pokračovat") has no plugin rows. `gen_start.py` generates `Includes_MilionarStart.xml`: card layouts copied
  from `InfoWallMovieLayout` / `InfoWallEpisodeLayout` with every `ListItem` info a static item lacks replaced by a property, and
  static list items reading `Skin.String(mil.st.<slot>.<item>.<field>)` (12 items, fields in `gen_start.FIELDS`).
- `wcs/skin/startrows.py` (service thread) builds each slot with the same builders as the plugin rows, decorates them with
  `context.attach`, reads them back into plain fields and writes only the strings that differ. Triggers: slot configuration
  or persisted revision (`mil.cont.<n>.rev`) changed, `refresh.publish`, the slot editor (`startrows.request()`).
- Kodi keeps skin strings in the skin settings, so the rows of the last session are drawn in the first frame.
- A static list is only built while visible, so the panel's visibility is `count > 0`, never `Container.NumItems`.
- Log prefix `[Milionář Start]` (LOGDEBUG); checks: `scripts/skin_harness/test_startrows.py`.
