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/search.hl

15.2 KB

  1. // lib/search.hl — THE SEARCH (tracker.worldapi.org#14): our own database first, then "Fetch from web" (TMDB) for titles
  2. // we don't have yet, and the import of the one picked. Used by components/search.hl (`/search/<text>`).
  3. //
  4. // dbSearch(text) our shows, movies and people — from an IN-MEMORY INDEX built once at boot (no table scan per
  5. // keystroke): every title/name split into words; each word's first 2 and first 3 characters are
  6. // a bucket key → the entries holding such a word. A query looks at ONE bucket (its longest word's
  7. // first 3 characters, or 2 for a 2-letter word) and keeps the entries where EVERY query word is
  8. // the start of one of the entry's words. A title's year is one of its words ("doctor who 2005").
  9. // webSearch(text) GET <TMDB_BASE_URL>/search/multi?query=… (one request; TV + movies only, people dropped) —
  10. // the titles we already have (same TMDB id and kind) are left out
  11. // importTitle(kind, id) a new show record from GET /tv/<id>?append_to_response=external_ids,aggregate_credits (a movie:
  12. // /movie/<id>?…,credits), then sync.hl `syncShowWith` fills it from that same answer exactly like
  13. // the daily sync does (seasons, episodes, poster, external ids); added to the index. The answer goes
  14. // back as `details`: the caller (components/search.hl) stores its cast + crew (credits.hl applyCredits,
  15. // tracker#28 — details.hl imports this file, so it cannot be called from here)
  16. //
  17. // ADULT TITLES (mission 054): our database lists only titles with `adult == false` (shows.hl isPublicTitle; unknown is
  18. // hidden) — `titleOk` holds that per show id, set by indexShow and by `searchTitleChanged` (the adult backfill and the daily
  19. // sync call it per title). The web list asks TMDB with include_adult=false AND keeps only results with `adult` false.
  20. //
  21. // ONE PROCESS PER STORAGE (hybriel#21): the index lives in the app's process and is kept up to date by importTitle; the
  22. // daily sync never changes a title. New rows get self-assigned 16-hex ids (hybriel#113), like sync.hl.
  23. import { now } from 'hl:time'
  24. import { textOr, newId } from './util.hl'
  25. import { showsTable, personsTable, genresTable, isPublicTitle, kindOfTitle, titlePath, liveShowOf } from './shows.hl'
  26. import { tmdbGet, tmdbImageBase, adultOf } from './tmdb.hl'
  27. import { syncShowWith, persistSync } from './sync.hl'
  28. import { refreshCatalogShow } from './catalog.hl'
  29. import { claimShortId } from './shortids.hl'
  30. import { wordsOf, bucketKeys, tmdbKeyOf, yearWordOf, scoreOf, matchesAll, insertTop, yearOfDate, slugBaseOf } from './search-helpers.hl'
  31. // ---- the index --------------------------------------------------------------------------------------
  32. // entries[i] = { kind ('title' | 'person'), id, words, text (the words joined by one space, without the year) }
  33. static entries = []
  34. static buckets = {}
  35. // what we have, by TMDB id: 'series:<id>' / 'movie:<id>' → our show id (the web list leaves these out)
  36. static haveTmdb = {}
  37. // tracker#18 (the daily sync's TVmaze change list, dailysync.hl): TVmaze id → our show id (kept by indexShow and
  38. // searchTitleChanged — the sync fills TVmaze ids)
  39. static haveTvmz = {}
  40. // every show slug in use (an imported title gets a free one)
  41. static slugTaken = {}
  42. // show id → true when the title may be listed (not adult, mission 054)
  43. static titleOk = {}
  44. // tracker#18 (a title renamed by the daily sync): show id → its entry's index; an entry replaced by a newer one is `stale`
  45. // (skipped by dbSearch — the buckets only ever grow)
  46. static titleEntry = {}
  47. static staleEntry = {}
  48. static indexStats = { built = false titles = 0 people = 0 keys = 0 ms = 0 }
  49. // `tie` orders equally good matches: lower first (a title: series before movies, then the newer one)
  50. static addEntry = (kind, id, &name, &extra, tie) => {
  51. words = wordsOf(name)
  52. if (words.length == 0) { return false }
  53. i = entries.length
  54. all = []
  55. for (w of words) { all.push(w) }
  56. if (extra != null && extra != '') { all.push(extra) }
  57. entries.push({ kind = kind id = id words = all text = words.join(' ') tie = tie })
  58. if (kind == 'title') { titleEntry[id] = i }
  59. for (k of bucketKeys(all)) {
  60. if (buckets[k] == null) { buckets[k] = [i] indexStats.keys = indexStats.keys + 1 } else { buckets[k].push(i) }
  61. }
  62. return true
  63. }
  64. static indexShow = (&s) => {
  65. if (s.urlSegment != null) { slugTaken[s.urlSegment] = true }
  66. // tracker#31: a merged tombstone keeps its slug taken, nothing else (its keeper is the title)
  67. if (s.mergedInto != null) {
  68. titleOk[s.id] = false
  69. return null
  70. }
  71. if (s.tmdbId != null) { haveTmdb[tmdbKeyOf(s.type, s.tmdbId)] = s.id }
  72. if (s.tvmzId != null) { haveTvmz['' + s.tvmzId] = s.id }
  73. titleOk[s.id] = isPublicTitle(s)
  74. y = toNumber(yearWordOf(s))
  75. if (addEntry('title', s.id, s.title, yearWordOf(s), (s.type == 'movie' ? 10000 : 0) + (y != null ? 3000 - y : 2999))) { indexStats.titles = indexStats.titles + 1 }
  76. return null
  77. }
  78. // tracker#26 (details.hl): a person added from a title's cast — findable by the search at once
  79. static indexPerson = (&p) => {
  80. ensureIndex()
  81. if (addEntry('person', p.id, p.name, '', 0)) { indexStats.people = indexStats.people + 1 }
  82. return null
  83. }
  84. // a title's adult flag changed (the backfill / the daily sync): listed from now on, or not
  85. static searchTitleChanged = (&showId) => {
  86. s = showsTable.fetch(showId)
  87. if (s != null) { titleOk[s.id] = isPublicTitle(s) }
  88. if (s != null && s.tvmzId != null) { haveTvmz['' + s.tvmzId] = s.id }
  89. return null
  90. }
  91. // tracker#31 (merge.hl): a duplicate became a tombstone of `keeperId` — its entry goes stale, its TMDB id (and TVmaze id) now
  92. // answer the keeper, so no import or filmography adds the title again
  93. static searchTitleMerged = (&tombId, &keeperId, &type, &tmdbId) => {
  94. ensureIndex()
  95. old = titleEntry[tombId]
  96. if (old != null) { staleEntry['' + old] = true }
  97. titleOk[tombId] = false
  98. if (tmdbId != null) { haveTmdb[tmdbKeyOf(type, tmdbId)] = keeperId }
  99. k = showsTable.fetch(keeperId)
  100. if (k != null) {
  101. if (k.urlSegment != null) { slugTaken[k.urlSegment] = true }
  102. if (k.tvmzId != null) { haveTvmz['' + k.tvmzId] = k.id }
  103. titleOk[k.id] = isPublicTitle(k)
  104. }
  105. return null
  106. }
  107. // a title's name changed (the daily sync, tracker#18): its old entry goes stale, a new one is added — found by the new name at once
  108. static searchTitleRenamed = (&showId) => {
  109. if (!indexStats.built) { return null }
  110. s = showsTable.fetch(showId)
  111. if (s == null) { return null }
  112. old = titleEntry[s.id]
  113. if (old != null && entries[old].text == wordsOf(s.title).join(' ')) { return null }
  114. if (old != null) { staleEntry['' + old] = true }
  115. indexShow(s)
  116. return null
  117. }
  118. // /people (tracker#32, people.hl peopleOfLetter): every person entry of the index — { id, text (the name's words, lower case) }
  119. static indexedPeople = () => {
  120. ensureIndex()
  121. out = []
  122. for (e of entries) { if (e.kind == 'person') { out.push(e) } }
  123. return out
  124. }
  125. // built once: at boot (project.hl calls it), else by the first search
  126. static ensureIndex = () => {
  127. if (indexStats.built) { return true }
  128. indexStats.built = true
  129. t0 = now()
  130. for (s of showsTable.find(null, null)) { indexShow(s) }
  131. for (p of personsTable.find(null, null)) {
  132. if (addEntry('person', p.id, p.name, '', 0)) { indexStats.people = indexStats.people + 1 }
  133. }
  134. indexStats.ms = now() - t0
  135. console.log('search index: ' + indexStats.titles + ' titles, ' + indexStats.people + ' people, ' + indexStats.keys + ' keys, ' + indexStats.ms + ' ms')
  136. return true
  137. }
  138. // tracker.worldapi.org#16 (people.hl): our show id for a TMDB title — `type` 'series' | 'movie' — or null
  139. static titleIdByTmdb = (&type, &tmdbId) => {
  140. ensureIndex()
  141. return haveTmdb[tmdbKeyOf(type, tmdbId)]
  142. }
  143. // tracker#18: our show id for a TVmaze id, or null
  144. static titleIdByTvmaze = (&tvmzId) => {
  145. ensureIndex()
  146. return haveTvmz['' + tvmzId]
  147. }
  148. // ---- our database ------------------------------------------------------------------------------------
  149. static titleLimit = 30
  150. static peopleLimit = 10
  151. // { text, words, titles: [{ id }], people: [{ id }], titleCount, peopleCount, tooShort }
  152. static dbSearch = (text) => {
  153. ensureIndex()
  154. out = { text = text titles = [] people = [] titleCount = 0 peopleCount = 0 tooShort = false ms = 0 }
  155. t0 = now()
  156. qw = wordsOf(text)
  157. let longest = ''
  158. for (w of qw) { if (w.length > longest.length) { longest = w } }
  159. if (longest.length < 2) { out.tooShort = true return out }
  160. key = longest.slice(0, longest.length >= 3 ? 3 : 2)
  161. cand = buckets[key]
  162. if (cand == null) { return out }
  163. qtext = qw.join(' ')
  164. let titles = []
  165. let people = []
  166. for (i of cand) {
  167. let e = entries[i]
  168. if ((e.kind != 'title' || (titleOk[e.id] == true && staleEntry['' + i] != true)) && matchesAll(e, qw)) {
  169. let item = { score = scoreOf(e, qw, qtext) len = e.text.length tie = e.tie i = i }
  170. if (e.kind == 'title') {
  171. out.titleCount = out.titleCount + 1
  172. titles = insertTop(titles, item, titleLimit)
  173. } else {
  174. out.peopleCount = out.peopleCount + 1
  175. people = insertTop(people, item, peopleLimit)
  176. }
  177. }
  178. }
  179. for (t of titles) { out.titles.push({ id = entries[t.i].id }) }
  180. for (p of people) { out.people.push({ id = entries[p.i].id }) }
  181. out.ms = now() - t0
  182. return out
  183. }
  184. // ---- the web (TMDB) -----------------------------------------------------------------------------------
  185. // TMDB's search: [{ kind ('tv' | 'movie'), tmdbId, title, year, overview, posterUrl }] of what we DON'T have, or
  186. // { failed }. One request (search/multi, page 1 = up to 20 results); people and titles we have are left out.
  187. static webSearch = (&text) => {
  188. ensureIndex()
  189. qw = wordsOf(text)
  190. if (text == null || text.trim().length < 2 || qw.length == 0) { return { rows = [] left = 0 failed = null } }
  191. j = tmdbGet('/search/multi?query=' + encodeURIComponent(text.trim()) + '&include_adult=false&page=1')
  192. if (j.failed != null) { return { rows = [] left = 0 failed = j.failed } }
  193. rows = []
  194. let left = 0
  195. if (j.results != null) {
  196. for (r of j.results) {
  197. let kind = r.media_type
  198. // mission 054: only what TMDB says is not adult (include_adult=false already, and the result's own flag)
  199. if ((kind == 'tv' || kind == 'movie') && r.id != null && hlTypeName(r.id) == 'Number' && adultOf(r) == false) {
  200. if (haveTmdb[tmdbKeyOf(kind == 'tv' ? 'series' : 'movie', r.id)] != null) {
  201. left = left + 1
  202. } else {
  203. let title = kind == 'tv' ? textOr(r.name) : textOr(r.title)
  204. let year = yearOfDate(kind == 'tv' ? r.first_air_date : r.release_date)
  205. if (title != null) {
  206. rows.push({ kind = kind tmdbId = r.id title = title year = year
  207. overview = textOr(r.overview) != null ? r.overview : ''
  208. posterUrl = textOr(r.poster_path) != null ? tmdbImageBase + '/w92' + r.poster_path : '/posters/none' })
  209. }
  210. }
  211. }
  212. }
  213. }
  214. return { rows = rows left = left failed = null }
  215. }
  216. // ---- the import ------------------------------------------------------------------------------------
  217. // "Doctor Who" → "Doctor-Who" (the migrated urlSegment form: letters and digits, everything else one '-'); a slug in use
  218. // gets "-2", "-3", … like the migrated ones ("Raised-by-Wolves-2"); a title with no Latin letter at all → "title-<tmdbId>"
  219. static slugOf = (&title, &tmdbId) => {
  220. out = slugBaseOf(title, 'title-' + tmdbId)
  221. if (slugTaken[out] != true) { return out }
  222. let n = 2
  223. while (slugTaken[out + '-' + n] == true) { n = n + 1 }
  224. return out + '-' + n
  225. }
  226. // our genres by lower-case name (TMDB's "Drama" → ours); TMDB-only names ("Sci-Fi & Fantasy") are left out
  227. static genreIdsOf = (&tmdbGenres) => {
  228. out = []
  229. if (tmdbGenres == null) { return out }
  230. byName = {}
  231. for (g of genresTable.find(null, null)) { if (g.name != null) { byName[('' + g.name).toLowerCase()] = g.id } }
  232. for (tg of tmdbGenres) {
  233. let id = tg.name != null ? byName[('' + tg.name).toLowerCase()] : null
  234. if (id != null && !out.includes(id)) { out.push(id) }
  235. }
  236. return out
  237. }
  238. // `kind` 'tv' | 'movie', `tmdbId` a number → { slug, path (its page, shows.hl titlePath), show, created, sync, details } or { error }. A title we already have (same
  239. // TMDB id and kind) is not added again: its slug is answered.
  240. static importTitle = (&kind, tmdbId) => {
  241. if (kind != 'tv' && kind != 'movie') { return { error = 'unknown kind' } }
  242. // `tid`, not `id`: inside the record literal below an entry named `id` would shadow it for every later entry
  243. tid = toNumber('' + tmdbId)
  244. if (tid == null || tid <= 0 || tid % 1 != 0) { return { error = 'bad TMDB id' } }
  245. ensureIndex()
  246. type = kind == 'tv' ? 'series' : 'movie'
  247. have = haveTmdb[tmdbKeyOf(type, tid)]
  248. if (have != null) {
  249. // tracker#31: a merged tombstone answers its keeper
  250. let s = liveShowOf(showsTable.fetch(have))
  251. if (s != null) { return { slug = s.urlSegment path = titlePath(s) show = s.id created = false } }
  252. }
  253. d = tmdbGet('/' + kind + '/' + tid + '?append_to_response=external_ids,' + (kind == 'tv' ? 'aggregate_credits' : 'credits'))
  254. if (d.failed != null) { return { error = 'TMDB: ' + d.failed } }
  255. let title = kind == 'tv' ? textOr(d.name) : textOr(d.title)
  256. if (title == null) { return { error = 'TMDB has no title for ' + kind + ' ' + tid } }
  257. release = textOr(kind == 'tv' ? d.first_air_date : d.release_date)
  258. year = release != null && release.length >= 4 ? toNumber(release.slice(0, 4)) : null
  259. // tracker#18: `summary` is the creator's own text — TMDB's goes to tmdbSummary only
  260. record = {
  261. id = newId() title = title summary = null tmdbSummary = textOr(d.overview) tmdbId = tid
  262. language = textOr(d.original_language) type = type release = release year = year image = null
  263. urlSegment = slugOf(title, tid) episodesCount = null homepage = textOr(d.homepage) imdbId = null seasonsCount = null
  264. status = textOr(d.status) tagline = textOr(d.tagline) tvdbId = null tvmzId = null genres = genreIdsOf(d.genres)
  265. cast = [] seasons = [] oldId = 'tmdb-' + kind + '-' + tid imported = now() adult = adultOf(d)
  266. }
  267. // tracker.worldapi.org#21: a TV title's TMDB type and kind (set after the literal: an entry named `kind` would shadow the
  268. // parameter for the entries after it)
  269. if (kind == 'tv') {
  270. record.tmdbType = textOr(d.type)
  271. record.kind = kindOfTitle(record.tmdbType, record.genres)
  272. }
  273. // tracker#31: looked up again right before the put — the TMDB request above may have taken a while
  274. again = titleIdByTmdb(type, tid)
  275. if (again != null) {
  276. let s = liveShowOf(showsTable.fetch(again))
  277. if (s != null) { return { slug = s.urlSegment path = titlePath(s) show = s.id created = false } }
  278. }
  279. // mission 060: its short id (shortids.hl — unique across shows and persons)
  280. record.shortId = claimShortId('show', record.id, record.oldId)
  281. if (showsTable.put(record) == null) { return { error = 'could not store the title: ' + showsTable.lastError() } }
  282. indexShow(record)
  283. r = syncShowWith(record.id, d)
  284. persistSync()
  285. // the public lists (catalog.hl, #13) get it at once, like a show the daily sync touched (merge of #13 + #14)
  286. refreshCatalogShow(record.id)
  287. console.log('search import: ' + kind + ' ' + tid + ' "' + title + '" → ' + titlePath(record) + ' (newSeasons=' + r.newSeasons + ' newEpisodes=' + r.newEpisodes + ' posters=' + r.posters + ' ids=' + r.ids + ' requests=' + r.requests + ' errors=' + r.errors + (r.error != '' ? ' ' + r.error : '') + ')')
  288. return { slug = record.urlSegment path = titlePath(record) show = record.id created = true sync = r details = d }
  289. }

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