gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf40c250ef40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermref40c250e/people.hl

14.9 KB

  1. // people.hl — THE PERSON PAGE's data (tracker.worldapi.org#16, components/person.hl `/person/<slug>`): a person by slug,
  2. // their facts and filmography, and the ONE-TIME fetch of their whole filmography from TMDB on the first visit. Creator:
  3. // "the movies come from artist scrolls. when one clicks on an artist that has no dataset yet the old version fetched that
  4. // artist and its entire bibilogrqphy so its profile was complete".
  5. //
  6. // personBySlug(slug) the person (slug = `urlSegment`) — a slug → id map built once (15k people, one scan)
  7. // personInfoOf(p) name, photo URL, born/died lines, bio
  8. // filmographyOf(p) their credits (`p.shows`): poster, title, year, role, kind — newest first, adult titles hidden
  9. // personFillStep(id) ONE step of the fetch, called again and again by the page until it answers `done`:
  10. // 1st call: GET <TMDB>/person/<tmdbId>?append_to_response=combined_credits (ONE request: details +
  11. // every cast/crew credit) + the photo (w185, like the posters); the person's facts are updated;
  12. // every next call stores the next `fillBatch` titles; the last one links the credits and marks
  13. // the person complete (`filmographyAt`, ms). hl:web serves one request at a time — between two
  14. // steps every other request is served, so a person with 300 credits never holds the server.
  15. //
  16. // COMPLETE = `filmographyAt` set: never fetched again (the daily sync may refresh followed people later — not built).
  17. // Migrated people are NOT complete (153 of them carry the old tracker's credits — kept, the fetch adds the rest).
  18. // A title we don't have → a MINIMAL show record (title, year, type, tmdbId, `adult`, overview, genres by TMDB id, language;
  19. // no seasons, no poster file — the daily sync / a backfill brings those), shaped like search.hl importTitle's
  20. // (`oldId tmdb-<tv|movie>-<id>`, a free slug), indexed for the search and put into the public lists (catalog.hl).
  21. // The person's credits (`shows`, the migrated field): [{ show, character, posterPath, adult }] — `character` is the role
  22. // text (the characters, then crew jobs like "Director", " / " between them); `posterPath` = TMDB's, so the page can show
  23. // TMDB's small poster (w92, like search's web rows) until we have the file. ADULT (TMDB's flag, mission 054): a row is
  24. // shown only for a PUBLIC title (shows.hl `isPublicTitle`: `adult == false`, unknown hidden like the public lists) whose
  25. // credit is not adult either (mission 056 merge).
  26. // ONE PROCESS PER STORAGE (hybriel#21); new rows get self-assigned 16-hex ids (hybriel#113), like tmdbsync.hl.
  27. import { now } from 'hl:time'
  28. import { fetch } from 'hl:fetch'
  29. import { randomBytes } from 'hl:crypto'
  30. import { writeFile, mkDir, exists } from 'hl:fs'
  31. import { personsTable, showsTable, genresTable, storageDir, showById, showYear, posterUrlOf, isPublicTitle } from './shows.hl'
  32. import { tmdbGet, tmdbImageBase, textOr } from './tmdbsync.hl'
  33. import { titleIdByTmdb, indexShow, slugOf } from './search.hl'
  34. import { refreshCatalogShow, sortDesc } from './catalog.hl'
  35. static profilesDir = storageDir + '/profiles'
  36. // titles stored per step (real data: see STATUS.md ticket #16 for the per-step time)
  37. static fillBatch = 40
  38. static listOr = (v) => { return v == null ? [] : v }
  39. // ---- the person -------------------------------------------------------------------------------------------------
  40. static slugIndex = { built = false ids = {} }
  41. static personBySlug = (slug) => {
  42. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  43. if (!slugIndex.built) {
  44. let m = {}
  45. for (p of personsTable.find(null, null)) { if (p.urlSegment != null) { m[p.urlSegment] = p.id } }
  46. slugIndex.ids = m
  47. slugIndex.built = true
  48. }
  49. let id = slugIndex.ids[slug]
  50. return id == null ? null : personsTable.fetch(id)
  51. }
  52. static isComplete = (p) => { return p.filmographyAt != null }
  53. // the page asks TMDB for this person: not complete yet, and TMDB knows them
  54. static needsFill = (p) => { return !isComplete(p) && p.tmdbId != null }
  55. // the photo: /profiles/<oldId>.<ext> (project.hl profileRoute) — only when the file is there (the migrated `image` = 'jpg'
  56. // of 129 people never came with a file)
  57. static profileName = (p) => {
  58. if (p.oldId == null || p.image == null || p.image == '') { return null }
  59. return p.oldId + '.' + p.image
  60. }
  61. static photoUrlOf = (p) => {
  62. let name = profileName(p)
  63. return name != null && exists(profilesDir + '/' + name) ? '/profiles/' + name : ''
  64. }
  65. static monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
  66. // '1986-03-04' → '4 March 1986'; a bare year or anything else as it is
  67. static dateText = (d) => {
  68. let t = textOr(d)
  69. if (t == null) { return null }
  70. if (t.length < 10) { return t }
  71. let m = toNumber(t.slice(5, 7))
  72. let day = toNumber(t.slice(8, 10))
  73. if (m == null || day == null || m < 1 || m > 12) { return t }
  74. return day + ' ' + monthNames[m - 1] + ' ' + t.slice(0, 4)
  75. }
  76. // { name, photoUrl, born, died, bio } — every field a plain string ('' = none)
  77. static personInfoOf = (p) => {
  78. let born = dateText(p.birthDate)
  79. let place = textOr(p.birthPlace)
  80. let bornText = ''
  81. if (born != null && place != null) { bornText = 'Born ' + born + ' in ' + place } else if (born != null) { bornText = 'Born ' + born } else if (place != null) { bornText = 'Born in ' + place }
  82. let died = dateText(p.deathDate)
  83. return { name = p.name != null ? p.name : '' photoUrl = photoUrlOf(p) born = bornText died = died != null ? 'Died ' + died : ''
  84. bio = textOr(p.biography) != null ? p.biography : '' }
  85. }
  86. // ---- the filmography --------------------------------------------------------------------------------------------
  87. // the credits, one row per title (a title twice — the migrated credit and TMDB's — is ONE row, roles merged), newest
  88. // first by the title's release ('YYYY-MM-DD', else its year), undated last; only public titles (no adult/unknown)
  89. static filmographyOf = (p) => {
  90. let byShow = {}
  91. let order = []
  92. for (c of listOr(p.shows)) {
  93. if (c != null && c.show != null) {
  94. let role = textOr(c.character)
  95. let prev = byShow[c.show]
  96. if (prev == null) {
  97. byShow[c.show] = { role = role != null ? role : '' posterPath = textOr(c.posterPath) adult = c.adult == true }
  98. order.push(c.show)
  99. } else {
  100. let x = prev
  101. if (role != null && !(' / ' + x.role + ' / ').includes(' / ' + role + ' / ')) { x.role = x.role == '' ? role : x.role + ' / ' + role }
  102. if (x.posterPath == null) { x.posterPath = textOr(c.posterPath) }
  103. if (c.adult == true) { x.adult = true }
  104. byShow[c.show] = x
  105. }
  106. }
  107. }
  108. let list = []
  109. for (sid of order) {
  110. let c = byShow[sid]
  111. let s = showById(sid)
  112. if (isPublicTitle(s) && !c.adult) {
  113. let y = showYear(s)
  114. let release = textOr(s.release)
  115. let key = release != null && release.length >= 4 ? release : (y != null ? y : '')
  116. let poster = posterUrlOf(s)
  117. if (poster == '/posters/none' && c.posterPath != null) { poster = tmdbImageBase + '/w92' + c.posterPath }
  118. list.push({ key = key id = s.id title = s.title year = y != null ? y : '' href = '/shows/' + s.urlSegment posterUrl = poster
  119. role = c.role typeLabel = s.type == 'movie' ? 'Movie' : 'Series' })
  120. }
  121. }
  122. let out = []
  123. let undated = []
  124. for (r of sortDesc(list)) { if (r.key == '') { undated.push(r) } else { out.push(r) } }
  125. for (r of undated) { out.push(r) }
  126. return out
  127. }
  128. // ---- the fetch ------------------------------------------------------------------------------------------------
  129. // person id → { items, pos, credits, added, requests, started, steps } while a fetch is under way (in memory: a restart
  130. // loses it — the titles stored so far are found again by TMDB id, nothing is duplicated)
  131. static jobs = {}
  132. // TMDB genre id → ours (our genres carry TMDB's id)
  133. static genreMap = { built = false ids = {} }
  134. static genreIdsOfTmdb = (tmdbIds) => {
  135. if (!genreMap.built) {
  136. let m = {}
  137. for (g of genresTable.find(null, null)) { if (g.tmdbId != null) { m['' + g.tmdbId] = g.id } }
  138. genreMap.ids = m
  139. genreMap.built = true
  140. }
  141. let out = []
  142. for (t of listOr(tmdbIds)) {
  143. let id = genreMap.ids['' + t]
  144. if (id != null && !out.includes(id)) { out.push(id) }
  145. }
  146. return out
  147. }
  148. // TMDB's combined_credits → one item per title (cast AND crew of the same title merged), TV + movies only
  149. static creditItems = (cc) => {
  150. let byKey = {}
  151. let order = []
  152. let all = []
  153. if (cc != null) {
  154. for (c of listOr(cc.cast)) { all.push({ credit = c role = textOr(c.character) }) }
  155. for (c of listOr(cc.crew)) { all.push({ credit = c role = textOr(c.job) }) }
  156. }
  157. for (a of all) {
  158. let c = a.credit
  159. let kind = c.media_type
  160. if ((kind == 'tv' || kind == 'movie') && c.id != null && hlTypeName(c.id) == 'Number') {
  161. let k = kind + ':' + c.id
  162. let known = byKey[k]
  163. if (known == null) {
  164. let name = kind == 'tv' ? textOr(c.name) : textOr(c.title)
  165. if (name != null) {
  166. byKey[k] = { kind = kind tmdbId = c.id title = name release = textOr(kind == 'tv' ? c.first_air_date : c.release_date)
  167. overview = textOr(c.overview) language = textOr(c.original_language) genreIds = listOr(c.genre_ids)
  168. posterPath = textOr(c.poster_path) adult = c.adult == true role = a.role != null ? a.role : '' }
  169. order.push(k)
  170. }
  171. } else if (a.role != null && !(' / ' + known.role + ' / ').includes(' / ' + a.role + ' / ')) {
  172. let x = known
  173. x.role = x.role == '' ? a.role : x.role + ' / ' + a.role
  174. byKey[k] = x
  175. }
  176. }
  177. }
  178. let out = []
  179. for (k of order) { out.push(byKey[k]) }
  180. return out
  181. }
  182. // the photo: w185 → storage/mpackdb/profiles/<oldId>.<ext> — { requests, image, tmdbProfile, failed }
  183. static syncProfile = (p, profilePath) => {
  184. let out = { requests = 0 image = p.image tmdbProfile = p.tmdbProfile failed = null }
  185. if (textOr(profilePath) == null || p.oldId == null) { return out }
  186. let parts = profilePath.split('.')
  187. let ext = parts[parts.length - 1].toLowerCase()
  188. if (ext != 'jpg' && ext != 'jpeg' && ext != 'png' && ext != 'webp') { return out }
  189. let file = profilesDir + '/' + p.oldId + '.' + ext
  190. if (exists(file) && p.tmdbProfile == profilePath && p.image == ext) { return out }
  191. out.requests = 1
  192. let r = fetch(tmdbImageBase + '/w185' + profilePath, { headers = { 'user-agent' = 'tracker.worldapi.org (person page)' } timeoutMs = 20000 })
  193. if (r == null || r.status != 200 || r.body == null || r.body.length == 0) {
  194. out.failed = 'photo ' + profilePath + ': ' + (r == null ? 'no answer' : 'HTTP ' + r.status)
  195. return out
  196. }
  197. mkDir(profilesDir)
  198. writeFile(file, toBytes(r.body), 420)
  199. out.image = ext
  200. out.tmdbProfile = profilePath
  201. return out
  202. }
  203. // step 1: TMDB's person + credits; the facts (a TMDB value that is empty never overwrites ours) and the photo
  204. static startFill = (p) => {
  205. let t0 = now()
  206. let d = tmdbGet('/person/' + p.tmdbId + '?append_to_response=combined_credits')
  207. if (d.failed != null) { return { error = 'TMDB: ' + d.failed } }
  208. let items = creditItems(d.combined_credits)
  209. let photo = syncProfile(p, d.profile_path)
  210. let x = p
  211. if (textOr(d.biography) != null) { x.biography = d.biography }
  212. if (textOr(d.birthday) != null) { x.birthDate = d.birthday }
  213. if (textOr(d.deathday) != null) { x.deathDate = d.deathday }
  214. if (textOr(d.place_of_birth) != null) { x.birthPlace = d.place_of_birth }
  215. if (textOr(x.imdbId) == null && textOr(d.imdb_id) != null) { x.imdbId = d.imdb_id }
  216. if (d.adult != null) { x.adult = d.adult == true }
  217. x.image = photo.image
  218. x.tmdbProfile = photo.tmdbProfile
  219. if (personsTable.update(x.id, x) == null) { return { error = 'could not store the person: ' + personsTable.lastError() } }
  220. if (photo.failed != null) { console.log('person fill: ' + p.name + ': ' + photo.failed) }
  221. jobs[p.id] = { items = items pos = 0 credits = [] added = 0 requests = 1 + photo.requests started = t0 steps = 1 tmdbMs = now() - t0 }
  222. return { done = false pos = 0 total = items.length }
  223. }
  224. // a title we don't have: the minimal record (shape: search.hl importTitle) → its id, or null
  225. static addMinimalTitle = (cr, pid, pname) => {
  226. let kindType = cr.kind == 'tv' ? 'series' : 'movie'
  227. let rel = cr.release
  228. let yr = rel != null && rel.length >= 4 ? toNumber(rel.slice(0, 4)) : null
  229. let slug = slugOf(cr.title, cr.tmdbId)
  230. let record = {
  231. id = randomBytes(8, 'hex') title = cr.title summary = cr.overview != null ? cr.overview : '' tmdbSummary = cr.overview tmdbId = cr.tmdbId
  232. language = cr.language type = kindType release = rel year = yr image = null
  233. urlSegment = slug episodesCount = null homepage = null imdbId = null seasonsCount = null
  234. status = null tagline = null tvdbId = null tvmzId = null genres = genreIdsOfTmdb(cr.genreIds)
  235. cast = [{ person = pid character = cr.role actor = pname }] seasons = [] oldId = 'tmdb-' + cr.kind + '-' + cr.tmdbId
  236. adult = cr.adult imported = now()
  237. }
  238. if (showsTable.put(record) == null) {
  239. console.log('person fill: "' + cr.title + '" not stored: ' + showsTable.lastError())
  240. return null
  241. }
  242. indexShow(record)
  243. refreshCatalogShow(record.id)
  244. return record.id
  245. }
  246. // every next step: the next `fillBatch` titles; the last one links the credits and marks the person complete
  247. static fillStep = (p, jobIn) => {
  248. let job = jobIn
  249. let total = job.items.length
  250. let end = job.pos + fillBatch
  251. if (end > total) { end = total }
  252. let credits = job.credits
  253. let added = job.added
  254. let pid = p.id
  255. let pname = p.name
  256. let i = job.pos
  257. while (i < end) {
  258. let item = job.items[i]
  259. let sid = titleIdByTmdb(item.kind == 'tv' ? 'series' : 'movie', item.tmdbId)
  260. if (sid == null) {
  261. sid = addMinimalTitle(item, pid, pname)
  262. if (sid != null) { added = added + 1 }
  263. }
  264. if (sid != null) { credits.push({ show = sid character = item.role posterPath = item.posterPath adult = item.adult }) }
  265. i = i + 1
  266. }
  267. job.pos = end
  268. job.credits = credits
  269. job.added = added
  270. job.steps = job.steps + 1
  271. jobs[pid] = job
  272. if (end < total) { return { done = false pos = end total = total } }
  273. // done: the credits (+ the migrated ones TMDB no longer lists), complete
  274. let have = {}
  275. let all = []
  276. for (c of credits) { have[c.show] = true all.push(c) }
  277. for (c of listOr(p.shows)) { if (c != null && c.show != null && have[c.show] != true) { all.push(c) } }
  278. let x = p
  279. x.shows = all
  280. x.filmographyAt = now()
  281. if (personsTable.update(x.id, x) == null) { return { error = 'could not store the filmography: ' + personsTable.lastError() } }
  282. personsTable.persist()
  283. showsTable.persist()
  284. jobs[pid] = null
  285. console.log('person fill: ' + pname + ' (tmdb ' + p.tmdbId + ') credits=' + total + ' added=' + added + ' requests=' + job.requests + ' steps=' + job.steps + ' tmdbMs=' + job.tmdbMs + ' ms=' + (now() - job.started))
  286. return { done = true pos = total total = total added = added }
  287. }
  288. // one step for the person with this id: { done, pos, total } or { error }
  289. static personFillStep = (personId) => {
  290. let p = personsTable.fetch(personId)
  291. if (p == null) { return { error = 'no such person' } }
  292. if (isComplete(p)) { return { done = true } }
  293. if (p.tmdbId == null) { return { error = 'TMDB does not know this person' } }
  294. let job = jobs[personId]
  295. if (job == null) { return startFill(p) }
  296. return fillStep(p, job)
  297. }

Branches

Latest commits

  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre