gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit84e1b3e184e1b3e1tracker: reports 025 + 026mre84e1b3e1/merge.hl

24.3 KB

  1. // 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; project.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. // RESUMES: the queue is built at start from what is still duplicated / stray — a restart in the middle goes on (a half-moved
  31. // group picks the same keeper again: it already has the moved follows/watches). A third start: "merge: nothing to do".
  32. // Persisted every 10 items and at the end. Log: start, one line per tombstone and per repaired title, done.
  33. //
  34. // ONE PROCESS PER STORAGE (hybriel#21): the tables are the app's own. Ids are unique (count-duplicate-ids), so update() is safe
  35. // (hybriel#113, tmdbsync.hl).
  36. import { now } from 'hl:time'
  37. import { readBytes, writeFile, exists } from 'hl:fs'
  38. import { showsTable, seasonsTable, episodesTable, personsTable, storageDir, noteSlugsOf, forgetEpisodeKey, posterName } from './shows.hl'
  39. import { followsTable } from './follows.hl'
  40. import { watchesTable, forgetUserWatches } from './watches.hl'
  41. import { searchTitleMerged, slugBaseOf } from './search.hl'
  42. import { refreshCatalogShow } from './catalog.hl'
  43. import { hasOldShortId } from './shortids.hl'
  44. import { titleMerged } from './franchises.hl'
  45. static hexDigits = '0123456789abcdef'
  46. // the empty fields of a keeper a duplicate may fill (never ids, slugs, flags or the job markers)
  47. static fillFields = ['summary', 'tmdbSummary', 'tvmazeSummary', 'imdbId', 'tvdbId', 'tvmzId', 'homepage', 'tagline', 'release', 'year', 'language',
  48. 'migratedSummary', 'migratedTitle', 'migratedGenres', 'migratedHomepage', 'migratedTagline']
  49. // the work, [{ kind ('title' | 'seasons'), ids (a group's record ids) | id (a title) }], worked off from `run.pos`; filled in place
  50. // (a static list is never reassigned)
  51. static queue = []
  52. // watch id lists by target, follow id lists by show, person ids by the duplicates' ids they credit, seasons naming a title that
  53. // does not list them — built once by mergePrepare, kept up to date by the moves (flat maps, written key by key)
  54. static watchIds = {}
  55. static followIds = {}
  56. static creditedBy = {}
  57. static strays = {}
  58. static seasonsByDup = {}
  59. static run = { built = false pos = 0 groups = 0 records = 0 strayTitles = 0 ms = 0 }
  60. static totals = { groups = 0 tombstones = 0 follows = 0 followRepeats = 0 watches = 0 watchRepeats = 0 seasonsMoved = 0 seasonsMerged = 0
  61. seasonsLinked = 0 episodesMoved = 0 episodesMerged = 0 cast = 0 people = 0 timelines = 0 slugs = 0 posters = 0 fields = 0 titles = 0 }
  62. static listOf = (&v) => { return v == null ? [] : v }
  63. static isEmpty = (&v) => { return v == null || (hlTypeName(v) == 'String' && v.trim() == '') }
  64. // ---- the queue ------------------------------------------------------------------------------------------------------------
  65. // one scan of shows, seasons, follows, watches and persons: the duplicate groups, the titles with stray seasons, the indexes
  66. static mergePrepare = () => {
  67. if (run.built) { return queue.length }
  68. run.built = true
  69. let t0 = now()
  70. let byKey = {}
  71. let order = []
  72. let listedBy = {}
  73. for (s of showsTable.find(null, null)) {
  74. if (s.mergedInto == null) {
  75. for (x of listOf(s.seasons)) { listedBy[x] = s.id }
  76. if (s.tmdbId != null) {
  77. let k = s.type + ':' + s.tmdbId
  78. if (byKey[k] == null) {
  79. byKey[k] = [s.id]
  80. order.push(k)
  81. } else { byKey[k].push(s.id) }
  82. }
  83. }
  84. }
  85. let dup = {}
  86. for (k of order) {
  87. let ids = byKey[k]
  88. if (ids.length > 1) {
  89. queue.push({ kind = 'title' key = k ids = ids })
  90. run.groups = run.groups + 1
  91. run.records = run.records + ids.length
  92. for (id of ids) { dup[id] = true }
  93. }
  94. }
  95. for (w of watchesTable.find(null, null)) {
  96. if (w.target != null) { if (watchIds[w.target] == null) { watchIds[w.target] = [w.id] } else { watchIds[w.target].push(w.id) } }
  97. }
  98. // seasons: a duplicate's (all of them, listed or not) and the stray ones (naming a live title that does not list them)
  99. let listedNum = {}
  100. let strayList = []
  101. for (x of seasonsTable.find(null, null)) {
  102. if (x.show != null && x.mergedInto == null) {
  103. if (dup[x.show] == true) {
  104. if (seasonsByDup[x.show] == null) { seasonsByDup[x.show] = [x.id] } else { seasonsByDup[x.show].push(x.id) }
  105. }
  106. if (listedBy[x.id] != null) { listedNum[listedBy[x.id] + '#' + x.seasonNumber] = true }
  107. else { strayList.push({ id = x.id show = x.show n = x.seasonNumber watched = hasWatches(x) }) }
  108. }
  109. }
  110. // 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
  111. // go with its group)
  112. let strayShows = []
  113. for (st of strayList) {
  114. if (dup[st.show] != true && (st.watched || listedNum[st.show + '#' + st.n] == true)) {
  115. if (strays[st.show] == null) {
  116. strays[st.show] = [st.id]
  117. strayShows.push(st.show)
  118. } else { strays[st.show].push(st.id) }
  119. }
  120. }
  121. for (id of strayShows) {
  122. let s = showsTable.fetch(id)
  123. if (s != null && s.mergedInto == null) {
  124. queue.push({ kind = 'seasons' id = id })
  125. run.strayTitles = run.strayTitles + 1
  126. }
  127. }
  128. if (queue.length > 0) {
  129. for (f of followsTable.find(null, null)) {
  130. if (f.show != null) { if (followIds[f.show] == null) { followIds[f.show] = [f.id] } else { followIds[f.show].push(f.id) } }
  131. }
  132. if (run.groups > 0) {
  133. for (p of personsTable.find(null, null)) {
  134. if (p.shows != null) {
  135. for (c of p.shows) {
  136. if (c != null && c.show != null && dup[c.show] == true) {
  137. if (creditedBy[c.show] == null) { creditedBy[c.show] = [p.id] } else if (!creditedBy[c.show].includes(p.id)) { creditedBy[c.show].push(p.id) }
  138. }
  139. }
  140. }
  141. }
  142. }
  143. }
  144. run.ms = now() - t0
  145. return queue.length
  146. }
  147. // anyone's watch on the season or one of its episodes (watchIds is built)
  148. static hasWatches = (&season) => {
  149. if (watchIds[season.id] != null && watchIds[season.id].length > 0) { return true }
  150. for (eid of listOf(season.episodes)) { if (watchIds[eid] != null && watchIds[eid].length > 0) { return true } }
  151. return false
  152. }
  153. static mergeLeft = () => { return queue.length - run.pos }
  154. static mergeStats = () => { return run }
  155. static mergeTotals = () => { return totals }
  156. static mergeLine = () => {
  157. let t = totals
  158. return 'groups=' + t.groups + ' tombstones=' + t.tombstones + ' follows=' + t.follows + ' followRepeats=' + t.followRepeats + ' watches=' + t.watches
  159. + ' watchRepeats=' + t.watchRepeats + ' seasonsMoved=' + t.seasonsMoved + ' seasonsMerged=' + t.seasonsMerged + ' seasonsLinked=' + t.seasonsLinked
  160. + ' episodesMoved=' + t.episodesMoved + ' episodesMerged=' + t.episodesMerged + ' cast=' + t.cast + ' people=' + t.people + ' timelines=' + t.timelines
  161. + ' slugs=' + t.slugs + ' posters=' + t.posters + ' fields=' + t.fields + ' strayTitles=' + t.titles
  162. }
  163. static persistMerge = () => {
  164. showsTable.persist()
  165. seasonsTable.persist()
  166. episodesTable.persist()
  167. followsTable.persist()
  168. watchesTable.persist()
  169. personsTable.persist()
  170. return true
  171. }
  172. // ---- moving user data ------------------------------------------------------------------------------------------------------
  173. // every watch of `fromId` → `toId` (an episode, season or movie id); a user who already has a watch on `toId` keeps that one and
  174. // the moved row is parked (target null, `mergedInto` = the row it repeats). Every moved row keeps where it was (`mergedTarget`).
  175. static moveWatches = (&fromId, &toId) => {
  176. if (watchIds[fromId] == null) { return 0 }
  177. let ids = []
  178. for (x of watchIds[fromId]) { ids.push(x) }
  179. let mine = {}
  180. if (watchIds[toId] != null) {
  181. for (x of watchIds[toId]) {
  182. let w = watchesTable.fetch(x)
  183. if (w != null && w.target == toId) { mine[w.user] = w.id }
  184. }
  185. }
  186. let moved = []
  187. let n = 0
  188. for (wid of ids) {
  189. let w = watchesTable.fetch(wid)
  190. if (w != null && w.target == fromId) {
  191. let x = w
  192. x.mergedTarget = fromId
  193. if (mine[x.user] != null) {
  194. x.target = null
  195. x.mergedInto = mine[x.user]
  196. totals.watchRepeats = totals.watchRepeats + 1
  197. } else {
  198. x.target = toId
  199. mine[x.user] = x.id
  200. moved.push(x.id)
  201. totals.watches = totals.watches + 1
  202. }
  203. if (watchesTable.update(x.id, x) == null) { console.log('merge: watch ' + x.id + ' not stored: ' + watchesTable.lastError()) } else { n = n + 1 }
  204. forgetUserWatches(x.user)
  205. }
  206. }
  207. watchIds[fromId] = []
  208. if (moved.length > 0) {
  209. if (watchIds[toId] == null) { watchIds[toId] = moved } else { for (x of moved) { watchIds[toId].push(x) } }
  210. }
  211. return n
  212. }
  213. // every follow of title `fromId` → `toId`, the same way (`mergedShow` = where it was)
  214. static moveFollows = (&fromId, &toId) => {
  215. if (followIds[fromId] == null) { return 0 }
  216. let ids = []
  217. for (x of followIds[fromId]) { ids.push(x) }
  218. let mine = {}
  219. if (followIds[toId] != null) {
  220. for (x of followIds[toId]) {
  221. let f = followsTable.fetch(x)
  222. if (f != null && f.show == toId) { mine[f.user] = f.id }
  223. }
  224. }
  225. let moved = []
  226. for (fid of ids) {
  227. let f = followsTable.fetch(fid)
  228. if (f != null && f.show == fromId) {
  229. let x = f
  230. x.mergedShow = fromId
  231. if (mine[x.user] != null) {
  232. x.show = null
  233. x.mergedInto = mine[x.user]
  234. totals.followRepeats = totals.followRepeats + 1
  235. } else {
  236. x.show = toId
  237. mine[x.user] = x.id
  238. moved.push(x.id)
  239. totals.follows = totals.follows + 1
  240. }
  241. if (followsTable.update(x.id, x) == null) { console.log('merge: follow ' + x.id + ' not stored: ' + followsTable.lastError()) }
  242. }
  243. }
  244. followIds[fromId] = []
  245. if (moved.length > 0) {
  246. if (followIds[toId] == null) { followIds[toId] = moved } else { for (x of moved) { followIds[toId].push(x) } }
  247. }
  248. return moved.length
  249. }
  250. // ---- seasons -------------------------------------------------------------------------------------------------------------------
  251. // season `x` into its twin `twinId` of title `titleId` (see the top) → the episode ids the twin gained
  252. static mergeSeasonInto = (&seasonId, &twinId, &titleId) => {
  253. let x = seasonsTable.fetch(seasonId)
  254. let twin = seasonsTable.fetch(twinId)
  255. if (x == null || twin == null || x.mergedInto != null) { return [] }
  256. moveWatches(x.id, twin.id)
  257. let byNum = {}
  258. for (eid of listOf(twin.episodes)) {
  259. let e = episodesTable.fetch(eid)
  260. if (e != null) { byNum['' + e.episodeNumber] = e.id }
  261. }
  262. let gained = []
  263. let tid = twin.id
  264. for (eid of listOf(x.episodes)) {
  265. let e = episodesTable.fetch(eid)
  266. if (e != null && e.mergedInto == null) {
  267. let other = byNum['' + e.episodeNumber]
  268. let y = e
  269. if (other != null && other != e.id) {
  270. moveWatches(e.id, other)
  271. y.mergedInto = other
  272. totals.episodesMerged = totals.episodesMerged + 1
  273. } else if (other == null) {
  274. y.movedFrom = { show = e.show season = e.season }
  275. y.show = titleId
  276. y.season = tid
  277. byNum['' + e.episodeNumber] = e.id
  278. gained.push(e.id)
  279. totals.episodesMoved = totals.episodesMoved + 1
  280. }
  281. if (episodesTable.update(y.id, y) == null) { console.log('merge: episode ' + y.id + ' not stored: ' + episodesTable.lastError()) }
  282. forgetEpisodeKey(y.id)
  283. }
  284. }
  285. if (gained.length > 0) {
  286. let tw = seasonsTable.fetch(tid)
  287. let eps = listOf(tw.episodes)
  288. for (g of gained) { eps.push(g) }
  289. tw.episodes = eps
  290. if (seasonsTable.update(tw.id, tw) == null) { console.log('merge: season ' + tw.id + ' not stored: ' + seasonsTable.lastError()) }
  291. }
  292. let done = seasonsTable.fetch(seasonId)
  293. done.mergedInto = tid
  294. done.mergedAt = now()
  295. done.mergedEpisodes = listOf(done.episodes)
  296. done.episodes = []
  297. if (seasonsTable.update(done.id, done) == null) { console.log('merge: season ' + done.id + ' not stored: ' + seasonsTable.lastError()) }
  298. totals.seasonsMerged = totals.seasonsMerged + 1
  299. return gained
  300. }
  301. // a season (and its episodes) moved to title `titleId` — it was a duplicate's and the keeper has no season of its number
  302. static moveSeason = (&seasonId, &titleId) => {
  303. let x = seasonsTable.fetch(seasonId)
  304. if (x == null) { return false }
  305. let y = x
  306. y.movedFrom = { show = x.show }
  307. y.show = titleId
  308. if (seasonsTable.update(y.id, y) == null) {
  309. console.log('merge: season ' + y.id + ' not stored: ' + seasonsTable.lastError())
  310. return false
  311. }
  312. for (eid of listOf(y.episodes)) {
  313. let e = episodesTable.fetch(eid)
  314. if (e != null) {
  315. let z = e
  316. z.movedFrom = { show = e.show season = e.season }
  317. z.show = titleId
  318. if (episodesTable.update(z.id, z) == null) { console.log('merge: episode ' + z.id + ' not stored: ' + episodesTable.lastError()) }
  319. forgetEpisodeKey(z.id)
  320. }
  321. }
  322. totals.seasonsMoved = totals.seasonsMoved + 1
  323. return true
  324. }
  325. // a title's listed seasons by number → season id
  326. static seasonsByNumber = (&show) => {
  327. let out = {}
  328. for (sid of listOf(show.seasons)) {
  329. let s = seasonsTable.fetch(sid)
  330. if (s != null) { out['' + s.seasonNumber] = s.id }
  331. }
  332. return out
  333. }
  334. // ---- the keeper ---------------------------------------------------------------------------------------------------------------
  335. // follows + watches on the record, its seasons and their episodes
  336. static userDataOf = (&r) => {
  337. let n = listOf(followIds[r.id]).length + listOf(watchIds[r.id]).length
  338. let sids = []
  339. for (x of listOf(r.seasons)) { sids.push(x) }
  340. for (x of listOf(seasonsByDup[r.id])) { if (!sids.includes(x)) { sids.push(x) } }
  341. for (sid of sids) {
  342. n = n + listOf(watchIds[sid]).length
  343. let s = seasonsTable.fetch(sid)
  344. if (s != null) { for (eid of listOf(s.episodes)) { n = n + listOf(watchIds[eid]).length } }
  345. }
  346. return n
  347. }
  348. // when the record was made (ms): a Mongo id's first 8 hex digits are its seconds; else `imported`; else last
  349. static ageOf = (&r) => {
  350. let o = r.oldId != null ? '' + r.oldId : ''
  351. if (o.length == 24) {
  352. let v = 0
  353. let i = 0
  354. let ok = true
  355. while (ok && i < 8) {
  356. let d = hexDigits.indexOf(o.slice(i, i + 1))
  357. if (d < 0) { ok = false } else { v = v * 16 + d }
  358. i = i + 1
  359. }
  360. if (ok) { return v * 1000 }
  361. }
  362. if (r.imported != null && hlTypeName(r.imported) == 'Number') { return r.imported }
  363. return 9000000000000000
  364. }
  365. // → the keeper's id: most user data, then the old short id, then the oldest; equal → the first
  366. static pickKeeper = (&recs) => {
  367. let best = null
  368. let bestData = -1
  369. let bestOld = false
  370. let bestAge = 0
  371. for (r of recs) {
  372. let d = userDataOf(r)
  373. let o = hasOldShortId(r)
  374. let a = ageOf(r)
  375. let better = best == null || d > bestData || (d == bestData && o && !bestOld) || (d == bestData && o == bestOld && a < bestAge)
  376. if (better) {
  377. best = r.id
  378. bestData = d
  379. bestOld = o
  380. bestAge = a
  381. }
  382. }
  383. return best
  384. }
  385. // ---- one duplicate into its keeper ---------------------------------------------------------------------------------------------
  386. static creditKey = (&c) => { return c.person != null ? 'p' + c.person : 't' + c.tmdbId }
  387. static mergeTitleInto = (&dupId, &keeperId) => {
  388. let t = showsTable.fetch(dupId)
  389. let k0 = showsTable.fetch(keeperId)
  390. if (t == null || k0 == null || t.mergedInto != null) { return null }
  391. let line = { follows = moveFollows(t.id, keeperId) watches = moveWatches(t.id, keeperId) seasons = 0 merged = 0 people = 0 }
  392. // seasons: the listed ones first, then the ones naming it
  393. let sids = []
  394. for (x of listOf(t.seasons)) { sids.push(x) }
  395. for (x of listOf(seasonsByDup[t.id])) { if (!sids.includes(x)) { sids.push(x) } }
  396. let have = seasonsByNumber(k0)
  397. let added = []
  398. for (sid of sids) {
  399. let s = seasonsTable.fetch(sid)
  400. if (s != null && s.mergedInto == null) {
  401. let twin = have['' + s.seasonNumber]
  402. if (twin != null && twin != s.id) {
  403. mergeSeasonInto(s.id, twin, keeperId)
  404. line.merged = line.merged + 1
  405. } else if (twin == null && moveSeason(s.id, keeperId)) {
  406. have['' + s.seasonNumber] = s.id
  407. added.push(s.id)
  408. line.seasons = line.seasons + 1
  409. }
  410. }
  411. }
  412. // the persons' credits
  413. for (pid of listOf(creditedBy[t.id])) {
  414. let p = personsTable.fetch(pid)
  415. if (p != null) {
  416. let shows = []
  417. let changed = false
  418. for (c of listOf(p.shows)) {
  419. let x = c
  420. if (x != null && x.show == t.id) {
  421. x.show = keeperId
  422. changed = true
  423. }
  424. shows.push(x)
  425. }
  426. if (changed) {
  427. let q = p
  428. q.shows = shows
  429. if (personsTable.update(q.id, q) == null) { console.log('merge: person ' + q.id + ' not stored: ' + personsTable.lastError()) } else { line.people = line.people + 1 }
  430. }
  431. }
  432. }
  433. totals.people = totals.people + line.people
  434. totals.timelines = totals.timelines + titleMerged(t.id, keeperId)
  435. // the keeper: its new seasons, the cast/crew it lacks, empty fields, the poster, the plain slug
  436. let k = showsTable.fetch(keeperId)
  437. let seasons = listOf(k.seasons)
  438. for (a of added) { seasons.push(a) }
  439. k.seasons = seasons
  440. let castKeys = {}
  441. let cast = listOf(k.cast)
  442. for (c of cast) { if (c != null) { castKeys[creditKey(c)] = true } }
  443. for (c of listOf(t.cast)) {
  444. if (c != null && castKeys[creditKey(c)] != true) {
  445. castKeys[creditKey(c)] = true
  446. cast.push(c)
  447. totals.cast = totals.cast + 1
  448. }
  449. }
  450. k.cast = cast
  451. let crewKeys = {}
  452. let crew = listOf(k.crew)
  453. for (c of crew) { if (c != null) { crewKeys[c.person + '|' + c.name + '|' + c.job] = true } }
  454. for (c of listOf(t.crew)) {
  455. if (c != null && crewKeys[c.person + '|' + c.name + '|' + c.job] != true) {
  456. crewKeys[c.person + '|' + c.name + '|' + c.job] = true
  457. crew.push(c)
  458. totals.cast = totals.cast + 1
  459. }
  460. }
  461. if (crew.length > 0) { k.crew = crew }
  462. for (f of fillFields) {
  463. if (isEmpty(k[f]) && !isEmpty(t[f])) {
  464. k[f] = t[f]
  465. totals.fields = totals.fields + 1
  466. }
  467. }
  468. let kp = posterName(k)
  469. let tp = posterName(t)
  470. if ((kp == null || !exists(storageDir + '/posters/' + kp)) && tp != null && exists(storageDir + '/posters/' + tp) && k.oldId != null) {
  471. writeFile(storageDir + '/posters/' + k.oldId + '.' + t.image, readBytes(storageDir + '/posters/' + tp), 420)
  472. k.image = t.image
  473. totals.posters = totals.posters + 1
  474. }
  475. let plain = slugBaseOf(k.title, '')
  476. if (plain != '' && t.urlSegment == plain && k.urlSegment != plain) {
  477. let olds = listOf(k.oldSlugs)
  478. if (k.urlSegment != null && !olds.includes(k.urlSegment)) { olds.push(k.urlSegment) }
  479. k.oldSlugs = olds
  480. k.urlSegment = plain
  481. totals.slugs = totals.slugs + 1
  482. }
  483. if (showsTable.update(k.id, k) == null) { console.log('merge: keeper ' + k.id + ' not stored: ' + showsTable.lastError()) }
  484. // the duplicate becomes a tombstone (last: a stop before this leaves a group the next start finishes)
  485. let d = showsTable.fetch(dupId)
  486. let tmdbId = d.tmdbId
  487. let type = d.type
  488. d.mergedInto = keeperId
  489. d.mergedAt = now()
  490. d.mergedTmdbId = tmdbId
  491. d.tmdbId = null
  492. d.mergedSeasons = listOf(d.seasons)
  493. d.seasons = []
  494. if (showsTable.update(d.id, d) == null) {
  495. console.log('merge: tombstone ' + d.id + ' not stored: ' + showsTable.lastError())
  496. return null
  497. }
  498. totals.tombstones = totals.tombstones + 1
  499. noteSlugsOf(d)
  500. noteSlugsOf(showsTable.fetch(keeperId))
  501. searchTitleMerged(d.id, keeperId, type, tmdbId)
  502. refreshCatalogShow(d.id)
  503. refreshCatalogShow(keeperId)
  504. console.log('merge: "' + d.title + '" ' + d.id + ' [' + d.urlSegment + ' ' + d.shortId + '] → ' + keeperId + ' [' + showsTable.fetch(keeperId).urlSegment + ']'
  505. + ' follows=' + line.follows + ' watches=' + line.watches + ' seasonsMoved=' + line.seasons + ' seasonsMerged=' + line.merged + ' people=' + line.people)
  506. return line
  507. }
  508. // ---- one title's stray seasons ----------------------------------------------------------------------------------------------
  509. static straySeasonsStep = (&showId) => {
  510. let show = showsTable.fetch(showId)
  511. if (show == null || show.mergedInto != null) { return null }
  512. let listed = listOf(show.seasons)
  513. let have = seasonsByNumber(show)
  514. // the ones with more episodes first: of two watched strays of one number the fuller one is linked, the other merged into it
  515. let rows = []
  516. for (sid of listOf(strays[showId])) {
  517. let s = seasonsTable.fetch(sid)
  518. if (s != null && s.mergedInto == null && s.show == showId && !listed.includes(s.id)) {
  519. let row = { id = s.id n = s.seasonNumber eps = listOf(s.episodes).length watched = hasWatches(s) }
  520. let out = []
  521. let put = false
  522. for (r of rows) {
  523. if (!put && row.eps > r.eps) { out.push(row) put = true }
  524. out.push(r)
  525. }
  526. if (!put) { out.push(row) }
  527. rows = out
  528. }
  529. }
  530. let linked = []
  531. let merged = 0
  532. for (r of rows) {
  533. let twin = have['' + r.n]
  534. if (twin != null) {
  535. mergeSeasonInto(r.id, twin, showId)
  536. merged = merged + 1
  537. } else if (r.watched) {
  538. linked.push(r.id)
  539. have['' + r.n] = r.id
  540. totals.seasonsLinked = totals.seasonsLinked + 1
  541. }
  542. }
  543. if (linked.length > 0) {
  544. let x = showsTable.fetch(showId)
  545. let seasons = listOf(x.seasons)
  546. let marks = listOf(x.linkedSeasons)
  547. for (l of linked) {
  548. seasons.push(l)
  549. marks.push(l)
  550. }
  551. x.seasons = seasons
  552. // which ones the repair linked (they were in the data, not in the list)
  553. x.linkedSeasons = marks
  554. if (showsTable.update(x.id, x) == null) { console.log('merge: title ' + x.id + ' not stored: ' + showsTable.lastError()) }
  555. }
  556. if (linked.length > 0 || merged > 0) {
  557. totals.titles = totals.titles + 1
  558. refreshCatalogShow(showId)
  559. console.log('merge: seasons of "' + show.title + '" ' + showId + ': linked=' + linked.length + ' merged=' + merged)
  560. }
  561. return null
  562. }
  563. // ONE item of the queue → { done, left }
  564. static mergeStep = () => {
  565. mergePrepare()
  566. if (run.pos >= queue.length) { return { done = true left = 0 } }
  567. let item = queue[run.pos]
  568. run.pos = run.pos + 1
  569. if (item.kind == 'title') {
  570. let recs = []
  571. for (id of item.ids) {
  572. let r = showsTable.fetch(id)
  573. if (r != null && r.mergedInto == null) { recs.push(r) }
  574. }
  575. if (recs.length > 1) {
  576. let keeper = pickKeeper(recs)
  577. for (r of recs) { if (r.id != keeper) { mergeTitleInto(r.id, keeper) } }
  578. totals.groups = totals.groups + 1
  579. // the keeper's own stray seasons (naming it, not listed — Reacher's kind) the same way as any title's
  580. strays[keeper] = listOf(seasonsByDup[keeper])
  581. straySeasonsStep(keeper)
  582. }
  583. } else {
  584. straySeasonsStep(item.id)
  585. }
  586. return { done = run.pos >= queue.length left = queue.length - run.pos }
  587. }

Branches

Latest commits

  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
  • 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre