gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit96ba683a96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre96ba683a/lib/merge.hl

24.0 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. // 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, sync.hl).
  36. import { now } from 'hl:time'
  37. import { readBytes, writeFile, exists } from 'hl:fs'
  38. import { postersDir, hexDigits, listOr } from './util.hl'
  39. import { showsTable, seasonsTable, episodesTable, personsTable, noteSlugsOf, forgetEpisodeKey, posterName } from './shows.hl'
  40. import { followsTable } from './follows.hl'
  41. import { watchesTable, forgetUserWatches } from './watches.hl'
  42. import { searchTitleMerged } from './search.hl'
  43. import { slugBaseOf } from './search-helpers.hl'
  44. import { refreshCatalogShow } from './catalog.hl'
  45. import { hasOldShortId } from './shortids.hl'
  46. import { titleMerged } from './franchises.hl'
  47. // the empty fields of a keeper a duplicate may fill (never ids, slugs, flags or the job markers)
  48. static fillFields = ['summary', 'tmdbSummary', 'tvmazeSummary', 'imdbId', 'tvdbId', 'tvmzId', 'homepage', 'tagline', 'release', 'year', 'language',
  49. 'migratedSummary', 'migratedTitle', 'migratedGenres', 'migratedHomepage', 'migratedTagline']
  50. // the work, [{ kind ('title' | 'seasons'), ids (a group's record ids) | id (a title) }], worked off from `run.pos`; filled in place
  51. // (a static list is never reassigned)
  52. static queue = []
  53. // watch id lists by target, follow id lists by show, person ids by the duplicates' ids they credit, seasons naming a title that
  54. // does not list them — built once by mergePrepare, kept up to date by the moves (flat maps, written key by key)
  55. static watchIds = {}
  56. static followIds = {}
  57. static creditedBy = {}
  58. static strays = {}
  59. static seasonsByDup = {}
  60. static run = { built = false pos = 0 groups = 0 records = 0 strayTitles = 0 ms = 0 }
  61. static totals = { groups = 0 tombstones = 0 follows = 0 followRepeats = 0 watches = 0 watchRepeats = 0 seasonsMoved = 0 seasonsMerged = 0
  62. seasonsLinked = 0 episodesMoved = 0 episodesMerged = 0 cast = 0 people = 0 timelines = 0 slugs = 0 posters = 0 fields = 0 titles = 0 }
  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. t0 = now()
  70. byKey = {}
  71. order = []
  72. listedBy = {}
  73. for (s of showsTable.find(null, null)) {
  74. if (s.mergedInto == null) {
  75. for (x of listOr(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. 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. listedNum = {}
  100. 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. 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 listOr(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. 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. ids = []
  178. for (x of watchIds[fromId]) { ids.push(x) }
  179. 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. 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. ids = []
  217. for (x of followIds[fromId]) { ids.push(x) }
  218. 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. 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. x = seasonsTable.fetch(seasonId)
  254. twin = seasonsTable.fetch(twinId)
  255. if (x == null || twin == null || x.mergedInto != null) { return [] }
  256. moveWatches(x.id, twin.id)
  257. byNum = {}
  258. for (eid of listOr(twin.episodes)) {
  259. let e = episodesTable.fetch(eid)
  260. if (e != null) { byNum['' + e.episodeNumber] = e.id }
  261. }
  262. gained = []
  263. tid = twin.id
  264. for (eid of listOr(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. tw = seasonsTable.fetch(tid)
  287. eps = listOr(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. done = seasonsTable.fetch(seasonId)
  293. done.mergedInto = tid
  294. done.mergedAt = now()
  295. done.mergedEpisodes = listOr(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. x = seasonsTable.fetch(seasonId)
  304. if (x == null) { return false }
  305. 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 listOr(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. out = {}
  328. for (sid of listOr(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 = listOr(followIds[r.id]).length + listOr(watchIds[r.id]).length
  338. sids = []
  339. for (x of listOr(r.seasons)) { sids.push(x) }
  340. for (x of listOr(seasonsByDup[r.id])) { if (!sids.includes(x)) { sids.push(x) } }
  341. for (sid of sids) {
  342. n = n + listOr(watchIds[sid]).length
  343. let s = seasonsTable.fetch(sid)
  344. if (s != null) { for (eid of listOr(s.episodes)) { n = n + listOr(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. 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. t = showsTable.fetch(dupId)
  389. k0 = showsTable.fetch(keeperId)
  390. if (t == null || k0 == null || t.mergedInto != null) { return null }
  391. 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. sids = []
  394. for (x of listOr(t.seasons)) { sids.push(x) }
  395. for (x of listOr(seasonsByDup[t.id])) { if (!sids.includes(x)) { sids.push(x) } }
  396. have = seasonsByNumber(k0)
  397. 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 listOr(creditedBy[t.id])) {
  414. let p = personsTable.fetch(pid)
  415. if (p != null) {
  416. let shows = []
  417. let changed = false
  418. for (c of listOr(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. k = showsTable.fetch(keeperId)
  437. seasons = listOr(k.seasons)
  438. for (a of added) { seasons.push(a) }
  439. k.seasons = seasons
  440. castKeys = {}
  441. cast = listOr(k.cast)
  442. for (c of cast) { if (c != null) { castKeys[creditKey(c)] = true } }
  443. for (c of listOr(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. crewKeys = {}
  452. crew = listOr(k.crew)
  453. for (c of crew) { if (c != null) { crewKeys[c.person + '|' + c.name + '|' + c.job] = true } }
  454. for (c of listOr(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. kp = posterName(k)
  469. tp = posterName(t)
  470. if ((kp == null || !exists(postersDir + '/' + kp)) && tp != null && exists(postersDir + '/' + tp) && k.oldId != null) {
  471. writeFile(postersDir + '/' + k.oldId + '.' + t.image, readBytes(postersDir + '/' + tp), 420)
  472. k.image = t.image
  473. totals.posters = totals.posters + 1
  474. }
  475. plain = slugBaseOf(k.title, '')
  476. if (plain != '' && t.urlSegment == plain && k.urlSegment != plain) {
  477. olds = listOr(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. d = showsTable.fetch(dupId)
  486. tmdbId = d.tmdbId
  487. type = d.type
  488. d.mergedInto = keeperId
  489. d.mergedAt = now()
  490. d.mergedTmdbId = tmdbId
  491. d.tmdbId = null
  492. d.mergedSeasons = listOr(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. show = showsTable.fetch(showId)
  511. if (show == null || show.mergedInto != null) { return null }
  512. listed = listOr(show.seasons)
  513. 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 listOr(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 = listOr(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. 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. x = showsTable.fetch(showId)
  545. seasons = listOr(x.seasons)
  546. marks = listOr(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. item = queue[run.pos]
  568. run.pos = run.pos + 1
  569. if (item.kind == 'title') {
  570. 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. 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] = listOr(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

  • 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
  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre
  • dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
  • 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