# 040 — Playback tracking, resume, watched, Kodi mirror

## 1. What is tracked (decisions 2, 3)

- Every movie and episode played through the canonical URL (§2), from any
  part of the addon (My lists, search, TMDb, ČSFD, AI, details dialog,
  Continue, autoplay), in **any skin** — tracking does not depend on
  skin.milionar; the skin is where the data is shown.
- Not tracked: MyTV / MyCinema playlists (their URLs never set the
  now-playing contract), Kodi library `.strm` playback (`play_*_from_library`
  keeps its current behaviour), trailers, TTS audio, manual Webshare files
  without a TMDb id.

## 2. Canonical play URL and resolver

- `urls.movie(tmdb)` → `plugin://plugin.video.milionar/?action=mplay&type=movie&tmdb=<id>`,
  `urls.episode(show, s, e)` → `…&type=episode&tmdb=<show>&s=<s>&e=<e>`.
  Exactly these strings everywhere (listings, rows, playlists, mirror), so
  Kodi's own state and ours share one key.
- Listing / row items: `IsPlayable=true`, path = canonical URL, full
  `InfoTagVideo` (title, year, plot, rating, mediatype, tvshowtitle, season,
  episode, duration, playcount, resume point, uniqueid tmdb), art.
- `resolver.py` (`action=mplay`):
  1. read and consume a matching play request (020 §3); default
     `src = setting` (the addon's existing "default movie action" /
     autoplay-first-item settings decide dialog vs automatic pick);
  2. `src=last`: reuse `last_ident`/`last_provider` from the store if the
     provider still resolves it; otherwise automatic pick
     (`calculate_advanced_score`, preferred quality/language settings);
  3. `src=dialog`: the existing `DialogWcsSearch` source dialog, modal, before
     `setResolvedUrl`; cancel → `setResolvedUrl(False)` and nothing is
     recorded;
  4. `setResolvedUrl(True, li)` with the stream; then write the now-playing
     contract with `stream_url`, `ident`, `provider`, `origin`;
  5. info dialog with the game is scheduled exactly as today
     (`schedule_info_dialog` / `schedule_episode_info_dialog`).
- Old entry points (`play_movie_from_addon`, `search_and_play_episode`,
  `play_episode_from_addon`, `play_next_episode_with_metadata`, context items,
  details dialog play buttons) are rewired to
  `RunPlugin(action=mplay_req…)` → `PlayMedia(<canonical>)`. Their source
  selection UI does not change for the user.

## 3. Thresholds (decision 1)

New addon settings (category "Přehrávání", cs/sk/en), defaults like Kodi:

| Setting | Default | Meaning |
|---------|---------|---------|
| `lib_resume_min_seconds` | 180 | below this a stop saves nothing (still logged as `short`) |
| `lib_watched_percent` | 90 | at or above this a stop marks watched and clears resume |

- Watched also when playback ends naturally (`onPlayBackEnded`) after the
  resume threshold, and when the next episode starts via the countdown
  (play now or timeout) — the current episode is marked watched first.
- A watched item replayed: playcount +1 on watched, resume rules as above.
- Total time comes from `Player.getTotalTime()`; if it is 0 (some streams)
  the TMDb runtime is used for the percentage.

## 4. Tracker (`tracker.py`, service main loop)

- `onAVStarted` → read `milionar.np`; accept it only if `stream_url` equals
  `getPlayingFile()` (or the contract is < 20 s old and the player file is
  the resolved Webshare/Kraska URL family); otherwise ignore the playback.
- Every 10 s while playing, and on pause, seek, stop, end: write
  `resume_pos/resume_total`, `last_*` file fields, `last_played_at`,
  `titles.last_activity_at`; clear `hidden_from_continue_at`.
- `onPlayBackStopped/Ended/Error` → apply §3, close the `play_log` row, bump
  the widget token, write the mirror (§6) 3 s later.
- Kodi crash or power loss: the 10 s autosave bounds the loss; on service
  start an open `play_log` row is closed with its last position.
- Resume start (`from=resume` or Kodi's own "Resume from …"): Kodi seeks
  itself from the mirrored bookmark (`PlayMedia(url,resume)`,
  `Application.cpp` 21.3 uses the bookmark of `original_listitem_url`). If
  `getTime()` is still < `start_at - 5` 3 s after `onAVStarted` the tracker
  seeks once (`seekTime`) as a safety net.

## 5. Manual marks (context menu, 080)

Movie, episode, season, whole series: watched / unwatched; season and series
marks apply to aired regular episodes only (specials only when the season-0
item itself is marked). "Remove from Continue" sets
`hidden_from_continue_at`; any new activity on that title clears it.

## 6. Mirror into Kodi and adopting Kodi's changes (decision 13)

- After every state change of an item the addon writes Kodi's record for its
  canonical URL with JSON-RPC `Files.SetFileDetails {file, media: "video",
  playcount, lastplayed, resume: {position, total}}`, sets `mirrored_at`
  and remembers the mirrored values.
- Kodi changes the same record itself (its own "Mark as watched", "Remove
  resume point", its stop-time bookmark) without announcing it
  (`VideoDatabase::SetPlayCount` announces only library items). Adoption:
  - when a listing or row is built, the addon reads Kodi's values for the
    shown items (`Files.GetFileDetails` per item, or one read-only query on
    `MyVideos131.db` `files`/`bookmark` by `strFilename` for ≥ 20 items) and
    compares with the last mirrored values; a difference that is newer than
    `mirrored_at` is a user change made through Kodi → applied to the store
    and re-mirrored;
  - guaranteed path: while a Videos window or Home row shows an addon path
    (`Container.FolderPath` / focused row starts with
    `plugin://plugin.video.milionar/`), the service compares Kodi's values
    for the focused container's items every 5 s (read-only query, < 5 ms)
    and adopts differences, then bumps the token / `Container.Refresh`;
  - L1 also verifies whether Kodi refreshes the container by itself after
    its own mark (faster path); the 5 s check stays either way;
  - the tracker does the same check for the item it just stopped.
- Our values always win over Kodi's automatic stop-time bookmark (the tracker
  wrote last, 3 s after stop).

## 7. Result (L1, 2026-10-01)

Built (all under `wcs/library2/` unless noted):

- `urls.py`, `play.py` (`request()` writes the options to
  `Window(10000).Property(milionar.play_req)` and calls
  `PlayMedia(<canonical>, resume|noresume)`; `consume()` ignores foreign or
  > 15 s old requests), `resolver.py` (`action=mplay`: source via dialog /
  auto / last, provider stream, contract `milionar.np`, `setResolvedUrl`,
  recently-played entry and the info dialog with the game exactly as the old
  paths did), `tmdb.py` (cache-backed movie / show / season / episode data).
  `main.py` handles `mplay`, `mplay_req` and the dev listing without
  importing the router.
- Rewired: `utils.play_movie_from_addon`, `utils.search_and_play_episode`,
  `utils.play_episode_from_addon` start a tracked play when a TMDb id is known
  (and, for episodes, no MyTV channel). Everything else (MyTV / MyCinema
  playlists, Kodi library `.strm`, manual files) is unchanged and untracked.
  `play_next_episode_with_metadata` / autoplay is L2.
- `policy.py` (pure rules: thresholds, `reconcile`, `np_matches`),
  `tracker.py` (service thread: Player callbacks only enqueue; the loop polls,
  autosaves every 10 s, applies the thresholds on stop / end, closes the
  `play_log`, bumps the widget token, mirrors 3 s later, safety seek 3 s after
  start when Kodi did not resume), `mirror.py` (`Files.SetFileDetails`
  push, `Files.GetFileDetails` read, `check()` adoption, 5 s check of the
  items of a shown addon container), schema **v2** (`mirrored_playcount`,
  `mirrored_resume_pos`, open play_log rows closed as `crash` at service
  start). Settings `lib_resume_min_seconds` (180) and `lib_watched_percent`
  (90), cs/sk/en, in the general category.
- Test aids: harness ops `addon_setting` and `call`,
  `scripts/library_harness/live.py` (setup / play / stop / state),
  `dev_list.py` (a listing of the fixture titles as canonical URLs, only
  while the fixture provider is on; L3 replaces it).
- **Unit tests: 48 green** (thresholds incl. 179 s / 181 s / 89.98 % / 90 % /
  natural end / zero total -> runtime, reconcile cases, `np` matching, schema
  migration v1 -> v2, tracker engine with a fake player: autosave every 10 s,
  mirror after 3 s, ignored playback without contract, safety seek, crash
  recovery, mirror adoption).
- **Live scenarios (fixture provider, thresholds lowered to 10 s)**:
  1. movie 22 s then stop: `resume_pos` 21.9 / 240, source file stored,
     mirrored; Kodi's `Files.GetFileDetails` shows the same resume 21.9 / 240.
  2. `from=resume`: Kodi resumed by itself (28 s on screen 7 s after the
     start); the safety seek was not needed.
  3. 40 s film played to the end: `outcome watched`, playcount 1, resume
     cleared, Kodi shows playcount 1.
  6. Kodi's own `ToggleWatched` on the in-progress "Fixture Film 1" in the dev
     listing: within 8 s the store had playcount 1, resume 0 and the mirror
     values re-written.
  7. a plain local file played without contract: "playback ignored", store
     unchanged (MyTV / MyCinema / `.strm` take the same road).
  8. Kodi killed (`kill -9`) 30 s into an episode: saved position 20.2 s
     (<= 10 s behind), the open log row was closed as `crash` at the next
     start.
- Also checked live: without the fixture provider the real path opens the
  existing source dialog inside the resolve call (it searched Webshare for
  the fixture title; nothing was played).
- Facts learned: `Files.SetFileDetails` / `GetFileDetails` work for plugin
  URLs; `Player.getPlayingFile()` returns the resolved stream URL (so the
  exact `stream_url` match works); the "does Kodi refresh the container after
  its own mark" question is left open, the 5 s check stays.
- Not run: resolving and playing a real Webshare / Kraska stream and the
  source dialog's autoplay variants (the user's real-playback test, L6).
- Fixture rows were removed from the user's `library.db` and Kodi's records
  reset after the tests; the fixture settings are back to defaults.

### 7a. Correction after the user's first real test (2026-10-01)

The movie from My movies was tracked (Terminátor 2: 402 / 9224 s). An episode
started from **My series** was not: that route is `tmdb_episode_search` ->
`SeriesSearch.search_and_handle_episode`, an entry point the first L1 pass had
missed (it resolves for Kodi itself and used the old `action=play`). Found in
`kodi.log` ("playback ignored (no matching contract)" for a Webshare URL).
Fixed and re-checked live (the route now runs `resolver.run` in its own
resolve call, "tracking episode 42009" in the log):

- `search_and_handle_episode`: canonical resolver when the series id is known
  from the URL or found by name (`library2/ids.py`: My series first, then a
  cached TMDb search); MyTV channel ids keep the old path.
- `play.request(..., pick=...)`: a caller that already chose a source hands
  `{ident, name, provider}` to the resolver, which then shows no dialog. Used
  by the router action `show_search_dialog` (library / context menu / search
  dialog callers) and the AI chat "Play" button when a TMDb id is known.
- Still outside tracking by design: MyTV / MyCinema, Kodi library `.strm`,
  manual Webshare files and the generic text search (`action=search`,
  `perform_search_and_display`; no media type or id). `tmdb_movie_search` in
  the router calls a function that does not exist (old dead route).
- Unit tests: 50 green (+ request / consume with pick, foreign and stale
  requests).

