Stop trusting Plex's Date Added on its own

Jess spotted that dates looked like file dates rather than library-add dates.
He is right, and MediaShelf was not the culprit: it reproduces Plex's addedAt
exactly (verified 500/500 identical to the second). Plex's own field is what
follows the file — replace or re-encode one and Date Added resets while the
item, its ratingKey and its watch history all survive.

Measured on the live library, comparing addedAt against lastViewedAt where both
exist: 55 of 509 movies (10.8%) and 306 of 1,393 TV Show Archive items (22.0%)
were watched BEFORE they were "added" — 19% overall. 2001: A Space Odyssey
reports added 2026-07-31, last watched 2017-08-26.

That is not cosmetic. pre_history is derived from added_at, so an old item whose
file was replaced looks post-coverage and gets promoted into the CONFIDENT
reclaim pool, which is the one pool meant to be trustworthy.

A completed play proves the item already existed, so added_at is now
MIN(provider_added_at, first_watched_at). Plex's raw value is kept in
provider_added_at, added_at_source records which applied, and the item drawer
explains the substitution instead of quietly disagreeing with Plex. Unwatched
items keep Plex's value since nothing contradicts it. first_seen_at is also
recorded now and is authoritative for anything added from here on.

Plex's API has no better field; the true insert time is only in Plex's own
metadata_items.created_at on Loki.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
This commit is contained in:
Jess Hallsworth 2026-09-10 17:35:48 +00:00
parent d12cbc62ee
commit e819548ff2
No known key found for this signature in database
9 changed files with 234 additions and 15 deletions

View file

@ -160,6 +160,7 @@ class Ingest:
self._progress(scan_id, "rolling up")
self._apply_watch_rollups(provider_id)
self._correct_added_at(provider_id)
self._rollup_seasons(provider_id)
self._rollup_shows(provider_id)
self._apply_pre_history(provider_id, coverage)
@ -373,17 +374,18 @@ class Ingest:
(provider_id, item.provider_item_id),
)
primary = item.parts[0].file_path if item.parts else None
now = int(time.time())
vals = (
provider_id, library_id, item.provider_item_id, "movie", item.guid, None,
item.title, item.sort_title, item.year, None, None,
item.added_at, item.updated_at, 0, item.size_bytes, item.duration_ms,
len(item.parts), primary, item.resolution, item.video_codec,
item.view_count, "present", scan_id, scan_id,
item.added_at, item.added_at, item.updated_at, 0, item.size_bytes,
item.duration_ms, len(item.parts), primary, item.resolution,
item.video_codec, item.view_count, "present", scan_id, scan_id, now,
)
if existing:
self.db.execute(
"UPDATE media_item SET library_id=?, guid=?, title=?, sort_title=?, year=?, "
"added_at=?, updated_at=?, size_bytes=?, duration_ms=?, part_count=?, "
"provider_added_at=?, updated_at=?, size_bytes=?, duration_ms=?, part_count=?, "
"primary_path=?, resolution=?, video_codec=?, provider_view_count=?, "
"status='present', last_seen_scan_id=? WHERE id=?",
(library_id, item.guid, item.title, item.sort_title, item.year,
@ -397,10 +399,11 @@ class Ingest:
cur = self.db.execute(
"INSERT INTO media_item (provider_id, library_id, provider_item_id, kind, "
"guid, show_guid, title, sort_title, year, parent_id, season_number, "
"added_at, updated_at, episode_count, size_bytes, duration_ms, part_count, "
"primary_path, resolution, video_codec, provider_view_count, status, "
"first_seen_scan_id, last_seen_scan_id) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", vals,
"added_at, provider_added_at, updated_at, episode_count, size_bytes, "
"duration_ms, part_count, primary_path, resolution, video_codec, "
"provider_view_count, status, first_seen_scan_id, last_seen_scan_id, "
"first_seen_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", vals,
)
item_id = cur.lastrowid
result.items_added += 1
@ -481,7 +484,7 @@ class Ingest:
ep_id = row["id"]
self.db.execute(
"UPDATE episode SET season_item_id=?, episode_number=?, title=?, "
"added_at=?, duration_ms=?, size_bytes=?, part_count=?, "
"provider_added_at=?, duration_ms=?, size_bytes=?, part_count=?, "
"status='present', last_seen_scan_id=? WHERE id=?",
(season_id, ep.episode_number, ep.title, ep.added_at, ep.duration_ms,
ep.size_bytes, primary_count, scan_id, ep_id),
@ -489,10 +492,12 @@ class Ingest:
else:
cur = self.db.execute(
"INSERT INTO episode (season_item_id, provider_item_id, episode_number, "
"title, added_at, duration_ms, size_bytes, part_count, status, last_seen_scan_id) "
"VALUES (?,?,?,?,?,?,?,?, 'present', ?)",
"title, added_at, provider_added_at, duration_ms, size_bytes, part_count, "
"status, last_seen_scan_id, first_seen_at) "
"VALUES (?,?,?,?,?,?,?,?,?, 'present', ?, ?)",
(season_id, ep.provider_item_id, ep.episode_number, ep.title,
ep.added_at, ep.duration_ms, ep.size_bytes, primary_count, scan_id),
ep.added_at, ep.added_at, ep.duration_ms, ep.size_bytes,
primary_count, scan_id, int(time.time())),
)
ep_id = cur.lastrowid
self.db.execute("DELETE FROM media_part WHERE episode_id=?", (ep_id,))
@ -546,7 +551,7 @@ class Ingest:
""")
c.execute("""
UPDATE episode SET watch_count=0, partial_count=0, abandoned_count=0,
last_watched_at=NULL, last_touched_at=NULL
last_watched_at=NULL, last_touched_at=NULL, first_watched_at=NULL
""")
agg = """
@ -584,9 +589,54 @@ class Ingest:
partial_count = COALESCE((SELECT partial FROM _wagg WHERE pid = episode.provider_item_id), 0),
abandoned_count = COALESCE((SELECT abandoned FROM _wagg WHERE pid = episode.provider_item_id), 0),
last_watched_at = (SELECT last_watched FROM _wagg WHERE pid = episode.provider_item_id),
first_watched_at = (SELECT first_watched FROM _wagg WHERE pid = episode.provider_item_id),
last_touched_at = (SELECT last_touched FROM _wagg WHERE pid = episode.provider_item_id)
""")
def _correct_added_at(self, provider_id: int) -> None:
"""Repair added_at where Plex's value is provably wrong.
Plex's addedAt tracks the FILE, not the library entry: replace or
re-encode a file and Date Added resets while the item and its watch
history survive. Measured on the live server, 19% of items report a
last-watch EARLIER than their added date.
A play is proof the item already existed, so the first completed view is
a lower bound on the true add date. That is not the exact date Plex
never kept, but it is strictly better than a value we can show is
impossible and it matters, because pre_history is derived from
added_at and a wrongly-recent date promotes an item into the CONFIDENT
reclaim pool when it belongs in the uncertain one.
Items nobody ever watched keep Plex's value; nothing contradicts it.
first_seen_at is authoritative for anything MediaShelf sees appear.
"""
c = self.db.conn
c.execute("UPDATE episode SET added_at = provider_added_at "
"WHERE provider_added_at IS NOT NULL")
c.execute("""
UPDATE episode SET added_at = first_watched_at
WHERE first_watched_at IS NOT NULL AND first_watched_at > 0
AND (added_at IS NULL OR added_at > first_watched_at)
""")
c.execute("UPDATE media_item SET added_at = provider_added_at, "
"added_at_source = 'provider' "
"WHERE kind = 'movie' AND provider_added_at IS NOT NULL "
"AND provider_id = ?", (provider_id,))
cur = c.execute("""
UPDATE media_item SET added_at = first_watched_at,
added_at_source = 'first_watch'
WHERE kind = 'movie' AND provider_id = ?
AND first_watched_at IS NOT NULL AND first_watched_at > 0
AND (added_at IS NULL OR added_at > first_watched_at)
""", (provider_id,))
corrected = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
if corrected:
self._warn(
"%d movie(s) had a Plex addedAt later than their first play; "
"corrected to the first play date" % corrected)
def _rollup_seasons(self, provider_id: int) -> None:
c = self.db.conn
c.execute("""
@ -614,6 +664,13 @@ class Ingest:
WHERE kind = 'season' AND provider_id = ?
""", (provider_id,))
c.execute("""
UPDATE media_item SET added_at_source = CASE
WHEN provider_added_at IS NOT NULL AND added_at < provider_added_at
THEN 'first_watch' ELSE 'provider' END
WHERE kind = 'season' AND provider_id = ?
""", (provider_id,))
# distinct watchers across the season's episodes
c.execute("""
UPDATE media_item SET distinct_watcher_count = COALESCE((