gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit3909810d3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre3909810d/search.hl

20.1 KB

  1. // 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 tmdbsync.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 (details.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 tmdbsync.hl.
  23. import { now } from 'hl:time'
  24. import { randomBytes } from 'hl:crypto'
  25. import { showsTable, personsTable, genresTable, isPublicTitle, kindOfTitle, titlePath } from './shows.hl'
  26. import { tmdbGet, tmdbImageBase, syncShowWith, persistSync, textOr, adultOf } from './tmdbsync.hl'
  27. import { refreshCatalogShow } from './catalog.hl'
  28. import { claimShortId } from './shortids.hl'
  29. // ---- text → words ---------------------------------------------------------------------------------
  30. // lower case, apostrophes dropped ("Grey's" → "greys"), every other punctuation mark a word break, the common Latin
  31. // accents folded ("Amélie" → "amelie") — the same for the titles and the query
  32. static apostrophes = ["'", '’', '`', '´']
  33. static breaks = ['.', ',', ':', ';', '!', '?', '(', ')', '[', ']', '{', '}', '"', '“', '”', '„', '-', '–', '—', '/', '&', '+', '*', '#', '_', '|', '…', '·', '@', '=', '~', '<', '>']
  34. static folds = [['á', 'a'], ['à', 'a'], ['â', 'a'], ['ä', 'a'], ['ã', 'a'], ['å', 'a'], ['æ', 'ae'], ['ç', 'c'], ['é', 'e'], ['è', 'e'], ['ê', 'e'], ['ë', 'e'],
  35. ['í', 'i'], ['ì', 'i'], ['î', 'i'], ['ï', 'i'], ['ñ', 'n'], ['ó', 'o'], ['ò', 'o'], ['ô', 'o'], ['ö', 'o'], ['õ', 'o'], ['ø', 'o'], ['œ', 'oe'],
  36. ['ú', 'u'], ['ù', 'u'], ['û', 'u'], ['ü', 'u'], ['ý', 'y'], ['ÿ', 'y'], ['ß', 'ss'], ['š', 's'], ['ž', 'z'], ['č', 'c'], ['ł', 'l']]
  37. static normalize = (&s) => {
  38. if (s == null) { return '' }
  39. let t = ('' + s).toLowerCase()
  40. for (p of apostrophes) { if (t.includes(p)) { t = t.replaceAll(p, '') } }
  41. for (p of breaks) { if (t.includes(p)) { t = t.replaceAll(p, ' ') } }
  42. for (f of folds) { if (t.includes(f[0])) { t = t.replaceAll(f[0], f[1]) } }
  43. return t
  44. }
  45. static wordsOf = (&s) => {
  46. let out = []
  47. for (w of normalize(s).split(' ')) { if (w != '') { out.push(w) } }
  48. return out
  49. }
  50. // ---- the index --------------------------------------------------------------------------------------
  51. // entries[i] = { kind ('title' | 'person'), id, words, text (the words joined by one space, without the year) }
  52. static entries = []
  53. static buckets = {}
  54. // what we have, by TMDB id: 'series:<id>' / 'movie:<id>' → our show id (the web list leaves these out)
  55. static haveTmdb = {}
  56. // tracker#18 (the daily sync's TVmaze change list, deltasync.hl): TVmaze id → our show id (kept by indexShow and
  57. // searchTitleChanged — the sync fills TVmaze ids)
  58. static haveTvmz = {}
  59. // every show slug in use (an imported title gets a free one)
  60. static slugTaken = {}
  61. // show id → true when the title may be listed (not adult, mission 054)
  62. static titleOk = {}
  63. // tracker#18 (a title renamed by the daily sync): show id → its entry's index; an entry replaced by a newer one is `stale`
  64. // (skipped by dbSearch — the buckets only ever grow)
  65. static titleEntry = {}
  66. static staleEntry = {}
  67. static indexStats = { built = false titles = 0 people = 0 keys = 0 ms = 0 }
  68. static bucketKeys = (&words) => {
  69. let seen = {}
  70. let out = []
  71. for (w of words) {
  72. let n = 2
  73. while (n <= 3) {
  74. if (w.length >= n) {
  75. let k = w.slice(0, n)
  76. if (seen[k] != true) { seen[k] = true out.push(k) }
  77. }
  78. n = n + 1
  79. }
  80. }
  81. return out
  82. }
  83. // `tie` orders equally good matches: lower first (a title: series before movies, then the newer one)
  84. static addEntry = (kind, id, &name, &extra, tie) => {
  85. let words = wordsOf(name)
  86. if (words.length == 0) { return false }
  87. let i = entries.length
  88. let all = []
  89. for (w of words) { all.push(w) }
  90. if (extra != null && extra != '') { all.push(extra) }
  91. entries.push({ kind = kind id = id words = all text = words.join(' ') tie = tie })
  92. if (kind == 'title') { titleEntry[id] = i }
  93. for (k of bucketKeys(all)) {
  94. if (buckets[k] == null) { buckets[k] = [i] indexStats.keys = indexStats.keys + 1 } else { buckets[k].push(i) }
  95. }
  96. return true
  97. }
  98. static tmdbKeyOf = (&type, &tmdbId) => { return (type == 'movie' ? 'movie:' : 'series:') + tmdbId }
  99. static yearWordOf = (&show) => {
  100. if (show.year != null) { return '' + show.year }
  101. if (show.release != null && hlTypeName(show.release) == 'String' && show.release.length >= 4) { return show.release.slice(0, 4) }
  102. return ''
  103. }
  104. static indexShow = (&s) => {
  105. if (s.urlSegment != null) { slugTaken[s.urlSegment] = true }
  106. if (s.tmdbId != null) { haveTmdb[tmdbKeyOf(s.type, s.tmdbId)] = s.id }
  107. if (s.tvmzId != null) { haveTvmz['' + s.tvmzId] = s.id }
  108. titleOk[s.id] = isPublicTitle(s)
  109. let y = toNumber(yearWordOf(s))
  110. if (addEntry('title', s.id, s.title, yearWordOf(s), (s.type == 'movie' ? 10000 : 0) + (y != null ? 3000 - y : 2999))) { indexStats.titles = indexStats.titles + 1 }
  111. return null
  112. }
  113. // tracker#26 (details.hl): a person added from a title's cast — findable by the search at once
  114. static indexPerson = (&p) => {
  115. ensureIndex()
  116. if (addEntry('person', p.id, p.name, '', 0)) { indexStats.people = indexStats.people + 1 }
  117. return null
  118. }
  119. // a title's adult flag changed (the backfill / the daily sync): listed from now on, or not
  120. static searchTitleChanged = (&showId) => {
  121. let s = showsTable.fetch(showId)
  122. if (s != null) { titleOk[s.id] = isPublicTitle(s) }
  123. if (s != null && s.tvmzId != null) { haveTvmz['' + s.tvmzId] = s.id }
  124. return null
  125. }
  126. // 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
  127. static searchTitleRenamed = (&showId) => {
  128. if (!indexStats.built) { return null }
  129. let s = showsTable.fetch(showId)
  130. if (s == null) { return null }
  131. let old = titleEntry[s.id]
  132. if (old != null && entries[old].text == wordsOf(s.title).join(' ')) { return null }
  133. if (old != null) { staleEntry['' + old] = true }
  134. indexShow(s)
  135. return null
  136. }
  137. // built once: at boot (project.hl calls it), else by the first search
  138. static ensureIndex = () => {
  139. if (indexStats.built) { return true }
  140. indexStats.built = true
  141. let t0 = now()
  142. for (s of showsTable.find(null, null)) { indexShow(s) }
  143. for (p of personsTable.find(null, null)) {
  144. if (addEntry('person', p.id, p.name, '', 0)) { indexStats.people = indexStats.people + 1 }
  145. }
  146. indexStats.ms = now() - t0
  147. console.log('search index: ' + indexStats.titles + ' titles, ' + indexStats.people + ' people, ' + indexStats.keys + ' keys, ' + indexStats.ms + ' ms')
  148. return true
  149. }
  150. // tracker.worldapi.org#16 (people.hl): our show id for a TMDB title — `type` 'series' | 'movie' — or null
  151. static titleIdByTmdb = (&type, &tmdbId) => {
  152. ensureIndex()
  153. return haveTmdb[tmdbKeyOf(type, tmdbId)]
  154. }
  155. // tracker#18: our show id for a TVmaze id, or null
  156. static titleIdByTvmaze = (&tvmzId) => {
  157. ensureIndex()
  158. return haveTvmz['' + tvmzId]
  159. }
  160. // ---- the query as it comes in the address ------------------------------------------------------------
  161. // `/search/<text>`: a full page load hands the page the path segment already DECODED by the server (measured: `%2F` splits
  162. // the segment → 404, so the page never puts one in the address), a client-side navigation the RAW one — decoded here.
  163. // decodeURIComponent stops the request on a malformed one ('%zz', a broken UTF-8 run), so every %-run is checked first; a
  164. // bad one is taken literally. (A text that itself looks like an escape, '%41', is decoded twice on a reload — accepted.)
  165. static hexDigits = '0123456789abcdef'
  166. static hexAt = (&s, &i) => {
  167. if (i + 3 > s.length) { return -1 }
  168. let a = hexDigits.indexOf(s.slice(i + 1, i + 2).toLowerCase())
  169. let b = hexDigits.indexOf(s.slice(i + 2, i + 3).toLowerCase())
  170. if (a < 0 || b < 0) { return -1 }
  171. return a * 16 + b
  172. }
  173. // the bytes of one run of %XX are valid UTF-8 (the rules decodeURIComponent applies)
  174. static utf8Ok = (&bytes) => {
  175. let i = 0
  176. while (i < bytes.length) {
  177. let b = bytes[i]
  178. let need = 0
  179. let lo = 128
  180. let hi = 191
  181. if (b < 128) { need = 0 } else if (b >= 194 && b <= 223) { need = 1 } else if (b >= 224 && b <= 239) {
  182. need = 2
  183. if (b == 224) { lo = 160 }
  184. if (b == 237) { hi = 159 }
  185. } else if (b >= 240 && b <= 244) {
  186. need = 3
  187. if (b == 240) { lo = 144 }
  188. if (b == 244) { hi = 143 }
  189. } else { return false }
  190. if (i + need > bytes.length - 1) { return false }
  191. let k = 1
  192. while (k <= need) {
  193. let c = bytes[i + k]
  194. if (k == 1 && (c < lo || c > hi)) { return false }
  195. if (k > 1 && (c < 128 || c > 191)) { return false }
  196. k = k + 1
  197. }
  198. i = i + need + 1
  199. }
  200. return true
  201. }
  202. static decodableUri = (&s) => {
  203. let i = 0
  204. while (i < s.length) {
  205. if (s.slice(i, i + 1) == '%') {
  206. let run = []
  207. while (i < s.length && s.slice(i, i + 1) == '%') {
  208. let v = hexAt(s, i)
  209. if (v < 0) { return false }
  210. run.push(v)
  211. i = i + 3
  212. }
  213. if (!utf8Ok(run)) { return false }
  214. } else {
  215. i = i + 1
  216. }
  217. }
  218. return true
  219. }
  220. static maxQuery = 100
  221. static queryOfParam = (&raw) => {
  222. if (raw == null || hlTypeName(raw) != 'String' || raw == '') { return '' }
  223. let t = raw.includes('%') && decodableUri(raw) ? decodeURIComponent(raw) : raw
  224. return t.length > maxQuery ? t.slice(0, maxQuery) : t
  225. }
  226. // ---- our database ------------------------------------------------------------------------------------
  227. static titleLimit = 30
  228. static peopleLimit = 10
  229. // lower is better: the whole name (0), the name starts with the query (1), its first word does (2), anything else (3);
  230. // then the shorter name
  231. static scoreOf = (&e, &qw, &qtext) => {
  232. if (e.text == qtext) { return 0 }
  233. if (e.text.startsWith(qtext)) { return 1 }
  234. if (e.words[0].startsWith(qw[0])) { return 2 }
  235. return 3
  236. }
  237. static matchesAll = (&e, &qw) => {
  238. for (q of qw) {
  239. let hit = false
  240. for (w of e.words) { if (!hit && w.startsWith(q)) { hit = true } }
  241. if (!hit) { return false }
  242. }
  243. return true
  244. }
  245. // keeps the best `limit` of { score, len, tie, i } in order (hand-written: no list sort(), hybriel#1)
  246. static insertTop = (&top, &item, &limit) => {
  247. let out = []
  248. let inserted = false
  249. for (x of top) {
  250. if (!inserted && (item.score < x.score || (item.score == x.score && (item.len < x.len || (item.len == x.len && item.tie < x.tie))))) { out.push(item) inserted = true }
  251. if (out.length < limit) { out.push(x) }
  252. }
  253. if (!inserted && out.length < limit) { out.push(item) }
  254. return out
  255. }
  256. // { text, words, titles: [{ id }], people: [{ id }], titleCount, peopleCount, tooShort }
  257. static dbSearch = (text) => {
  258. ensureIndex()
  259. let out = { text = text titles = [] people = [] titleCount = 0 peopleCount = 0 tooShort = false ms = 0 }
  260. let t0 = now()
  261. let qw = wordsOf(text)
  262. let longest = ''
  263. for (w of qw) { if (w.length > longest.length) { longest = w } }
  264. if (longest.length < 2) { out.tooShort = true return out }
  265. let key = longest.slice(0, longest.length >= 3 ? 3 : 2)
  266. let cand = buckets[key]
  267. if (cand == null) { return out }
  268. let qtext = qw.join(' ')
  269. let titles = []
  270. let people = []
  271. for (i of cand) {
  272. let e = entries[i]
  273. if ((e.kind != 'title' || (titleOk[e.id] == true && staleEntry['' + i] != true)) && matchesAll(e, qw)) {
  274. let item = { score = scoreOf(e, qw, qtext) len = e.text.length tie = e.tie i = i }
  275. if (e.kind == 'title') {
  276. out.titleCount = out.titleCount + 1
  277. titles = insertTop(titles, item, titleLimit)
  278. } else {
  279. out.peopleCount = out.peopleCount + 1
  280. people = insertTop(people, item, peopleLimit)
  281. }
  282. }
  283. }
  284. for (t of titles) { out.titles.push({ id = entries[t.i].id }) }
  285. for (p of people) { out.people.push({ id = entries[p.i].id }) }
  286. out.ms = now() - t0
  287. return out
  288. }
  289. // ---- the web (TMDB) -----------------------------------------------------------------------------------
  290. static yearOfDate = (&d) => { return textOr(d) != null && d.length >= 4 ? d.slice(0, 4) : '' }
  291. // TMDB's search: [{ kind ('tv' | 'movie'), tmdbId, title, year, overview, posterUrl }] of what we DON'T have, or
  292. // { failed }. One request (search/multi, page 1 = up to 20 results); people and titles we have are left out.
  293. static webSearch = (&text) => {
  294. ensureIndex()
  295. let qw = wordsOf(text)
  296. if (text == null || text.trim().length < 2 || qw.length == 0) { return { rows = [] left = 0 failed = null } }
  297. let j = tmdbGet('/search/multi?query=' + encodeURIComponent(text.trim()) + '&include_adult=false&page=1')
  298. if (j.failed != null) { return { rows = [] left = 0 failed = j.failed } }
  299. let rows = []
  300. let left = 0
  301. if (j.results != null) {
  302. for (r of j.results) {
  303. let kind = r.media_type
  304. // mission 054: only what TMDB says is not adult (include_adult=false already, and the result's own flag)
  305. if ((kind == 'tv' || kind == 'movie') && r.id != null && hlTypeName(r.id) == 'Number' && adultOf(r) == false) {
  306. if (haveTmdb[tmdbKeyOf(kind == 'tv' ? 'series' : 'movie', r.id)] != null) {
  307. left = left + 1
  308. } else {
  309. let title = kind == 'tv' ? textOr(r.name) : textOr(r.title)
  310. let year = yearOfDate(kind == 'tv' ? r.first_air_date : r.release_date)
  311. if (title != null) {
  312. rows.push({ kind = kind tmdbId = r.id title = title year = year
  313. overview = textOr(r.overview) != null ? r.overview : ''
  314. posterUrl = textOr(r.poster_path) != null ? tmdbImageBase + '/w92' + r.poster_path : '/posters/none' })
  315. }
  316. }
  317. }
  318. }
  319. }
  320. return { rows = rows left = left failed = null }
  321. }
  322. // ---- the import ------------------------------------------------------------------------------------
  323. static slugChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
  324. // "Doctor Who" → "Doctor-Who" (the migrated urlSegment form: letters and digits, everything else one '-'); a slug in use
  325. // gets "-2", "-3", … like the migrated ones ("Raised-by-Wolves-2"); a title with no Latin letter at all → "title-<tmdbId>"
  326. static slugOf = (&title, &tmdbId) => {
  327. let out = slugBaseOf(title, 'title-' + tmdbId)
  328. if (slugTaken[out] != true) { return out }
  329. let n = 2
  330. while (slugTaken[out + '-' + n] == true) { n = n + 1 }
  331. return out + '-' + n
  332. }
  333. // the slug of a text without the "in use" check — `fallback` when it has no Latin letter at all (people.hl personSlugOf
  334. // uses it for new people, tracker#26)
  335. static slugBaseOf = (&title, fallback) => {
  336. let t = normalizeSlugText(title)
  337. let out = ''
  338. let dash = false
  339. let i = 0
  340. while (i < t.length) {
  341. let c = t.slice(i, i + 1)
  342. if (slugChars.includes(c)) {
  343. if (dash && out != '') { out = out + '-' }
  344. out = out + c
  345. dash = false
  346. } else {
  347. dash = true
  348. }
  349. i = i + 1
  350. }
  351. if (out == '') { out = fallback }
  352. if (out.length > 80) { out = out.slice(0, 80) }
  353. return out
  354. }
  355. // the accents folded (keeping the case) and apostrophes dropped, so "Grey's Anatomy" → "Greys-Anatomy"
  356. static normalizeSlugText = (&s) => {
  357. let t = '' + s
  358. for (p of apostrophes) { if (t.includes(p)) { t = t.replaceAll(p, '') } }
  359. for (f of folds) {
  360. if (t.includes(f[0])) { t = t.replaceAll(f[0], f[1]) }
  361. let up = f[0].toUpperCase()
  362. if (up != f[0] && t.includes(up)) { t = t.replaceAll(up, f[1].toUpperCase()) }
  363. }
  364. return t
  365. }
  366. // our genres by lower-case name (TMDB's "Drama" → ours); TMDB-only names ("Sci-Fi & Fantasy") are left out
  367. static genreIdsOf = (&tmdbGenres) => {
  368. let out = []
  369. if (tmdbGenres == null) { return out }
  370. let byName = {}
  371. for (g of genresTable.find(null, null)) { if (g.name != null) { byName[('' + g.name).toLowerCase()] = g.id } }
  372. for (tg of tmdbGenres) {
  373. let id = tg.name != null ? byName[('' + tg.name).toLowerCase()] : null
  374. if (id != null && !out.includes(id)) { out.push(id) }
  375. }
  376. return out
  377. }
  378. // `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
  379. // TMDB id and kind) is not added again: its slug is answered.
  380. static importTitle = (&kind, tmdbId) => {
  381. if (kind != 'tv' && kind != 'movie') { return { error = 'unknown kind' } }
  382. // `tid`, not `id`: inside the record literal below an entry named `id` would shadow it for every later entry
  383. let tid = toNumber('' + tmdbId)
  384. if (tid == null || tid <= 0 || tid % 1 != 0) { return { error = 'bad TMDB id' } }
  385. ensureIndex()
  386. let type = kind == 'tv' ? 'series' : 'movie'
  387. let have = haveTmdb[tmdbKeyOf(type, tid)]
  388. if (have != null) {
  389. let s = showsTable.fetch(have)
  390. if (s != null) { return { slug = s.urlSegment path = titlePath(s) show = s.id created = false } }
  391. }
  392. let d = tmdbGet('/' + kind + '/' + tid + '?append_to_response=external_ids,' + (kind == 'tv' ? 'aggregate_credits' : 'credits'))
  393. if (d.failed != null) { return { error = 'TMDB: ' + d.failed } }
  394. let title = kind == 'tv' ? textOr(d.name) : textOr(d.title)
  395. if (title == null) { return { error = 'TMDB has no title for ' + kind + ' ' + tid } }
  396. let release = textOr(kind == 'tv' ? d.first_air_date : d.release_date)
  397. let year = release != null && release.length >= 4 ? toNumber(release.slice(0, 4)) : null
  398. // tracker#18: `summary` is the creator's own text — TMDB's goes to tmdbSummary only
  399. let record = {
  400. id = randomBytes(8, 'hex') title = title summary = null tmdbSummary = textOr(d.overview) tmdbId = tid
  401. language = textOr(d.original_language) type = type release = release year = year image = null
  402. urlSegment = slugOf(title, tid) episodesCount = null homepage = textOr(d.homepage) imdbId = null seasonsCount = null
  403. status = textOr(d.status) tagline = textOr(d.tagline) tvdbId = null tvmzId = null genres = genreIdsOf(d.genres)
  404. cast = [] seasons = [] oldId = 'tmdb-' + kind + '-' + tid imported = now() adult = adultOf(d)
  405. }
  406. // tracker.worldapi.org#21: a TV title's TMDB type and kind (set after the literal: an entry named `kind` would shadow the
  407. // parameter for the entries after it)
  408. if (kind == 'tv') {
  409. record.tmdbType = textOr(d.type)
  410. record.kind = kindOfTitle(record.tmdbType, record.genres)
  411. }
  412. // mission 060: its short id (shortids.hl — unique across shows and persons)
  413. record.shortId = claimShortId('show', record.id, record.oldId)
  414. if (showsTable.put(record) == null) { return { error = 'could not store the title: ' + showsTable.lastError() } }
  415. indexShow(record)
  416. let r = syncShowWith(record.id, d)
  417. persistSync()
  418. // the public lists (catalog.hl, #13) get it at once, like a show the daily sync touched (merge of #13 + #14)
  419. refreshCatalogShow(record.id)
  420. 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 : '') + ')')
  421. return { slug = record.urlSegment path = titlePath(record) show = record.id created = true sync = r details = d }
  422. }

Branches

Latest commits

  • 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
  • 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre