gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf8ffa4dbf8ffa4dbtracker: report 032 + The Remaining + #39mref8ffa4db/lib/merge.hl

29.5 KB

  1. // lib/merge.hl — DUPLICATE TITLES MERGED (tracker.worldapi.org#31, mission 026). The architect: "Every TMDB title exists once in our
  2. // data; watches, follows and seasons all hang on that one record."
  3. //
  4. // WHERE THEY CAME FROM: the old tracker. On the live copy (2026-10-03) 68 type+tmdbId pairs were held by 157 records — every one
  5. // of them a migrated row (Mongo `oldId`, created 2022–2023 in runs of consecutive ids: Archive 81 ×6, Peripheral ×4, Ally
  6. // McBeal ×2 …); not one was made by this app. The app's two creating paths look the title up first (search.hl importTitle,
  7. // people.hl fillStep → search.hl titleIdByTmdb) and their records carry `oldId = tmdb-<tv|movie>-<id>`, which the table's
  8. // `!oldId` UNIQUE index refuses twice. Seasons the same way: 449 migrated seasons name a title that does not list them (the old
  9. // tracker showed a title's seasons by query); the daily sync goes by the list, so it made a SECOND season where TMDB has that
  10. // number (Reacher: the creator's S3 watches sat on the unlisted migrated S3, the page showed the sync's empty S3).
  11. //
  12. // THE REPAIR (in the app, on its own clock like the short-id backfill — no TMDB, one item per tick; jobs.hl `mergeTick`):
  13. // 1. per duplicate group: the KEEPER = the record with follows/watches (most first), else the one with its old short id
  14. // (shortids.hl), else the oldest (the Mongo id's time, else `imported`). Into it, from every other record:
  15. // follows + watches (a user who already has the keeper's: the moved row gets `target`/`show` null + `mergedInto` = the
  16. // row it repeats — no row is deleted), seasons the keeper lacks (moved: `show` = keeper), seasons it has (merged
  17. // episode by episode, below), cast + crew entries it lacks, the persons' credits, the timelines (franchises.hl
  18. // titleMerged), empty text/id fields, the poster file if the keeper has none. The keeper takes the plain slug of its
  19. // title when a duplicate held it (Archive 81: "Archive-82" → "Archive-81"; its former slug in `oldSlugs`).
  20. // The other record becomes a TOMBSTONE (shows.hl): `mergedInto`, `mergedAt`, `mergedTmdbId` (tmdbId null), `mergedSeasons`
  21. // (seasons []). Its slug and short id lead to the keeper (shows.hl movedSlugs → project.hl 301, shortids.hl shortIdPath).
  22. // 2. per title with seasons that name it but are not in its list (STRAY seasons): such a season with a listed TWIN (same
  23. // number) is merged into the twin; one without is LINKED into the list only when someone watched it or one of its episodes
  24. // (recorded in the title's `linkedSeasons`). The other strays stay as they are: linking all 393 with episodes on the live
  25. // copy put 1,091 never-watched old talk-show episodes on the creator's /my/unwatched (1,273 → 2,364 rows). When the sync
  26. // later makes a season of that number, the next start merges the stray into it.
  27. // MERGING A SEASON INTO ITS TWIN: the season's watches → the twin; each episode: the twin's episode of that number gets its
  28. // watches (the episode becomes `mergedInto` = that one), an episode the twin lacks MOVES into the twin. The season gets
  29. // `mergedInto` = the twin, `mergedEpisodes` (episodes []).
  30. // 3. tracker#39 (mission 032): per listed season with two or more live EPISODES of one number (DOUBLE EPISODES: a migrated
  31. // episode whose TMDB id TMDB had replaced + the one the sync then added — sync.hl matches those by number now; live copy
  32. // 2026-10-04: 850 in 112 series): the KEEPER = the one with the most watches, else the synced one (`tmdb-episode:` oldId:
  33. // TMDB's current id), else the first listed. It gets the others' watches; a migrated keeper takes the synced one's tmdbId
  34. // (its own → `migratedTmdbId`, so the next sync matches it) and TMDB's texts (its own kept in migratedTitle/migratedSummary),
  35. // empty fields filled. The others become tombstones: `mergedInto`, `mergedAt`, `mergedTmdbId` (tmdbId null), out of the
  36. // season's `episodes` into its `mergedEpisodes`. Nothing is deleted.
  37. // RESUMES: the queue is built at start from what is still duplicated / stray — a restart in the middle goes on (a half-moved
  38. // group picks the same keeper again: it already has the moved follows/watches). A third start: "merge: nothing to do".
  39. // Persisted every 10 items and at the end. Log: start, one line per tombstone and per repaired title, done.
  40. //
  41. // ONE PROCESS PER STORAGE (hybriel#21): the tables are the app's own. Ids are unique (count-duplicate-ids), so update() is safe
  42. // (hybriel#113, sync.hl).
  43. import { now } from 'hl:time'
  44. import { readBytes, writeFile, exists } from 'hl:fs'
  45. import { postersDir, hexDigits, listOr } from './util.hl'
  46. import { showsTable, seasonsTable, episodesTable, personsTable, noteSlugsOf, forgetEpisodeKey, posterName } from './shows.hl'
  47. import { followsTable } from './follows.hl'
  48. import { watchesTable, forgetUserWatches } from './watches.hl'
  49. import { searchTitleMerged } from './search.hl'
  50. import { slugBaseOf } from './search-helpers.hl'
  51. import { refreshCatalogShow } from './catalog.hl'
  52. import { hasOldShortId } from './shortids.hl'
  53. import { titleMerged } from './franchises.hl'
  54. // the empty fields of a keeper a duplicate may fill (never ids, slugs, flags or the job markers)
  55. static fillFields = ['summary', 'tmdbSummary', 'tvmazeSummary', 'imdbId', 'tvdbId', 'tvmzId', 'homepage', 'tagline', 'release', 'year', 'language',
  56. 'migratedSummary', 'migratedTitle', 'migratedGenres', 'migratedHomepage', 'migratedTagline']
  57. // the work, [{ kind ('title' | 'seasons'), ids (a group's record ids) | id (a title) }], worked off from `run.pos`; filled in place
  58. // (a static list is never reassigned)
  59. static queue = []
  60. // watch id lists by target, follow id lists by show, person ids by the duplicates' ids they credit, seasons naming a title that
  61. // does not list them — built once by mergePrepare, kept up to date by the moves (flat maps, written key by key)
  62. static watchIds = {}
  63. static followIds = {}
  64. static creditedBy = {}
  65. static strays = {}
  66. static seasonsByDup = {}
  67. static run = { built = false pos = 0 groups = 0 records = 0 strayTitles = 0 doubleSeasons = 0 ms = 0 }
  68. static totals = { groups = 0 tombstones = 0 follows = 0 followRepeats = 0 watches = 0 watchRepeats = 0 seasonsMoved = 0 seasonsMerged = 0
  69. seasonsLinked = 0 episodesMoved = 0 episodesMerged = 0 cast = 0 people = 0 timelines = 0 slugs = 0 posters = 0 fields = 0 titles = 0
  70. doubleSeasons = 0 doubleEpisodes = 0 }
  71. static isEmpty = (&v) => { return v == null || (hlTypeName(v) == 'String' && v.trim() == '') }
  72. // ---- the queue ------------------------------------------------------------------------------------------------------------
  73. // one scan of shows, seasons, follows, watches and persons: the duplicate groups, the titles with stray seasons, the indexes
  74. static mergePrepare = () => {
  75. if (run.built) { return queue.length }
  76. run.built = true
  77. t0 = now()
  78. byKey = {}
  79. order = []
  80. listedBy = {}
  81. for (s of showsTable.find(null, null)) {
  82. if (s.mergedInto == null) {
  83. for (x of listOr(s.seasons)) { listedBy[x] = s.id }
  84. if (s.tmdbId != null) {
  85. let k = s.type + ':' + s.tmdbId
  86. if (byKey[k] == null) {
  87. byKey[k] = [s.id]
  88. order.push(k)
  89. } else { byKey[k].push(s.id) }
  90. }
  91. }
  92. }
  93. dup = {}
  94. for (k of order) {
  95. let ids = byKey[k]
  96. if (ids.length > 1) {
  97. queue.push({ kind = 'title' key = k ids = ids })
  98. run.groups = run.groups + 1
  99. run.records = run.records + ids.length
  100. for (id of ids) { dup[id] = true }
  101. }
  102. }
  103. for (w of watchesTable.find(null, null)) {
  104. if (w.target != null) { if (watchIds[w.target] == null) { watchIds[w.target] = [w.id] } else { watchIds[w.target].push(w.id) } }
  105. }
  106. // seasons: a duplicate's (all of them, listed or not) and the stray ones (naming a live title that does not list them)
  107. listedNum = {}
  108. strayList = []
  109. for (x of seasonsTable.find(null, null)) {
  110. if (x.show != null && x.mergedInto == null) {
  111. if (dup[x.show] == true) {
  112. if (seasonsByDup[x.show] == null) { seasonsByDup[x.show] = [x.id] } else { seasonsByDup[x.show].push(x.id) }
  113. }
  114. if (listedBy[x.id] != null) { listedNum[listedBy[x.id] + '#' + x.seasonNumber] = true }
  115. else { strayList.push({ id = x.id show = x.show n = x.seasonNumber watched = hasWatches(x) }) }
  116. }
  117. }
  118. // a stray season is work when it has a listed twin or watches; its title must be live and no duplicate (a duplicate's seasons
  119. // go with its group)
  120. strayShows = []
  121. for (st of strayList) {
  122. if (dup[st.show] != true && (st.watched || listedNum[st.show + '#' + st.n] == true)) {
  123. if (strays[st.show] == null) {
  124. strays[st.show] = [st.id]
  125. strayShows.push(st.show)
  126. } else { strays[st.show].push(st.id) }
  127. }
  128. }
  129. for (id of strayShows) {
  130. let s = showsTable.fetch(id)
  131. if (s != null && s.mergedInto == null) {
  132. queue.push({ kind = 'seasons' id = id })
  133. run.strayTitles = run.strayTitles + 1
  134. }
  135. }
  136. // tracker#39: the listed seasons holding two live episodes of one number (one pass over the episodes, one at a time)
  137. firstOfNumber = {}
  138. doubled = {}
  139. for (e of episodesTable.iterate(null, null)) {
  140. if (e.mergedInto == null && e.season != null && listedBy[e.season] != null) {
  141. let k = e.season + '#' + e.episodeNumber
  142. if (firstOfNumber[k] == null) { firstOfNumber[k] = true }
  143. else if (doubled[e.season] == null) {
  144. doubled[e.season] = true
  145. queue.push({ kind = 'episodes' id = e.season show = listedBy[e.season] })
  146. run.doubleSeasons = run.doubleSeasons + 1
  147. }
  148. }
  149. }
  150. if (queue.length > 0) {
  151. for (f of followsTable.find(null, null)) {
  152. if (f.show != null) { if (followIds[f.show] == null) { followIds[f.show] = [f.id] } else { followIds[f.show].push(f.id) } }
  153. }
  154. if (run.groups > 0) {
  155. for (p of personsTable.find(null, null)) {
  156. if (p.shows != null) {
  157. for (c of p.shows) {
  158. if (c != null && c.show != null && dup[c.show] == true) {
  159. if (creditedBy[c.show] == null) { creditedBy[c.show] = [p.id] } else if (!creditedBy[c.show].includes(p.id)) { creditedBy[c.show].push(p.id) }
  160. }
  161. }
  162. }
  163. }
  164. }
  165. }
  166. run.ms = now() - t0
  167. return queue.length
  168. }
  169. // anyone's watch on the season or one of its episodes (watchIds is built)
  170. static hasWatches = (&season) => {
  171. if (watchIds[season.id] != null && watchIds[season.id].length > 0) { return true }
  172. for (eid of listOr(season.episodes)) { if (watchIds[eid] != null && watchIds[eid].length > 0) { return true } }
  173. return false
  174. }
  175. static mergeLeft = () => { return queue.length - run.pos }
  176. static mergeStats = () => { return run }
  177. static mergeTotals = () => { return totals }
  178. static mergeLine = () => {
  179. t = totals
  180. return 'groups=' + t.groups + ' tombstones=' + t.tombstones + ' follows=' + t.follows + ' followRepeats=' + t.followRepeats + ' watches=' + t.watches
  181. + ' watchRepeats=' + t.watchRepeats + ' seasonsMoved=' + t.seasonsMoved + ' seasonsMerged=' + t.seasonsMerged + ' seasonsLinked=' + t.seasonsLinked
  182. + ' episodesMoved=' + t.episodesMoved + ' episodesMerged=' + t.episodesMerged + ' cast=' + t.cast + ' people=' + t.people + ' timelines=' + t.timelines
  183. + ' slugs=' + t.slugs + ' posters=' + t.posters + ' fields=' + t.fields + ' strayTitles=' + t.titles + ' doubleSeasons=' + t.doubleSeasons
  184. + ' doubleEpisodes=' + t.doubleEpisodes
  185. }
  186. static persistMerge = () => {
  187. showsTable.persist()
  188. seasonsTable.persist()
  189. episodesTable.persist()
  190. followsTable.persist()
  191. watchesTable.persist()
  192. personsTable.persist()
  193. return true
  194. }
  195. // ---- moving user data ------------------------------------------------------------------------------------------------------
  196. // every watch of `fromId` → `toId` (an episode, season or movie id); a user who already has a watch on `toId` keeps that one and
  197. // the moved row is parked (target null, `mergedInto` = the row it repeats). Every moved row keeps where it was (`mergedTarget`).
  198. static moveWatches = (&fromId, &toId) => {
  199. if (watchIds[fromId] == null) { return 0 }
  200. ids = []
  201. for (x of watchIds[fromId]) { ids.push(x) }
  202. mine = {}
  203. if (watchIds[toId] != null) {
  204. for (x of watchIds[toId]) {
  205. let w = watchesTable.fetch(x)
  206. if (w != null && w.target == toId) { mine[w.user] = w.id }
  207. }
  208. }
  209. moved = []
  210. let n = 0
  211. for (wid of ids) {
  212. let w = watchesTable.fetch(wid)
  213. if (w != null && w.target == fromId) {
  214. let x = w
  215. x.mergedTarget = fromId
  216. if (mine[x.user] != null) {
  217. x.target = null
  218. x.mergedInto = mine[x.user]
  219. totals.watchRepeats = totals.watchRepeats + 1
  220. } else {
  221. x.target = toId
  222. mine[x.user] = x.id
  223. moved.push(x.id)
  224. totals.watches = totals.watches + 1
  225. }
  226. if (watchesTable.update(x.id, x) == null) { console.log('merge: watch ' + x.id + ' not stored: ' + watchesTable.lastError()) } else { n = n + 1 }
  227. forgetUserWatches(x.user)
  228. }
  229. }
  230. watchIds[fromId] = []
  231. if (moved.length > 0) {
  232. if (watchIds[toId] == null) { watchIds[toId] = moved } else { for (x of moved) { watchIds[toId].push(x) } }
  233. }
  234. return n
  235. }
  236. // every follow of title `fromId` → `toId`, the same way (`mergedShow` = where it was)
  237. static moveFollows = (&fromId, &toId) => {
  238. if (followIds[fromId] == null) { return 0 }
  239. ids = []
  240. for (x of followIds[fromId]) { ids.push(x) }
  241. mine = {}
  242. if (followIds[toId] != null) {
  243. for (x of followIds[toId]) {
  244. let f = followsTable.fetch(x)
  245. if (f != null && f.show == toId) { mine[f.user] = f.id }
  246. }
  247. }
  248. moved = []
  249. for (fid of ids) {
  250. let f = followsTable.fetch(fid)
  251. if (f != null && f.show == fromId) {
  252. let x = f
  253. x.mergedShow = fromId
  254. if (mine[x.user] != null) {
  255. x.show = null
  256. x.mergedInto = mine[x.user]
  257. totals.followRepeats = totals.followRepeats + 1
  258. } else {
  259. x.show = toId
  260. mine[x.user] = x.id
  261. moved.push(x.id)
  262. totals.follows = totals.follows + 1
  263. }
  264. if (followsTable.update(x.id, x) == null) { console.log('merge: follow ' + x.id + ' not stored: ' + followsTable.lastError()) }
  265. }
  266. }
  267. followIds[fromId] = []
  268. if (moved.length > 0) {
  269. if (followIds[toId] == null) { followIds[toId] = moved } else { for (x of moved) { followIds[toId].push(x) } }
  270. }
  271. return moved.length
  272. }
  273. // ---- seasons -------------------------------------------------------------------------------------------------------------------
  274. // season `x` into its twin `twinId` of title `titleId` (see the top) → the episode ids the twin gained
  275. static mergeSeasonInto = (&seasonId, &twinId, &titleId) => {
  276. x = seasonsTable.fetch(seasonId)
  277. twin = seasonsTable.fetch(twinId)
  278. if (x == null || twin == null || x.mergedInto != null) { return [] }
  279. moveWatches(x.id, twin.id)
  280. byNum = {}
  281. for (eid of listOr(twin.episodes)) {
  282. let e = episodesTable.fetch(eid)
  283. if (e != null) { byNum['' + e.episodeNumber] = e.id }
  284. }
  285. gained = []
  286. tid = twin.id
  287. for (eid of listOr(x.episodes)) {
  288. let e = episodesTable.fetch(eid)
  289. if (e != null && e.mergedInto == null) {
  290. let other = byNum['' + e.episodeNumber]
  291. let y = e
  292. if (other != null && other != e.id) {
  293. moveWatches(e.id, other)
  294. y.mergedInto = other
  295. totals.episodesMerged = totals.episodesMerged + 1
  296. } else if (other == null) {
  297. y.movedFrom = { show = e.show season = e.season }
  298. y.show = titleId
  299. y.season = tid
  300. byNum['' + e.episodeNumber] = e.id
  301. gained.push(e.id)
  302. totals.episodesMoved = totals.episodesMoved + 1
  303. }
  304. if (episodesTable.update(y.id, y) == null) { console.log('merge: episode ' + y.id + ' not stored: ' + episodesTable.lastError()) }
  305. forgetEpisodeKey(y.id)
  306. }
  307. }
  308. if (gained.length > 0) {
  309. tw = seasonsTable.fetch(tid)
  310. eps = listOr(tw.episodes)
  311. for (g of gained) { eps.push(g) }
  312. tw.episodes = eps
  313. if (seasonsTable.update(tw.id, tw) == null) { console.log('merge: season ' + tw.id + ' not stored: ' + seasonsTable.lastError()) }
  314. }
  315. done = seasonsTable.fetch(seasonId)
  316. done.mergedInto = tid
  317. done.mergedAt = now()
  318. done.mergedEpisodes = listOr(done.episodes)
  319. done.episodes = []
  320. if (seasonsTable.update(done.id, done) == null) { console.log('merge: season ' + done.id + ' not stored: ' + seasonsTable.lastError()) }
  321. totals.seasonsMerged = totals.seasonsMerged + 1
  322. return gained
  323. }
  324. // a season (and its episodes) moved to title `titleId` — it was a duplicate's and the keeper has no season of its number
  325. static moveSeason = (&seasonId, &titleId) => {
  326. x = seasonsTable.fetch(seasonId)
  327. if (x == null) { return false }
  328. y = x
  329. y.movedFrom = { show = x.show }
  330. y.show = titleId
  331. if (seasonsTable.update(y.id, y) == null) {
  332. console.log('merge: season ' + y.id + ' not stored: ' + seasonsTable.lastError())
  333. return false
  334. }
  335. for (eid of listOr(y.episodes)) {
  336. let e = episodesTable.fetch(eid)
  337. if (e != null) {
  338. let z = e
  339. z.movedFrom = { show = e.show season = e.season }
  340. z.show = titleId
  341. if (episodesTable.update(z.id, z) == null) { console.log('merge: episode ' + z.id + ' not stored: ' + episodesTable.lastError()) }
  342. forgetEpisodeKey(z.id)
  343. }
  344. }
  345. totals.seasonsMoved = totals.seasonsMoved + 1
  346. return true
  347. }
  348. // a title's listed seasons by number → season id
  349. static seasonsByNumber = (&show) => {
  350. out = {}
  351. for (sid of listOr(show.seasons)) {
  352. let s = seasonsTable.fetch(sid)
  353. if (s != null) { out['' + s.seasonNumber] = s.id }
  354. }
  355. return out
  356. }
  357. // ---- the keeper ---------------------------------------------------------------------------------------------------------------
  358. // follows + watches on the record, its seasons and their episodes
  359. static userDataOf = (&r) => {
  360. let n = listOr(followIds[r.id]).length + listOr(watchIds[r.id]).length
  361. sids = []
  362. for (x of listOr(r.seasons)) { sids.push(x) }
  363. for (x of listOr(seasonsByDup[r.id])) { if (!sids.includes(x)) { sids.push(x) } }
  364. for (sid of sids) {
  365. n = n + listOr(watchIds[sid]).length
  366. let s = seasonsTable.fetch(sid)
  367. if (s != null) { for (eid of listOr(s.episodes)) { n = n + listOr(watchIds[eid]).length } }
  368. }
  369. return n
  370. }
  371. // when the record was made (ms): a Mongo id's first 8 hex digits are its seconds; else `imported`; else last
  372. static ageOf = (&r) => {
  373. o = r.oldId != null ? '' + r.oldId : ''
  374. if (o.length == 24) {
  375. let v = 0
  376. let i = 0
  377. let ok = true
  378. while (ok && i < 8) {
  379. let d = hexDigits.indexOf(o.slice(i, i + 1))
  380. if (d < 0) { ok = false } else { v = v * 16 + d }
  381. i = i + 1
  382. }
  383. if (ok) { return v * 1000 }
  384. }
  385. if (r.imported != null && hlTypeName(r.imported) == 'Number') { return r.imported }
  386. return 9000000000000000
  387. }
  388. // → the keeper's id: most user data, then the old short id, then the oldest; equal → the first
  389. static pickKeeper = (&recs) => {
  390. let best = null
  391. let bestData = -1
  392. let bestOld = false
  393. let bestAge = 0
  394. for (r of recs) {
  395. let d = userDataOf(r)
  396. let o = hasOldShortId(r)
  397. let a = ageOf(r)
  398. let better = best == null || d > bestData || (d == bestData && o && !bestOld) || (d == bestData && o == bestOld && a < bestAge)
  399. if (better) {
  400. best = r.id
  401. bestData = d
  402. bestOld = o
  403. bestAge = a
  404. }
  405. }
  406. return best
  407. }
  408. // ---- one duplicate into its keeper ---------------------------------------------------------------------------------------------
  409. static creditKey = (&c) => { return c.person != null ? 'p' + c.person : 't' + c.tmdbId }
  410. static mergeTitleInto = (&dupId, &keeperId) => {
  411. t = showsTable.fetch(dupId)
  412. k0 = showsTable.fetch(keeperId)
  413. if (t == null || k0 == null || t.mergedInto != null) { return null }
  414. line = { follows = moveFollows(t.id, keeperId) watches = moveWatches(t.id, keeperId) seasons = 0 merged = 0 people = 0 }
  415. // seasons: the listed ones first, then the ones naming it
  416. sids = []
  417. for (x of listOr(t.seasons)) { sids.push(x) }
  418. for (x of listOr(seasonsByDup[t.id])) { if (!sids.includes(x)) { sids.push(x) } }
  419. have = seasonsByNumber(k0)
  420. added = []
  421. for (sid of sids) {
  422. let s = seasonsTable.fetch(sid)
  423. if (s != null && s.mergedInto == null) {
  424. let twin = have['' + s.seasonNumber]
  425. if (twin != null && twin != s.id) {
  426. mergeSeasonInto(s.id, twin, keeperId)
  427. line.merged = line.merged + 1
  428. } else if (twin == null && moveSeason(s.id, keeperId)) {
  429. have['' + s.seasonNumber] = s.id
  430. added.push(s.id)
  431. line.seasons = line.seasons + 1
  432. }
  433. }
  434. }
  435. // the persons' credits
  436. for (pid of listOr(creditedBy[t.id])) {
  437. let p = personsTable.fetch(pid)
  438. if (p != null) {
  439. let shows = []
  440. let changed = false
  441. for (c of listOr(p.shows)) {
  442. let x = c
  443. if (x != null && x.show == t.id) {
  444. x.show = keeperId
  445. changed = true
  446. }
  447. shows.push(x)
  448. }
  449. if (changed) {
  450. let q = p
  451. q.shows = shows
  452. if (personsTable.update(q.id, q) == null) { console.log('merge: person ' + q.id + ' not stored: ' + personsTable.lastError()) } else { line.people = line.people + 1 }
  453. }
  454. }
  455. }
  456. totals.people = totals.people + line.people
  457. totals.timelines = totals.timelines + titleMerged(t.id, keeperId)
  458. // the keeper: its new seasons, the cast/crew it lacks, empty fields, the poster, the plain slug
  459. k = showsTable.fetch(keeperId)
  460. seasons = listOr(k.seasons)
  461. for (a of added) { seasons.push(a) }
  462. k.seasons = seasons
  463. castKeys = {}
  464. cast = listOr(k.cast)
  465. for (c of cast) { if (c != null) { castKeys[creditKey(c)] = true } }
  466. for (c of listOr(t.cast)) {
  467. if (c != null && castKeys[creditKey(c)] != true) {
  468. castKeys[creditKey(c)] = true
  469. cast.push(c)
  470. totals.cast = totals.cast + 1
  471. }
  472. }
  473. k.cast = cast
  474. crewKeys = {}
  475. crew = listOr(k.crew)
  476. for (c of crew) { if (c != null) { crewKeys[c.person + '|' + c.name + '|' + c.job] = true } }
  477. for (c of listOr(t.crew)) {
  478. if (c != null && crewKeys[c.person + '|' + c.name + '|' + c.job] != true) {
  479. crewKeys[c.person + '|' + c.name + '|' + c.job] = true
  480. crew.push(c)
  481. totals.cast = totals.cast + 1
  482. }
  483. }
  484. if (crew.length > 0) { k.crew = crew }
  485. for (f of fillFields) {
  486. if (isEmpty(k[f]) && !isEmpty(t[f])) {
  487. k[f] = t[f]
  488. totals.fields = totals.fields + 1
  489. }
  490. }
  491. kp = posterName(k)
  492. tp = posterName(t)
  493. if ((kp == null || !exists(postersDir + '/' + kp)) && tp != null && exists(postersDir + '/' + tp) && k.oldId != null) {
  494. writeFile(postersDir + '/' + k.oldId + '.' + t.image, readBytes(postersDir + '/' + tp), 420)
  495. k.image = t.image
  496. totals.posters = totals.posters + 1
  497. }
  498. plain = slugBaseOf(k.title, '')
  499. if (plain != '' && t.urlSegment == plain && k.urlSegment != plain) {
  500. olds = listOr(k.oldSlugs)
  501. if (k.urlSegment != null && !olds.includes(k.urlSegment)) { olds.push(k.urlSegment) }
  502. k.oldSlugs = olds
  503. k.urlSegment = plain
  504. totals.slugs = totals.slugs + 1
  505. }
  506. if (showsTable.update(k.id, k) == null) { console.log('merge: keeper ' + k.id + ' not stored: ' + showsTable.lastError()) }
  507. // the duplicate becomes a tombstone (last: a stop before this leaves a group the next start finishes)
  508. d = showsTable.fetch(dupId)
  509. tmdbId = d.tmdbId
  510. type = d.type
  511. d.mergedInto = keeperId
  512. d.mergedAt = now()
  513. d.mergedTmdbId = tmdbId
  514. d.tmdbId = null
  515. d.mergedSeasons = listOr(d.seasons)
  516. d.seasons = []
  517. if (showsTable.update(d.id, d) == null) {
  518. console.log('merge: tombstone ' + d.id + ' not stored: ' + showsTable.lastError())
  519. return null
  520. }
  521. totals.tombstones = totals.tombstones + 1
  522. noteSlugsOf(d)
  523. noteSlugsOf(showsTable.fetch(keeperId))
  524. searchTitleMerged(d.id, keeperId, type, tmdbId)
  525. refreshCatalogShow(d.id)
  526. refreshCatalogShow(keeperId)
  527. console.log('merge: "' + d.title + '" ' + d.id + ' [' + d.urlSegment + ' ' + d.shortId + '] → ' + keeperId + ' [' + showsTable.fetch(keeperId).urlSegment + ']'
  528. + ' follows=' + line.follows + ' watches=' + line.watches + ' seasonsMoved=' + line.seasons + ' seasonsMerged=' + line.merged + ' people=' + line.people)
  529. return line
  530. }
  531. // ---- one title's stray seasons ----------------------------------------------------------------------------------------------
  532. static straySeasonsStep = (&showId) => {
  533. show = showsTable.fetch(showId)
  534. if (show == null || show.mergedInto != null) { return null }
  535. listed = listOr(show.seasons)
  536. have = seasonsByNumber(show)
  537. // the ones with more episodes first: of two watched strays of one number the fuller one is linked, the other merged into it
  538. let rows = []
  539. for (sid of listOr(strays[showId])) {
  540. let s = seasonsTable.fetch(sid)
  541. if (s != null && s.mergedInto == null && s.show == showId && !listed.includes(s.id)) {
  542. let row = { id = s.id n = s.seasonNumber eps = listOr(s.episodes).length watched = hasWatches(s) }
  543. let out = []
  544. let put = false
  545. for (r of rows) {
  546. if (!put && row.eps > r.eps) { out.push(row) put = true }
  547. out.push(r)
  548. }
  549. if (!put) { out.push(row) }
  550. rows = out
  551. }
  552. }
  553. linked = []
  554. let merged = 0
  555. for (r of rows) {
  556. let twin = have['' + r.n]
  557. if (twin != null) {
  558. mergeSeasonInto(r.id, twin, showId)
  559. merged = merged + 1
  560. } else if (r.watched) {
  561. linked.push(r.id)
  562. have['' + r.n] = r.id
  563. totals.seasonsLinked = totals.seasonsLinked + 1
  564. }
  565. }
  566. if (linked.length > 0) {
  567. x = showsTable.fetch(showId)
  568. seasons = listOr(x.seasons)
  569. marks = listOr(x.linkedSeasons)
  570. for (l of linked) {
  571. seasons.push(l)
  572. marks.push(l)
  573. }
  574. x.seasons = seasons
  575. // which ones the repair linked (they were in the data, not in the list)
  576. x.linkedSeasons = marks
  577. if (showsTable.update(x.id, x) == null) { console.log('merge: title ' + x.id + ' not stored: ' + showsTable.lastError()) }
  578. }
  579. if (linked.length > 0 || merged > 0) {
  580. totals.titles = totals.titles + 1
  581. refreshCatalogShow(showId)
  582. console.log('merge: seasons of "' + show.title + '" ' + showId + ': linked=' + linked.length + ' merged=' + merged)
  583. }
  584. return null
  585. }
  586. // ---- double episodes (tracker#39) ----------------------------------------------------------------------------------------------
  587. static isSyncedEpisode = (&e) => { return e.oldId != null && ('' + e.oldId).startsWith('tmdb-episode:') }
  588. static watchCountOf = (&id) => { return watchIds[id] != null ? watchIds[id].length : 0 }
  589. // the most watches, else the synced one, else the first listed
  590. static pickEpisodeKeeper = (&group) => {
  591. let best = group[0]
  592. for (e of group) {
  593. let a = watchCountOf(e.id)
  594. let b = watchCountOf(best.id)
  595. if (a > b || (a == b && isSyncedEpisode(e) && !isSyncedEpisode(best))) { best = e }
  596. }
  597. return best
  598. }
  599. // episode `e` into `keeperId` (same season, same number)
  600. static mergeEpisodeInto = (&e, &keeperId) => {
  601. moveWatches(e.id, keeperId)
  602. k = episodesTable.fetch(keeperId)
  603. if (k == null) { return false }
  604. x = k
  605. if (isSyncedEpisode(e) && !isSyncedEpisode(k) && e.tmdbId != null) {
  606. // a migrated keeper takes TMDB's current id and texts (its own kept, like the sync keeps a migrated row's)
  607. if (k.tmdbId != null && k.migratedTmdbId == null) { x.migratedTmdbId = k.tmdbId }
  608. x.tmdbId = e.tmdbId
  609. if (!isEmpty(e.title) && e.title != k.title) {
  610. if (!isEmpty(k.title) && k.migratedTitle == null) { x.migratedTitle = k.title }
  611. x.title = e.title
  612. }
  613. if (!isEmpty(e.summary) && e.summary != k.summary) {
  614. if (!isEmpty(k.summary) && k.migratedSummary == null) { x.migratedSummary = k.summary }
  615. x.summary = e.summary
  616. }
  617. if (!isEmpty(e.release)) { x.release = e.release }
  618. } else {
  619. if (isEmpty(k.title) && !isEmpty(e.title)) { x.title = e.title }
  620. if (isEmpty(k.summary) && !isEmpty(e.summary)) { x.summary = e.summary }
  621. if (isEmpty(k.release) && !isEmpty(e.release)) { x.release = e.release }
  622. }
  623. if (isEmpty(k.tvmzId) && !isEmpty(e.tvmzId)) { x.tvmzId = e.tvmzId }
  624. // the tombstone's TMDB id first (the table holds no id twice for the sync's lookup), then the keeper
  625. y = episodesTable.fetch(e.id)
  626. y.mergedInto = keeperId
  627. y.mergedAt = now()
  628. y.mergedTmdbId = e.tmdbId
  629. y.tmdbId = null
  630. if (episodesTable.update(y.id, y) == null) { console.log('merge: episode ' + y.id + ' not stored: ' + episodesTable.lastError()) }
  631. if (episodesTable.update(x.id, x) == null) { console.log('merge: episode ' + x.id + ' not stored: ' + episodesTable.lastError()) }
  632. forgetEpisodeKey(y.id)
  633. forgetEpisodeKey(x.id)
  634. return true
  635. }
  636. // one season: every number held twice or more → one episode (the others tombstones, out of the season's list)
  637. static doubleEpisodesStep = (&seasonId, &showId) => {
  638. se = seasonsTable.fetch(seasonId)
  639. if (se == null || se.mergedInto != null) { return 0 }
  640. byNum = {}
  641. order = []
  642. for (eid of listOr(se.episodes)) {
  643. let e = episodesTable.fetch(eid)
  644. if (e != null && e.mergedInto == null) {
  645. let k = '' + e.episodeNumber
  646. if (byNum[k] == null) {
  647. byNum[k] = [e]
  648. order.push(k)
  649. } else { byNum[k].push(e) }
  650. }
  651. }
  652. gone = {}
  653. let n = 0
  654. for (k of order) {
  655. let group = byNum[k]
  656. if (group.length > 1) {
  657. let keeper = pickEpisodeKeeper(group)
  658. for (e of group) {
  659. if (e.id != keeper.id && mergeEpisodeInto(e, keeper.id)) {
  660. gone[e.id] = true
  661. n = n + 1
  662. }
  663. }
  664. }
  665. }
  666. if (n == 0) { return 0 }
  667. keep = []
  668. parked = []
  669. for (x of listOr(se.mergedEpisodes)) { parked.push(x) }
  670. for (eid of listOr(se.episodes)) { if (gone[eid] == true) { parked.push(eid) } else { keep.push(eid) } }
  671. z = se
  672. z.episodes = keep
  673. z.mergedEpisodes = parked
  674. if (seasonsTable.update(z.id, z) == null) { console.log('merge: season ' + z.id + ' not stored: ' + seasonsTable.lastError()) }
  675. totals.doubleSeasons = totals.doubleSeasons + 1
  676. totals.doubleEpisodes = totals.doubleEpisodes + n
  677. refreshCatalogShow(showId)
  678. s = showsTable.fetch(showId)
  679. console.log('merge: double episodes of "' + (s != null ? s.title : showId) + '" season ' + se.seasonNumber + ' ' + seasonId + ': ' + n + ' merged, ' + keep.length + ' left')
  680. return n
  681. }
  682. // ONE item of the queue → { done, left }
  683. static mergeStep = () => {
  684. mergePrepare()
  685. if (run.pos >= queue.length) { return { done = true left = 0 } }
  686. item = queue[run.pos]
  687. run.pos = run.pos + 1
  688. if (item.kind == 'title') {
  689. recs = []
  690. for (id of item.ids) {
  691. let r = showsTable.fetch(id)
  692. if (r != null && r.mergedInto == null) { recs.push(r) }
  693. }
  694. if (recs.length > 1) {
  695. keeper = pickKeeper(recs)
  696. for (r of recs) { if (r.id != keeper) { mergeTitleInto(r.id, keeper) } }
  697. totals.groups = totals.groups + 1
  698. // the keeper's own stray seasons (naming it, not listed — Reacher's kind) the same way as any title's
  699. strays[keeper] = listOr(seasonsByDup[keeper])
  700. straySeasonsStep(keeper)
  701. }
  702. } else if (item.kind == 'episodes') {
  703. doubleEpisodesStep(item.id, item.show)
  704. } else {
  705. straySeasonsStep(item.id)
  706. }
  707. return { done = run.pos >= queue.length left = queue.length - run.pos }
  708. }

Branches

Latest commits

  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8751adb8tracker: report 032mre
  • 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
  • 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
  • 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
  • a386dc92tracker: reports 029 + 030mre
  • 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
  • d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
  • 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
  • 661c2592tracker: report 028mre
  • 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
  • d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
  • 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
  • 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre