@torrent-tv/proxy 2.89.7 → 2.89.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (497) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +358 -338
  13. package/CHANGELOG.md +2340 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1373 -1373
  263. package/services/transport/delivery-probe.js +842 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe.test.js +393 -393
  314. package/test/demand-register.test.js +206 -206
  315. package/test/dht-bootstrap.test.js +72 -72
  316. package/test/drain-threat.test.js +65 -65
  317. package/test/duplicate-add.test.js +48 -48
  318. package/test/encode-admission.test.js +146 -146
  319. package/test/encode-cost.test.js +275 -275
  320. package/test/encode-exit.test.js +111 -111
  321. package/test/encode-orchestrator.test.js +426 -426
  322. package/test/encode-plan-viewers.test.js +725 -725
  323. package/test/encode-plan.test.js +545 -545
  324. package/test/encode-run-state.test.js +314 -314
  325. package/test/encode-run.test.js +20 -0
  326. package/test/encode-slope.test.js +46 -46
  327. package/test/encoded-output.test.js +39 -39
  328. package/test/encoder-kinds.test.js +122 -122
  329. package/test/encoder-readings.test.js +68 -68
  330. package/test/episode-naming.test.js +131 -131
  331. package/test/every-claimant-is-told-its-share.test.js +152 -152
  332. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  333. package/test/failed-start-breaker.test.js +202 -202
  334. package/test/fastest-wires.test.js +143 -143
  335. package/test/ffmpeg-stream-counts.test.js +78 -78
  336. package/test/file-edges.test.js +191 -191
  337. package/test/fixtures/audio-frame-boundary.json +1 -1
  338. package/test/fmp4-self-contained.test.js +80 -80
  339. package/test/fmp4-timestamps.test.js +140 -140
  340. package/test/fmp4-track-completeness.test.js +41 -41
  341. package/test/format-keyed-outputs.test.js +78 -78
  342. package/test/health-metrics.test.js +36 -38
  343. package/test/held-read-ownership.test.js +77 -77
  344. package/test/held-request-width.test.js +157 -157
  345. package/test/helpers/encode-run.js +248 -240
  346. package/test/helpers/held-file.js +77 -77
  347. package/test/helpers/manager.js +62 -62
  348. package/test/helpers/matroska-file.js +296 -296
  349. package/test/helpers/output-spec.js +63 -63
  350. package/test/host-load.test.js +68 -68
  351. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  352. package/test/init-compat.test.js +219 -219
  353. package/test/input-lost-quiets-the-plan.test.js +261 -261
  354. package/test/input-unavailable.test.js +48 -48
  355. package/test/installation-id.test.js +39 -39
  356. package/test/keeping-period.test.js +83 -83
  357. package/test/keyframe-index-accuracy.test.js +188 -188
  358. package/test/keyframe-table-budget.test.js +123 -123
  359. package/test/keyframes-belong-to-the-file.test.js +249 -249
  360. package/test/learned-median.test.js +37 -37
  361. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  362. package/test/limit-row-by-area.test.js +60 -60
  363. package/test/link-budget.test.js +111 -111
  364. package/test/link-probe.test.js +56 -56
  365. package/test/local-encoding-data.test.js +273 -273
  366. package/test/logger-repeats.test.js +134 -134
  367. package/test/look-ahead-coverage.test.js +56 -56
  368. package/test/manager-surface.test.js +291 -291
  369. package/test/matroska-cluster-discovery.test.js +323 -323
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/media-time.test.js +62 -62
  372. package/test/memory-budget.test.js +390 -390
  373. package/test/move-cost.test.js +216 -216
  374. package/test/mp4-subtitles.test.js +173 -173
  375. package/test/one-authority.test.js +285 -285
  376. package/test/one-budget.test.js +149 -149
  377. package/test/one-output-one-session.test.js +273 -273
  378. package/test/one-resource-divided.test.js +185 -185
  379. package/test/one-serving-output-per-height.test.js +458 -458
  380. package/test/orchestrator-wired.test.js +207 -207
  381. package/test/output-catalog.test.js +39 -39
  382. package/test/output-shape.test.js +21 -21
  383. package/test/output-spec.test.js +251 -251
  384. package/test/output-speed.test.js +92 -87
  385. package/test/outputs-being-prepared.test.js +40 -40
  386. package/test/packet-witness-ring.test.js +236 -236
  387. package/test/packet-witness.test.js +148 -148
  388. package/test/piece-disk-store.test.js +401 -401
  389. package/test/piece-from-whole-file.test.js +129 -129
  390. package/test/piece-lru.test.js +319 -319
  391. package/test/piece-reader.test.js +162 -162
  392. package/test/piece-selection-offset.test.js +85 -85
  393. package/test/piece-store-eviction.test.js +595 -595
  394. package/test/piece-store-never-refuses.test.js +153 -153
  395. package/test/piece-store-reservations.test.js +403 -403
  396. package/test/piece-store-slow-disk.test.js +159 -159
  397. package/test/piece-tail.test.js +97 -97
  398. package/test/plan-host-timings.test.js +68 -68
  399. package/test/plan-is-steady.test.js +225 -225
  400. package/test/playback-readiness.test.js +518 -518
  401. package/test/playback-start-regressions.test.js +101 -101
  402. package/test/priority-map-download.test.js +174 -174
  403. package/test/priority-map-emptied.test.js +204 -204
  404. package/test/priority-map-per-output.test.js +215 -215
  405. package/test/priority-map.test.js +163 -163
  406. package/test/probe-wedge-certainty.test.js +60 -60
  407. package/test/produced-copy-choice.test.js +213 -213
  408. package/test/published-timeline.test.js +83 -83
  409. package/test/quality-offer.test.js +160 -160
  410. package/test/quality-variants.test.js +1274 -1270
  411. package/test/rate-control-in-the-output.test.js +181 -180
  412. package/test/read-survives-withdrawal.test.js +164 -164
  413. package/test/read-window.test.js +365 -365
  414. package/test/reads-not-yet-arrived.test.js +218 -218
  415. package/test/resume-warm.test.js +39 -39
  416. package/test/run-graph-drift.test.js +37 -37
  417. package/test/run-inputs.test.js +80 -80
  418. package/test/run-intervals.test.js +100 -100
  419. package/test/run-position-follows-published-grid.test.js +89 -89
  420. package/test/run-progress.test.js +64 -64
  421. package/test/same-height-switch.test.js +663 -663
  422. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  423. package/test/seek-landing.test.js +109 -109
  424. package/test/seek-target-not-superseded.test.js +195 -195
  425. package/test/segment-closed-on-both-branches.test.js +114 -114
  426. package/test/segment-cut-times.test.js +63 -63
  427. package/test/segment-demand.test.js +80 -80
  428. package/test/segment-hold-supersede.test.js +91 -91
  429. package/test/segment-judged-by-its-own-header.test.js +222 -222
  430. package/test/segment-serve-wiring.test.js +481 -481
  431. package/test/segment-store-eviction.test.js +247 -247
  432. package/test/segment-store.test.js +352 -352
  433. package/test/segment-true-start.test.js +86 -86
  434. package/test/segments-are-shared.test.js +176 -176
  435. package/test/send-chunk-bytes.test.js +86 -86
  436. package/test/serving-existing-output.test.js +111 -111
  437. package/test/serving-output.test.js +102 -102
  438. package/test/shared-piece-store.test.js +310 -310
  439. package/test/sidecar-naming.test.js +142 -142
  440. package/test/soundtrack-rate-bound.test.js +289 -289
  441. package/test/source-file.test.js +133 -133
  442. package/test/source-files-route.test.js +113 -113
  443. package/test/source-registry.test.js +18 -18
  444. package/test/split-a-stretch.test.js +139 -139
  445. package/test/stale-request-after-seek.test.js +158 -158
  446. package/test/startup-readings.test.js +124 -124
  447. package/test/stream-route.test.js +237 -237
  448. package/test/subtitle-cue-framing.test.js +200 -200
  449. package/test/subtitle-cue-source.test.js +102 -102
  450. package/test/subtitle-cue-walk.test.js +385 -385
  451. package/test/subtitle-cursor.test.js +63 -63
  452. package/test/subtitle-defaults.test.js +118 -118
  453. package/test/subtitle-language.test.js +252 -252
  454. package/test/subtitle-metadata.test.js +15 -15
  455. package/test/subtitle-plan-waits.test.js +125 -125
  456. package/test/subtitle-track-numbering.test.js +391 -391
  457. package/test/supply-margin.test.js +190 -190
  458. package/test/swarm-follows-readers.test.js +174 -174
  459. package/test/swarm-reach.test.js +130 -130
  460. package/test/swarm-selection.test.js +220 -220
  461. package/test/tail-duplication.test.js +167 -167
  462. package/test/the-channel-has-both-ends.test.js +91 -91
  463. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  464. package/test/thread-pool.test.js +53 -53
  465. package/test/timeline.test.js +84 -84
  466. package/test/torrent-contents.test.js +235 -235
  467. package/test/torrent-cost.test.js +71 -71
  468. package/test/torrent-files.test.js +221 -221
  469. package/test/torrent-naming.test.js +255 -255
  470. package/test/tracks-begin-together.test.js +209 -209
  471. package/test/tunnel-renewal.test.js +167 -167
  472. package/test/two-viewers-one-picture.test.js +508 -508
  473. package/test/upload-hurry.test.js +186 -186
  474. package/test/usrsctp-state.test.js +131 -131
  475. package/test/video-facts.test.js +102 -102
  476. package/test/viewer-assignments.test.js +143 -143
  477. package/test/viewer-outputs.test.js +285 -285
  478. package/test/viewer-presence.test.js +116 -116
  479. package/test/viewer-requests-admission.test.js +92 -92
  480. package/test/viewer-starts-on-map.test.js +27 -27
  481. package/test/viewer-states.test.js +192 -189
  482. package/test/viewer-subtitle-subscription.test.js +96 -96
  483. package/test/viewer.test.js +139 -139
  484. package/test/viewing-generations.test.js +390 -390
  485. package/test/visible-rung.test.js +37 -37
  486. package/test/wait-ledger.test.js +100 -100
  487. package/test/webrtc-shim.test.js +149 -149
  488. package/test/wedge-certainty.test.js +131 -131
  489. package/test/whole-file-held-read.test.js +30 -30
  490. package/test/withdraw-piece-claim.test.js +212 -212
  491. package/test/worker-channel.test.js +158 -158
  492. package/utils/client-log-file.js +151 -151
  493. package/utils/coalesce.js +41 -41
  494. package/utils/logger.js +312 -312
  495. package/utils/parse-range.js +25 -25
  496. package/utils/perf.js +59 -59
  497. package/utils/torrent-source-key.js +38 -38
@@ -1,619 +1,619 @@
1
- /**
2
- * @file What a file NAME says about a track shipped beside the video.
3
- *
4
- * A name is written by the person who made the file, and reading it costs
5
- * nothing. Content detection is a guess — and a guess that is measurably wrong
6
- * on short text (`research/subtitle-language-ass-markup-2026-09-01.md` §6) — so
7
- * every name we can read is one track that never has to be guessed at.
8
- *
9
- * The grammar here is the UNION of what five players already implement, and
10
- * every rule in it is in at least two of them. Nothing is invented. Sources,
11
- * all read 2026-09-01 and quoted in
12
- * `research/sidecar-naming-conventions-2026-09-01.md`:
13
- *
14
- * - **Jellyfin** (`Emby.Naming/ExternalFiles/ExternalPathParser.cs`,
15
- * `Common/NamingOptions.cs`): the `.` delimiter, the three flag lists, tokens
16
- * read from the END backwards, the last language winning, the `hi`/Hindi
17
- * collision resolved by whether another language is present, and leftover
18
- * text becoming the stream title.
19
- * - **Plex** ("Adding Local Subtitles to Your Media"): the language code is
20
- * "ISO-639-1 (2-letter) or ISO-639-2/B (3-letter)", flags after it.
21
- * - **Kodi** (wiki, "Subtitles"): `<movie name>.<language>.<ext>`, where the
22
- * language "can be an ISO 639-1 or ISO 639-2 code, a BCP 47 tag (with `-`
23
- * characters replaced with `_`) or the English name of a language"; the
24
- * forced flag may be separated by a dot, a space or a dash.
25
- * - **mpv** (`misc/language.c`): the delimiter is taken from the LAST
26
- * character — `(` when the name ends `)`, `[` when it ends `]` — which covers
27
- * `Movie (Russian)` and `Movie [rus]` with one rule instead of a list of
28
- * bracket shapes; and BCP-47 shape, primary subtag of 2 or 3 letters.
29
- * - **VLC** (`src/libvlc-module.c`): the folders that mean "subtitles live
30
- * here" and NOT a language — its default `sub-autodetect-path` is
31
- * `./Subtitles, ./subtitles, ./Subs, ./subs`.
32
- *
33
- * Used for BOTH subtitles and soundtracks, because both are the same question
34
- * about the same kind of path.
35
- */
36
-
37
- /**
38
- * One language, with every spelling a release might use for it.
39
- *
40
- * `aliases` carries: the ISO 639-1 code, BOTH ISO 639-2 sets where they differ
41
- * (Plex names the bibliographic set, ffmpeg and Matroska write either), the
42
- * English name, and — for the languages these trackers actually carry — the
43
- * name in Russian, because a Russian release names its folders in Russian.
44
- *
45
- * @type {Array<{ code: string, name: string, aliases: string[] }>}
46
- */
47
- const LANGUAGES = [
48
- { code: "en", name: "English", aliases: ["en", "eng", "english", "английский", "англ", "англи", "английские", "инглиш"] },
49
- { code: "ru", name: "Russian", aliases: ["ru", "rus", "russian", "русский", "рус", "русские", "русск"] },
50
- { code: "uk", name: "Ukrainian", aliases: ["uk", "ukr", "ukrainian", "українська", "укр", "украинский"] },
51
- { code: "be", name: "Belarusian", aliases: ["be", "bel", "belarusian", "беларуская", "белорусский"] },
52
- { code: "ja", name: "Japanese", aliases: ["ja", "jpn", "japanese", "японский", "яп", "японские"] },
53
- { code: "ko", name: "Korean", aliases: ["ko", "kor", "korean", "корейский"] },
54
- { code: "zh", name: "Chinese", aliases: ["zh", "chi", "zho", "chinese", "китайский"] },
55
- { code: "zh-Hans", name: "Chinese (Simplified)", aliases: ["chs", "hans", "simplified"] },
56
- { code: "zh-Hant", name: "Chinese (Traditional)", aliases: ["cht", "hant", "traditional"] },
57
- { code: "es", name: "Spanish", aliases: ["es", "spa", "spanish", "espanol", "español", "испанский"] },
58
- { code: "fr", name: "French", aliases: ["fr", "fre", "fra", "french", "francais", "français", "французский"] },
59
- { code: "de", name: "German", aliases: ["de", "ger", "deu", "german", "deutsch", "немецкий"] },
60
- { code: "it", name: "Italian", aliases: ["it", "ita", "italian", "italiano", "итальянский"] },
61
- { code: "pt", name: "Portuguese", aliases: ["pt", "por", "portuguese", "portugues", "português", "португальский"] },
62
- { code: "pl", name: "Polish", aliases: ["pl", "pol", "polish", "polski", "польский"] },
63
- { code: "nl", name: "Dutch", aliases: ["nl", "dut", "nld", "dutch", "nederlands", "голландский", "нидерландский"] },
64
- { code: "ar", name: "Arabic", aliases: ["ar", "ara", "arabic", "арабский"] },
65
- { code: "tr", name: "Turkish", aliases: ["tr", "tur", "turkish", "türkçe", "турецкий"] },
66
- { code: "vi", name: "Vietnamese", aliases: ["vi", "vie", "vietnamese", "вьетнамский"] },
67
- { code: "th", name: "Thai", aliases: ["th", "tha", "thai", "тайский"] },
68
- { code: "hi", name: "Hindi", aliases: ["hi", "hin", "hindi", "хинди"] },
69
- { code: "id", name: "Indonesian", aliases: ["id", "ind", "indonesian", "индонезийский"] },
70
- { code: "ms", name: "Malay", aliases: ["ms", "may", "msa", "malay", "малайский"] },
71
- { code: "cs", name: "Czech", aliases: ["cs", "cze", "ces", "czech", "чешский"] },
72
- { code: "sk", name: "Slovak", aliases: ["sk", "slo", "slk", "slovak", "словацкий"] },
73
- { code: "ro", name: "Romanian", aliases: ["ro", "rum", "ron", "romanian", "румынский"] },
74
- { code: "hu", name: "Hungarian", aliases: ["hu", "hun", "hungarian", "венгерский"] },
75
- { code: "sr", name: "Serbian", aliases: ["sr", "srp", "serbian", "српски", "сербский"] },
76
- { code: "hr", name: "Croatian", aliases: ["hr", "hrv", "croatian", "hrvatski", "хорватский"] },
77
- { code: "bs", name: "Bosnian", aliases: ["bs", "bos", "bosnian", "боснийский"] },
78
- { code: "sl", name: "Slovenian", aliases: ["sl", "slv", "slovenian", "словенский"] },
79
- { code: "bg", name: "Bulgarian", aliases: ["bg", "bul", "bulgarian", "български", "болгарский"] },
80
- { code: "mk", name: "Macedonian", aliases: ["mk", "mac", "mkd", "macedonian", "македонский"] },
81
- { code: "el", name: "Greek", aliases: ["el", "gre", "ell", "greek", "греческий"] },
82
- { code: "he", name: "Hebrew", aliases: ["he", "heb", "hebrew", "иврит"] },
83
- { code: "da", name: "Danish", aliases: ["da", "dan", "danish", "датский"] },
84
- { code: "fi", name: "Finnish", aliases: ["fi", "fin", "finnish", "suomi", "финский"] },
85
- { code: "no", name: "Norwegian", aliases: ["no", "nor", "nob", "nno", "norwegian", "норвежский"] },
86
- { code: "sv", name: "Swedish", aliases: ["sv", "swe", "swedish", "svenska", "шведский"] },
87
- { code: "et", name: "Estonian", aliases: ["et", "est", "estonian", "эстонский"] },
88
- { code: "lv", name: "Latvian", aliases: ["lv", "lav", "latvian", "латышский"] },
89
- { code: "lt", name: "Lithuanian", aliases: ["lt", "lit", "lithuanian", "литовский"] },
90
- { code: "fa", name: "Persian", aliases: ["fa", "per", "fas", "persian", "farsi", "персидский"] },
91
- { code: "ka", name: "Georgian", aliases: ["ka", "geo", "kat", "georgian", "грузинский"] },
92
- { code: "hy", name: "Armenian", aliases: ["hy", "arm", "hye", "armenian", "армянский"] },
93
- { code: "az", name: "Azerbaijani", aliases: ["az", "aze", "azerbaijani", "азербайджанский"] },
94
- { code: "kk", name: "Kazakh", aliases: ["kk", "kaz", "kazakh", "казахский"] },
95
- { code: "is", name: "Icelandic", aliases: ["is", "ice", "isl", "icelandic", "исландский"] },
96
- { code: "sq", name: "Albanian", aliases: ["sq", "alb", "sqi", "albanian", "албанский"] },
97
- { code: "eu", name: "Basque", aliases: ["eu", "baq", "eus", "basque"] },
98
- { code: "cy", name: "Welsh", aliases: ["cy", "wel", "cym", "welsh"] },
99
- { code: "ca", name: "Catalan", aliases: ["ca", "cat", "catalan", "català"] },
100
- { code: "gl", name: "Galician", aliases: ["gl", "glg", "galician"] },
101
- { code: "my", name: "Burmese", aliases: ["my", "bur", "mya", "burmese"] },
102
- { code: "bo", name: "Tibetan", aliases: ["bo", "tib", "bod", "tibetan"] },
103
- { code: "mi", name: "Maori", aliases: ["mi", "mao", "mri", "maori"] }
104
- ];
105
-
106
- /** alias → { code, name }. Built once. */
107
- const BY_ALIAS = new Map();
108
- for (const language of LANGUAGES) {
109
- for (const alias of language.aliases) {
110
- BY_ALIAS.set(alias.toLowerCase(), { code: language.code, name: language.name });
111
- }
112
- }
113
-
114
- /**
115
- * Words that appear where a language would and are NOT one.
116
- *
117
- * Two kinds, and both are needed. A folder can say only that a KIND of track
118
- * lives in it — VLC's own default search path is `./Subtitles, ./subtitles,
119
- * ./Subs, ./subs`, and a Russian release writes the same thing in Russian. And
120
- * a name can carry a technical word that happens to sit where a language token
121
- * does. Today `Subs/` is safe only because no such word happens to be in the
122
- * language table; that is an accident, and this makes it a statement.
123
- */
124
- const NEUTRAL_WORDS = new Set([
125
- "sub", "subs", "subtitle", "subtitles", "subtitulos", "sous", "titres",
126
- "sound", "audio", "dub", "dubs", "dubbed", "track", "tracks", "voice",
127
- "субтитры", "субтитр", "суб", "сабы", "саб", "титры",
128
- "звук", "звуковая", "звуковые", "дорожка", "дорожки", "аудио",
129
- "озвучка", "озвучание", "перевод", "переводы", "дубляж"
130
- ]);
131
-
132
- /** Flags, per Jellyfin's `NamingOptions` and mpv's `language.c`. */
133
- const FORCED_FLAGS = new Set(["forced", "foreign"]);
134
- const HEARING_IMPAIRED_FLAGS = new Set(["sdh", "cc", "hi"]);
135
- const DEFAULT_FLAGS = new Set(["default"]);
136
-
137
- /**
138
- * Look up one bare token as a language.
139
- *
140
- * @param {string} token
141
- * @returns {{ code: string, name: string } | null}
142
- */
143
- export function lookupLanguage(token) {
144
- if (typeof token !== "string") {
145
- return null;
146
- }
147
- return BY_ALIAS.get(token.trim().toLowerCase()) ?? null;
148
- }
149
-
150
- /**
151
- * Is this a word that names a KIND of track rather than a language?
152
- *
153
- * @param {string} token
154
- * @returns {boolean}
155
- */
156
- export function isNeutralWord(token) {
157
- return typeof token === "string" && NEUTRAL_WORDS.has(token.trim().toLowerCase());
158
- }
159
-
160
- /**
161
- * Read one token as a language tag, keeping a region or script subtag.
162
- *
163
- * Kodi writes BCP-47 with `_` where the standard has `-` (its own example is
164
- * `The Matrix.zh_yue.srt`), so both separators are accepted. The whole tag is
165
- * KEPT — `pt-BR` against `pt-PT` is a distinction a viewer can see — while the
166
- * lookup is done on the primary subtag alone. Per BCP-47 and mpv's reading of
167
- * it: the primary subtag is 2 or 3 letters, later subtags are 1 to 8
168
- * alphanumerics.
169
- *
170
- * @param {string} token
171
- * @returns {{ code: string, name: string } | null}
172
- */
173
- export function parseLanguageTag(token) {
174
- if (typeof token !== "string") {
175
- return null;
176
- }
177
- const trimmed = token.trim();
178
- if (trimmed.length === 0 || isNeutralWord(trimmed)) {
179
- return null;
180
- }
181
- const whole = lookupLanguage(trimmed);
182
- if (whole) {
183
- return whole;
184
- }
185
- const parts = trimmed.split(/[-_]/);
186
- if (parts.length < 2) {
187
- return null;
188
- }
189
- const primary = lookupLanguage(parts[0]);
190
- if (!primary || !/^[A-Za-z]{2,3}$/.test(parts[0])) {
191
- return null;
192
- }
193
- for (const subtag of parts.slice(1)) {
194
- if (!/^[A-Za-z0-9]{1,8}$/.test(subtag)) {
195
- return null;
196
- }
197
- }
198
- const base = primary.code.split("-")[0];
199
- return { code: [base, ...parts.slice(1)].join("-"), name: primary.name };
200
- }
201
-
202
- /**
203
- * Split a base name into the tokens a language or a flag could be.
204
- *
205
- * The delimiter follows mpv: `.` normally, but `(` when the name ends with `)`
206
- * and `[` when it ends with `]`, so `Movie (Russian)` and `Movie [rus]` need no
207
- * rule of their own. `_` is accepted alongside `.` because releases on these
208
- * trackers use it interchangeably (`Avatar_The_Last_Airbender_S02E01_1080p_rus`),
209
- * and neither character occurs inside a language code or a flag word.
210
- *
211
- * A trailing flag separated by a space or a dash is peeled off the last token,
212
- * which is Kodi's rule for the forced flag: it "can use a '.' (dot), ' '
213
- * (space) or '-' (dash) to separate them from the name of the movie and
214
- * language of the subtitles".
215
- *
216
- * @param {string} baseName - File name with its extension already removed.
217
- * @returns {string[]} Tokens in name order.
218
- */
219
- export function tokenizeName(baseName) {
220
- const text = String(baseName ?? "").trim();
221
- if (text.length === 0) {
222
- return [];
223
- }
224
- // A bracketed tail is a token of its own; what comes BEFORE it is still split
225
- // the ordinary way, so `Film.rus [1080p]` yields `rus` rather than `.rus`.
226
- const bracketed = /^(.*)[([]([^()[\]]*)[)\]]$/.exec(text);
227
- const head = bracketed ? bracketed[1] : text;
228
- const tail = bracketed ? splitTrailingFlags(bracketed[2]) : [];
229
- const parts = head.split(/[._]/);
230
- const last = parts.pop() ?? "";
231
- return [...parts, ...splitTrailingFlags(last), ...tail];
232
- }
233
-
234
- /**
235
- * Peel flag words off the end of one token when a space or a dash separates
236
- * them — `"English Forced"` is two tokens, `"English"` is one.
237
- *
238
- * @param {string} token
239
- * @returns {string[]}
240
- */
241
- function splitTrailingFlags(token) {
242
- let text = String(token ?? "").trim();
243
- const flags = [];
244
- for (;;) {
245
- // Only a trailing FLAG is peeled. The token is otherwise left exactly as it
246
- // is, because a dash is also what BCP-47 puts between subtags: splitting
247
- // `pt-BR` here would destroy the region before anything could read it.
248
- const match = /^(.*\S)[ -]+([A-Za-z]+)$/.exec(text);
249
- if (!match) {
250
- break;
251
- }
252
- const candidate = match[2].toLowerCase();
253
- if (!FORCED_FLAGS.has(candidate) && !HEARING_IMPAIRED_FLAGS.has(candidate) && !DEFAULT_FLAGS.has(candidate)) {
254
- break;
255
- }
256
- flags.unshift(match[2]);
257
- text = match[1];
258
- }
259
- return [text, ...flags].filter((word) => word.length > 0);
260
- }
261
-
262
- /**
263
- * @typedef {object} SidecarNameReading
264
- * @property {{ code: string, name: string } | null} language
265
- * @property {boolean} isForced
266
- * @property {boolean} isHearingImpaired
267
- * @property {boolean} isDefault
268
- * @property {string | null} title - Text that is neither a language nor a flag.
269
- */
270
-
271
- /**
272
- * Read the language and the flags a sidecar's file name states.
273
- *
274
- * Two readings, and which one applies depends on whether the picture's name is
275
- * known — this is Jellyfin's own safeguard, and without it a name is dangerous
276
- * to read. Jellyfin parses only the part of the name that FOLLOWS the video's
277
- * name, because everything before it belongs to the film: `Movie.It.Follows.
278
- * 2014.srt` would otherwise be read as Italian.
279
- *
280
- * 1. **The name begins with the video's name.** Everything after it is the
281
- * releaser speaking about this track, so every token in it is read, the last
282
- * language token wins, and what is neither language nor flag is the title.
283
- * 2. **It does not** (or the video is unknown). Then tokens are read from the
284
- * END backwards and reading STOPS at the first token that is neither a flag
285
- * nor a language — mpv's rule, and the only safe one when the boundary
286
- * between the film's name and the track's description is not known.
287
- *
288
- * @param {string} baseName - The sidecar's file name, extension removed.
289
- * @param {string} [videoBaseName] - The picture's file name, extension removed.
290
- * @returns {SidecarNameReading}
291
- */
292
- /**
293
- * Whether a sidecar's base name continues the video's base name — and continues
294
- * it at a token boundary.
295
- *
296
- * The boundary is the whole of the rule's safety. Without it, "begins with the
297
- * video's name" pairs `Film.20.rus.srt` with `Film.2.mkv`: measured, and the
298
- * grammar then reports the leftover `0` as the track's title, which is the tell
299
- * that the remainder is a fragment of another film's name rather than anything
300
- * a releaser wrote about this track.
301
- *
302
- * The delimiters are the ones every surveyed player splits on — `.`, `_`, `-`,
303
- * space — plus mpv's bracket rule, so `Movie (Russian).srt` and `Movie [rus].srt`
304
- * are read as continuations too.
305
- *
306
- * @param {string} base - The sidecar's base name.
307
- * @param {string} video - The picture's base name.
308
- * @returns {boolean}
309
- */
310
- export function nameFollows(base, video) {
311
- if (video.length === 0 || base.length < video.length) {
312
- return false;
313
- }
314
- if (!base.toLowerCase().startsWith(video.toLowerCase())) {
315
- return false;
316
- }
317
- if (base.length === video.length) {
318
- return true;
319
- }
320
- return /^[._\-\s([]/.test(base.slice(video.length));
321
- }
322
-
323
- export function readSidecarName(baseName, videoBaseName = "") {
324
- const reading = {
325
- language: null,
326
- isForced: false,
327
- isHearingImpaired: false,
328
- isDefault: false,
329
- title: null
330
- };
331
- const base = String(baseName ?? "").trim();
332
- if (base.length === 0) {
333
- return reading;
334
- }
335
-
336
- const video = String(videoBaseName ?? "").trim();
337
- const followsVideo = nameFollows(base, video);
338
- const text = followsVideo ? base.slice(video.length) : base;
339
- const tokens = tokenizeName(text);
340
- if (tokens.length === 0) {
341
- return reading;
342
- }
343
-
344
- const leftovers = [];
345
- let sawHiToken = false;
346
- // Backwards: the LAST language in the name is the one that counts, which is
347
- // Jellyfin's documented rule and what its parser does by walking this way.
348
- for (let index = tokens.length - 1; index >= 0; index -= 1) {
349
- const token = tokens[index];
350
- const word = token.trim().toLowerCase();
351
- if (word.length === 0) {
352
- continue;
353
- }
354
- if (FORCED_FLAGS.has(word)) {
355
- reading.isForced = true;
356
- continue;
357
- }
358
- if (DEFAULT_FLAGS.has(word)) {
359
- reading.isDefault = true;
360
- continue;
361
- }
362
- // `hi` is both Hindi and "hearing impaired". Which it is depends on whether
363
- // another language is named — Jellyfin: "`hi` by itself will resolve as a
364
- // Hindi language track, while `hi` in conjunction with another language
365
- // identifier … will use the other language and tag it as hearing impaired."
366
- // It cannot be settled here, because the other language may still be to the
367
- // left, so it is remembered and settled below.
368
- if (word === "hi") {
369
- sawHiToken = true;
370
- continue;
371
- }
372
- if (HEARING_IMPAIRED_FLAGS.has(word)) {
373
- reading.isHearingImpaired = true;
374
- continue;
375
- }
376
- const language = parseLanguageTag(token);
377
- if (language) {
378
- if (reading.language === null) {
379
- reading.language = language;
380
- }
381
- continue;
382
- }
383
- if (!followsVideo) {
384
- break;
385
- }
386
- leftovers.unshift(token.trim());
387
- }
388
-
389
- if (sawHiToken) {
390
- if (reading.language === null) {
391
- reading.language = lookupLanguage("hi");
392
- } else {
393
- reading.isHearingImpaired = true;
394
- }
395
- }
396
-
397
- const title = leftovers.filter((part) => part.length > 0).join(" ").trim();
398
- reading.title = title.length > 0 ? title : null;
399
- return reading;
400
- }
401
-
402
- /**
403
- * The language a path's FOLDERS state, looking from the innermost outwards.
404
- *
405
- * A whole segment is tried first (`ENG`, `Russian`, `рус`), then its words —
406
- * which is what reads `Rus Sound` as Russian. A word is only accepted from a
407
- * multi-word segment when it is at least three letters, because two-letter
408
- * codes are also ordinary words (`no`, `id`, `it`) and a folder called
409
- * `No Subs` does not mean Norwegian.
410
- *
411
- * A segment made only of words that name a KIND of track — `Subs`, `Субтитры` —
412
- * states no language and is skipped rather than searched, so a future table
413
- * entry cannot turn one into a language by accident.
414
- *
415
- * @param {string[]} folders - Folders above the file, outermost first.
416
- * @returns {{ code: string, name: string } | null}
417
- */
418
- export function languageFromFolderNames(folders) {
419
- const segments = Array.isArray(folders) ? folders : [];
420
- for (let index = segments.length - 1; index >= 0; index -= 1) {
421
- const segment = String(segments[index] ?? "").trim();
422
- if (segment.length === 0 || isNeutralWord(segment)) {
423
- continue;
424
- }
425
- const whole = parseLanguageTag(segment);
426
- if (whole) {
427
- return whole;
428
- }
429
- const words = segment.split(/[^\p{L}]+/u).filter((word) => word.length >= 3);
430
- for (const word of words) {
431
- const found = lookupLanguage(word);
432
- if (found) {
433
- return found;
434
- }
435
- }
436
- }
437
- return null;
438
- }
439
-
440
- // ---------------------------------------------------------------------------
441
- // Reading a whole sidecar path: its name AND the folders above it.
442
- //
443
- // This half used to live in the browser (`subtitle-utils.js`), which is also
444
- // where the pairing was decided — two answers about the same file, computed on
445
- // two sides, compared nowhere. Measured 2026-09-04 over the 115 torrents in
446
- // `Dropbox/trn`: 1249 video files, 1239 pairings agreed and 10 did not, every
447
- // one of the ten a `<video base>.<language>.<ext>` name that the browser paired
448
- // and the proxy did not. The proxy warms what it paired and the browser offers
449
- // what it paired, so a disagreement reached the viewer as a track nobody had
450
- // warmed — a wait of 27.7 s in the field measurement of 2026-08-31.
451
- // ---------------------------------------------------------------------------
452
-
453
- /**
454
- * Remove the file extension from a name.
455
- *
456
- * @param {string} name
457
- * @returns {string}
458
- */
459
- export function stripExtension(name) {
460
- const text = typeof name === "string" ? name : "";
461
- const dot = text.lastIndexOf(".");
462
- return dot > 0 ? text.slice(0, dot) : text;
463
- }
464
-
465
- /**
466
- * The bracketed groups of a name, in order.
467
- *
468
- * @param {string} text
469
- * @returns {string[]}
470
- */
471
- function bracketTokens(text) {
472
- const source = typeof text === "string" ? text : "";
473
- const tokens = [];
474
- for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
475
- const token = match[1].trim();
476
- if (token.length > 0) {
477
- tokens.push(token);
478
- }
479
- }
480
- return tokens;
481
- }
482
-
483
- /**
484
- * Bracketed groups that describe the ENCODE rather than a team — resolution,
485
- * codec, source, audio format, bit depth. A releaser's name is what is left
486
- * after these are set aside.
487
- *
488
- * @param {string} token
489
- * @returns {boolean}
490
- */
491
- function isTechnicalToken(token) {
492
- const text = String(token ?? "").trim().toLowerCase();
493
- if (text.length === 0) {
494
- return true;
495
- }
496
- // A release hash: `[78EFD746]`. It identifies the file, not its author.
497
- if (/^[0-9a-f]{4,10}$/.test(text) && /\d/.test(text)) {
498
- return true;
499
- }
500
- if (/^\d{3,4}[pi]$/.test(text) || /^\d{3,4}x\d{3,4}$/.test(text)) {
501
- return true;
502
- }
503
- if (/^(x|h)\.?26[45]$/.test(text) || /^(hevc|avc|av1|vp9|xvid|divx)$/.test(text)) {
504
- return true;
505
- }
506
- if (/^(aac|ac3|eac3|dts(-?hd)?|flac|mp3|opus|truehd|atmos|pcm|\d\.\d)$/.test(text)) {
507
- return true;
508
- }
509
- if (/^(web-?rip|web-?dl|bd-?rip|blu-?ray|hdtv|dvd-?rip|remux|hdr\d*|dv|sdr|\d{1,2}bit)$/.test(text)) {
510
- return true;
511
- }
512
- // A bracket that names a language names a language, not a team; and one that
513
- // names the KIND of track (`[Subs]`) names neither.
514
- return lookupLanguage(text) !== null || isNeutralWord(text);
515
- }
516
-
517
- /**
518
- * What is left of a name after the language and the flags, as a TITLE — or
519
- * nothing, when what is left only describes the encode.
520
- *
521
- * Jellyfin keeps every leftover word ("Any arbitrary text not parsable to a
522
- * language or flag will be combined and used as the title of the stream"), and
523
- * on its own library that is right, because the part it reads is what the owner
524
- * deliberately appended. A torrent is not that tidy: `Film.1080p.rus.srt` beside
525
- * `Film.mkv` leaves `1080p`, and a track labelled "Russian (1080p)" is worse
526
- * than one labelled "Russian".
527
- *
528
- * @param {string | null} title
529
- * @returns {string | null}
530
- */
531
- function titleOf(title) {
532
- if (typeof title !== "string") {
533
- return null;
534
- }
535
- const words = title.split(/\s+/).filter((word) => word.length > 0);
536
- if (words.length === 0) {
537
- return null;
538
- }
539
- // ALL of it, or none. A word is only weighed on its own when it stands on its
540
- // own: inside a phrase, a word that happens to name a language is part of the
541
- // phrase — `English Commentary` is a title, and dropping `English` from it
542
- // would leave a label nobody wrote.
543
- return words.every((word) => isTechnicalToken(word)) ? null : words.join(" ");
544
- }
545
-
546
- /**
547
- * Who made a track that ships as its own file, when the torrent says so.
548
- *
549
- * The rule that keeps this honest: a bracketed group is only a releaser of the
550
- * SIDECAR when it is not also in the video's own name. In the field case that
551
- * settles it — a Russian dub named exactly like the picture,
552
- * `[HorribleSubs] Drifters - 02 [1080p].mka`, sitting in `Rus Sound/` — every
553
- * bracket it carries is the picture's, and attributing the dub to HorribleSubs
554
- * would be inventing an author. The subtitles of the same release, in
555
- * `Sub/[Stan WarHammer & Nesitach]/`, carry a bracket the video does not, and
556
- * that one IS their author.
557
- *
558
- * The innermost folder is looked at before the file name, because that is where
559
- * a release puts the team when several of them contributed.
560
- *
561
- * @param {object} params
562
- * @param {string[]} params.folders - Folders above the file, innermost last.
563
- * @param {string} params.fileName
564
- * @param {string} [params.videoName] - The picture's own file name.
565
- * @returns {string | null}
566
- */
567
- export function releaserFrom({ folders, fileName, videoName = "" }) {
568
- const shared = new Set(
569
- bracketTokens(stripExtension(String(videoName ?? ""))).map((token) => token.toLowerCase())
570
- );
571
- const sources = [...(Array.isArray(folders) ? folders : [])].reverse();
572
- sources.push(stripExtension(String(fileName ?? "")));
573
- for (const source of sources) {
574
- for (const token of bracketTokens(source)) {
575
- if (shared.has(token.toLowerCase()) || isTechnicalToken(token)) {
576
- continue;
577
- }
578
- return token;
579
- }
580
- }
581
- return null;
582
- }
583
-
584
- /**
585
- * Language, flags and releaser for a track that ships as its own file.
586
- *
587
- * Reading order, and the reason for it: the FILE NAME first, then the folders.
588
- * All five players surveyed read the name
589
- * (`research/sidecar-naming-conventions-2026-09-01.md`); only VLC and Kodi treat
590
- * a folder as a place rather than as a statement about language. A name is also
591
- * per-file where a folder is per-group, so where both speak the name is the more
592
- * specific of the two.
593
- *
594
- * A soundtrack is named by the same conventions a subtitle is — Jellyfin's own
595
- * example set carries `Film.en.ac3` and `Film.german.ac3` beside `Film.de.srt` —
596
- * so it is the same grammar over the same path.
597
- *
598
- * @param {object} params
599
- * @param {string[]} params.folders
600
- * @param {string} params.fileName
601
- * @param {string} [params.videoName]
602
- * @returns {{ code: string | null, name: string | null, releaser: string | null,
603
- * isForced: boolean, isHearingImpaired: boolean, isDefault: boolean }}
604
- */
605
- export function sidecarNaming({ folders, fileName, videoName = "" }) {
606
- const fromName = readSidecarName(
607
- stripExtension(String(fileName ?? "")),
608
- stripExtension(String(videoName ?? ""))
609
- );
610
- const language = fromName.language ?? languageFromFolderNames(folders);
611
- return {
612
- code: language?.code ?? null,
613
- name: language?.name ?? null,
614
- releaser: titleOf(fromName.title) ?? releaserFrom({ folders, fileName, videoName }),
615
- isForced: fromName.isForced,
616
- isHearingImpaired: fromName.isHearingImpaired,
617
- isDefault: fromName.isDefault
618
- };
619
- }
1
+ /**
2
+ * @file What a file NAME says about a track shipped beside the video.
3
+ *
4
+ * A name is written by the person who made the file, and reading it costs
5
+ * nothing. Content detection is a guess — and a guess that is measurably wrong
6
+ * on short text (`research/subtitle-language-ass-markup-2026-09-01.md` §6) — so
7
+ * every name we can read is one track that never has to be guessed at.
8
+ *
9
+ * The grammar here is the UNION of what five players already implement, and
10
+ * every rule in it is in at least two of them. Nothing is invented. Sources,
11
+ * all read 2026-09-01 and quoted in
12
+ * `research/sidecar-naming-conventions-2026-09-01.md`:
13
+ *
14
+ * - **Jellyfin** (`Emby.Naming/ExternalFiles/ExternalPathParser.cs`,
15
+ * `Common/NamingOptions.cs`): the `.` delimiter, the three flag lists, tokens
16
+ * read from the END backwards, the last language winning, the `hi`/Hindi
17
+ * collision resolved by whether another language is present, and leftover
18
+ * text becoming the stream title.
19
+ * - **Plex** ("Adding Local Subtitles to Your Media"): the language code is
20
+ * "ISO-639-1 (2-letter) or ISO-639-2/B (3-letter)", flags after it.
21
+ * - **Kodi** (wiki, "Subtitles"): `<movie name>.<language>.<ext>`, where the
22
+ * language "can be an ISO 639-1 or ISO 639-2 code, a BCP 47 tag (with `-`
23
+ * characters replaced with `_`) or the English name of a language"; the
24
+ * forced flag may be separated by a dot, a space or a dash.
25
+ * - **mpv** (`misc/language.c`): the delimiter is taken from the LAST
26
+ * character — `(` when the name ends `)`, `[` when it ends `]` — which covers
27
+ * `Movie (Russian)` and `Movie [rus]` with one rule instead of a list of
28
+ * bracket shapes; and BCP-47 shape, primary subtag of 2 or 3 letters.
29
+ * - **VLC** (`src/libvlc-module.c`): the folders that mean "subtitles live
30
+ * here" and NOT a language — its default `sub-autodetect-path` is
31
+ * `./Subtitles, ./subtitles, ./Subs, ./subs`.
32
+ *
33
+ * Used for BOTH subtitles and soundtracks, because both are the same question
34
+ * about the same kind of path.
35
+ */
36
+
37
+ /**
38
+ * One language, with every spelling a release might use for it.
39
+ *
40
+ * `aliases` carries: the ISO 639-1 code, BOTH ISO 639-2 sets where they differ
41
+ * (Plex names the bibliographic set, ffmpeg and Matroska write either), the
42
+ * English name, and — for the languages these trackers actually carry — the
43
+ * name in Russian, because a Russian release names its folders in Russian.
44
+ *
45
+ * @type {Array<{ code: string, name: string, aliases: string[] }>}
46
+ */
47
+ const LANGUAGES = [
48
+ { code: "en", name: "English", aliases: ["en", "eng", "english", "английский", "англ", "англи", "английские", "инглиш"] },
49
+ { code: "ru", name: "Russian", aliases: ["ru", "rus", "russian", "русский", "рус", "русские", "русск"] },
50
+ { code: "uk", name: "Ukrainian", aliases: ["uk", "ukr", "ukrainian", "українська", "укр", "украинский"] },
51
+ { code: "be", name: "Belarusian", aliases: ["be", "bel", "belarusian", "беларуская", "белорусский"] },
52
+ { code: "ja", name: "Japanese", aliases: ["ja", "jpn", "japanese", "японский", "яп", "японские"] },
53
+ { code: "ko", name: "Korean", aliases: ["ko", "kor", "korean", "корейский"] },
54
+ { code: "zh", name: "Chinese", aliases: ["zh", "chi", "zho", "chinese", "китайский"] },
55
+ { code: "zh-Hans", name: "Chinese (Simplified)", aliases: ["chs", "hans", "simplified"] },
56
+ { code: "zh-Hant", name: "Chinese (Traditional)", aliases: ["cht", "hant", "traditional"] },
57
+ { code: "es", name: "Spanish", aliases: ["es", "spa", "spanish", "espanol", "español", "испанский"] },
58
+ { code: "fr", name: "French", aliases: ["fr", "fre", "fra", "french", "francais", "français", "французский"] },
59
+ { code: "de", name: "German", aliases: ["de", "ger", "deu", "german", "deutsch", "немецкий"] },
60
+ { code: "it", name: "Italian", aliases: ["it", "ita", "italian", "italiano", "итальянский"] },
61
+ { code: "pt", name: "Portuguese", aliases: ["pt", "por", "portuguese", "portugues", "português", "португальский"] },
62
+ { code: "pl", name: "Polish", aliases: ["pl", "pol", "polish", "polski", "польский"] },
63
+ { code: "nl", name: "Dutch", aliases: ["nl", "dut", "nld", "dutch", "nederlands", "голландский", "нидерландский"] },
64
+ { code: "ar", name: "Arabic", aliases: ["ar", "ara", "arabic", "арабский"] },
65
+ { code: "tr", name: "Turkish", aliases: ["tr", "tur", "turkish", "türkçe", "турецкий"] },
66
+ { code: "vi", name: "Vietnamese", aliases: ["vi", "vie", "vietnamese", "вьетнамский"] },
67
+ { code: "th", name: "Thai", aliases: ["th", "tha", "thai", "тайский"] },
68
+ { code: "hi", name: "Hindi", aliases: ["hi", "hin", "hindi", "хинди"] },
69
+ { code: "id", name: "Indonesian", aliases: ["id", "ind", "indonesian", "индонезийский"] },
70
+ { code: "ms", name: "Malay", aliases: ["ms", "may", "msa", "malay", "малайский"] },
71
+ { code: "cs", name: "Czech", aliases: ["cs", "cze", "ces", "czech", "чешский"] },
72
+ { code: "sk", name: "Slovak", aliases: ["sk", "slo", "slk", "slovak", "словацкий"] },
73
+ { code: "ro", name: "Romanian", aliases: ["ro", "rum", "ron", "romanian", "румынский"] },
74
+ { code: "hu", name: "Hungarian", aliases: ["hu", "hun", "hungarian", "венгерский"] },
75
+ { code: "sr", name: "Serbian", aliases: ["sr", "srp", "serbian", "српски", "сербский"] },
76
+ { code: "hr", name: "Croatian", aliases: ["hr", "hrv", "croatian", "hrvatski", "хорватский"] },
77
+ { code: "bs", name: "Bosnian", aliases: ["bs", "bos", "bosnian", "боснийский"] },
78
+ { code: "sl", name: "Slovenian", aliases: ["sl", "slv", "slovenian", "словенский"] },
79
+ { code: "bg", name: "Bulgarian", aliases: ["bg", "bul", "bulgarian", "български", "болгарский"] },
80
+ { code: "mk", name: "Macedonian", aliases: ["mk", "mac", "mkd", "macedonian", "македонский"] },
81
+ { code: "el", name: "Greek", aliases: ["el", "gre", "ell", "greek", "греческий"] },
82
+ { code: "he", name: "Hebrew", aliases: ["he", "heb", "hebrew", "иврит"] },
83
+ { code: "da", name: "Danish", aliases: ["da", "dan", "danish", "датский"] },
84
+ { code: "fi", name: "Finnish", aliases: ["fi", "fin", "finnish", "suomi", "финский"] },
85
+ { code: "no", name: "Norwegian", aliases: ["no", "nor", "nob", "nno", "norwegian", "норвежский"] },
86
+ { code: "sv", name: "Swedish", aliases: ["sv", "swe", "swedish", "svenska", "шведский"] },
87
+ { code: "et", name: "Estonian", aliases: ["et", "est", "estonian", "эстонский"] },
88
+ { code: "lv", name: "Latvian", aliases: ["lv", "lav", "latvian", "латышский"] },
89
+ { code: "lt", name: "Lithuanian", aliases: ["lt", "lit", "lithuanian", "литовский"] },
90
+ { code: "fa", name: "Persian", aliases: ["fa", "per", "fas", "persian", "farsi", "персидский"] },
91
+ { code: "ka", name: "Georgian", aliases: ["ka", "geo", "kat", "georgian", "грузинский"] },
92
+ { code: "hy", name: "Armenian", aliases: ["hy", "arm", "hye", "armenian", "армянский"] },
93
+ { code: "az", name: "Azerbaijani", aliases: ["az", "aze", "azerbaijani", "азербайджанский"] },
94
+ { code: "kk", name: "Kazakh", aliases: ["kk", "kaz", "kazakh", "казахский"] },
95
+ { code: "is", name: "Icelandic", aliases: ["is", "ice", "isl", "icelandic", "исландский"] },
96
+ { code: "sq", name: "Albanian", aliases: ["sq", "alb", "sqi", "albanian", "албанский"] },
97
+ { code: "eu", name: "Basque", aliases: ["eu", "baq", "eus", "basque"] },
98
+ { code: "cy", name: "Welsh", aliases: ["cy", "wel", "cym", "welsh"] },
99
+ { code: "ca", name: "Catalan", aliases: ["ca", "cat", "catalan", "català"] },
100
+ { code: "gl", name: "Galician", aliases: ["gl", "glg", "galician"] },
101
+ { code: "my", name: "Burmese", aliases: ["my", "bur", "mya", "burmese"] },
102
+ { code: "bo", name: "Tibetan", aliases: ["bo", "tib", "bod", "tibetan"] },
103
+ { code: "mi", name: "Maori", aliases: ["mi", "mao", "mri", "maori"] }
104
+ ];
105
+
106
+ /** alias → { code, name }. Built once. */
107
+ const BY_ALIAS = new Map();
108
+ for (const language of LANGUAGES) {
109
+ for (const alias of language.aliases) {
110
+ BY_ALIAS.set(alias.toLowerCase(), { code: language.code, name: language.name });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Words that appear where a language would and are NOT one.
116
+ *
117
+ * Two kinds, and both are needed. A folder can say only that a KIND of track
118
+ * lives in it — VLC's own default search path is `./Subtitles, ./subtitles,
119
+ * ./Subs, ./subs`, and a Russian release writes the same thing in Russian. And
120
+ * a name can carry a technical word that happens to sit where a language token
121
+ * does. Today `Subs/` is safe only because no such word happens to be in the
122
+ * language table; that is an accident, and this makes it a statement.
123
+ */
124
+ const NEUTRAL_WORDS = new Set([
125
+ "sub", "subs", "subtitle", "subtitles", "subtitulos", "sous", "titres",
126
+ "sound", "audio", "dub", "dubs", "dubbed", "track", "tracks", "voice",
127
+ "субтитры", "субтитр", "суб", "сабы", "саб", "титры",
128
+ "звук", "звуковая", "звуковые", "дорожка", "дорожки", "аудио",
129
+ "озвучка", "озвучание", "перевод", "переводы", "дубляж"
130
+ ]);
131
+
132
+ /** Flags, per Jellyfin's `NamingOptions` and mpv's `language.c`. */
133
+ const FORCED_FLAGS = new Set(["forced", "foreign"]);
134
+ const HEARING_IMPAIRED_FLAGS = new Set(["sdh", "cc", "hi"]);
135
+ const DEFAULT_FLAGS = new Set(["default"]);
136
+
137
+ /**
138
+ * Look up one bare token as a language.
139
+ *
140
+ * @param {string} token
141
+ * @returns {{ code: string, name: string } | null}
142
+ */
143
+ export function lookupLanguage(token) {
144
+ if (typeof token !== "string") {
145
+ return null;
146
+ }
147
+ return BY_ALIAS.get(token.trim().toLowerCase()) ?? null;
148
+ }
149
+
150
+ /**
151
+ * Is this a word that names a KIND of track rather than a language?
152
+ *
153
+ * @param {string} token
154
+ * @returns {boolean}
155
+ */
156
+ export function isNeutralWord(token) {
157
+ return typeof token === "string" && NEUTRAL_WORDS.has(token.trim().toLowerCase());
158
+ }
159
+
160
+ /**
161
+ * Read one token as a language tag, keeping a region or script subtag.
162
+ *
163
+ * Kodi writes BCP-47 with `_` where the standard has `-` (its own example is
164
+ * `The Matrix.zh_yue.srt`), so both separators are accepted. The whole tag is
165
+ * KEPT — `pt-BR` against `pt-PT` is a distinction a viewer can see — while the
166
+ * lookup is done on the primary subtag alone. Per BCP-47 and mpv's reading of
167
+ * it: the primary subtag is 2 or 3 letters, later subtags are 1 to 8
168
+ * alphanumerics.
169
+ *
170
+ * @param {string} token
171
+ * @returns {{ code: string, name: string } | null}
172
+ */
173
+ export function parseLanguageTag(token) {
174
+ if (typeof token !== "string") {
175
+ return null;
176
+ }
177
+ const trimmed = token.trim();
178
+ if (trimmed.length === 0 || isNeutralWord(trimmed)) {
179
+ return null;
180
+ }
181
+ const whole = lookupLanguage(trimmed);
182
+ if (whole) {
183
+ return whole;
184
+ }
185
+ const parts = trimmed.split(/[-_]/);
186
+ if (parts.length < 2) {
187
+ return null;
188
+ }
189
+ const primary = lookupLanguage(parts[0]);
190
+ if (!primary || !/^[A-Za-z]{2,3}$/.test(parts[0])) {
191
+ return null;
192
+ }
193
+ for (const subtag of parts.slice(1)) {
194
+ if (!/^[A-Za-z0-9]{1,8}$/.test(subtag)) {
195
+ return null;
196
+ }
197
+ }
198
+ const base = primary.code.split("-")[0];
199
+ return { code: [base, ...parts.slice(1)].join("-"), name: primary.name };
200
+ }
201
+
202
+ /**
203
+ * Split a base name into the tokens a language or a flag could be.
204
+ *
205
+ * The delimiter follows mpv: `.` normally, but `(` when the name ends with `)`
206
+ * and `[` when it ends with `]`, so `Movie (Russian)` and `Movie [rus]` need no
207
+ * rule of their own. `_` is accepted alongside `.` because releases on these
208
+ * trackers use it interchangeably (`Avatar_The_Last_Airbender_S02E01_1080p_rus`),
209
+ * and neither character occurs inside a language code or a flag word.
210
+ *
211
+ * A trailing flag separated by a space or a dash is peeled off the last token,
212
+ * which is Kodi's rule for the forced flag: it "can use a '.' (dot), ' '
213
+ * (space) or '-' (dash) to separate them from the name of the movie and
214
+ * language of the subtitles".
215
+ *
216
+ * @param {string} baseName - File name with its extension already removed.
217
+ * @returns {string[]} Tokens in name order.
218
+ */
219
+ export function tokenizeName(baseName) {
220
+ const text = String(baseName ?? "").trim();
221
+ if (text.length === 0) {
222
+ return [];
223
+ }
224
+ // A bracketed tail is a token of its own; what comes BEFORE it is still split
225
+ // the ordinary way, so `Film.rus [1080p]` yields `rus` rather than `.rus`.
226
+ const bracketed = /^(.*)[([]([^()[\]]*)[)\]]$/.exec(text);
227
+ const head = bracketed ? bracketed[1] : text;
228
+ const tail = bracketed ? splitTrailingFlags(bracketed[2]) : [];
229
+ const parts = head.split(/[._]/);
230
+ const last = parts.pop() ?? "";
231
+ return [...parts, ...splitTrailingFlags(last), ...tail];
232
+ }
233
+
234
+ /**
235
+ * Peel flag words off the end of one token when a space or a dash separates
236
+ * them — `"English Forced"` is two tokens, `"English"` is one.
237
+ *
238
+ * @param {string} token
239
+ * @returns {string[]}
240
+ */
241
+ function splitTrailingFlags(token) {
242
+ let text = String(token ?? "").trim();
243
+ const flags = [];
244
+ for (;;) {
245
+ // Only a trailing FLAG is peeled. The token is otherwise left exactly as it
246
+ // is, because a dash is also what BCP-47 puts between subtags: splitting
247
+ // `pt-BR` here would destroy the region before anything could read it.
248
+ const match = /^(.*\S)[ -]+([A-Za-z]+)$/.exec(text);
249
+ if (!match) {
250
+ break;
251
+ }
252
+ const candidate = match[2].toLowerCase();
253
+ if (!FORCED_FLAGS.has(candidate) && !HEARING_IMPAIRED_FLAGS.has(candidate) && !DEFAULT_FLAGS.has(candidate)) {
254
+ break;
255
+ }
256
+ flags.unshift(match[2]);
257
+ text = match[1];
258
+ }
259
+ return [text, ...flags].filter((word) => word.length > 0);
260
+ }
261
+
262
+ /**
263
+ * @typedef {object} SidecarNameReading
264
+ * @property {{ code: string, name: string } | null} language
265
+ * @property {boolean} isForced
266
+ * @property {boolean} isHearingImpaired
267
+ * @property {boolean} isDefault
268
+ * @property {string | null} title - Text that is neither a language nor a flag.
269
+ */
270
+
271
+ /**
272
+ * Read the language and the flags a sidecar's file name states.
273
+ *
274
+ * Two readings, and which one applies depends on whether the picture's name is
275
+ * known — this is Jellyfin's own safeguard, and without it a name is dangerous
276
+ * to read. Jellyfin parses only the part of the name that FOLLOWS the video's
277
+ * name, because everything before it belongs to the film: `Movie.It.Follows.
278
+ * 2014.srt` would otherwise be read as Italian.
279
+ *
280
+ * 1. **The name begins with the video's name.** Everything after it is the
281
+ * releaser speaking about this track, so every token in it is read, the last
282
+ * language token wins, and what is neither language nor flag is the title.
283
+ * 2. **It does not** (or the video is unknown). Then tokens are read from the
284
+ * END backwards and reading STOPS at the first token that is neither a flag
285
+ * nor a language — mpv's rule, and the only safe one when the boundary
286
+ * between the film's name and the track's description is not known.
287
+ *
288
+ * @param {string} baseName - The sidecar's file name, extension removed.
289
+ * @param {string} [videoBaseName] - The picture's file name, extension removed.
290
+ * @returns {SidecarNameReading}
291
+ */
292
+ /**
293
+ * Whether a sidecar's base name continues the video's base name — and continues
294
+ * it at a token boundary.
295
+ *
296
+ * The boundary is the whole of the rule's safety. Without it, "begins with the
297
+ * video's name" pairs `Film.20.rus.srt` with `Film.2.mkv`: measured, and the
298
+ * grammar then reports the leftover `0` as the track's title, which is the tell
299
+ * that the remainder is a fragment of another film's name rather than anything
300
+ * a releaser wrote about this track.
301
+ *
302
+ * The delimiters are the ones every surveyed player splits on — `.`, `_`, `-`,
303
+ * space — plus mpv's bracket rule, so `Movie (Russian).srt` and `Movie [rus].srt`
304
+ * are read as continuations too.
305
+ *
306
+ * @param {string} base - The sidecar's base name.
307
+ * @param {string} video - The picture's base name.
308
+ * @returns {boolean}
309
+ */
310
+ export function nameFollows(base, video) {
311
+ if (video.length === 0 || base.length < video.length) {
312
+ return false;
313
+ }
314
+ if (!base.toLowerCase().startsWith(video.toLowerCase())) {
315
+ return false;
316
+ }
317
+ if (base.length === video.length) {
318
+ return true;
319
+ }
320
+ return /^[._\-\s([]/.test(base.slice(video.length));
321
+ }
322
+
323
+ export function readSidecarName(baseName, videoBaseName = "") {
324
+ const reading = {
325
+ language: null,
326
+ isForced: false,
327
+ isHearingImpaired: false,
328
+ isDefault: false,
329
+ title: null
330
+ };
331
+ const base = String(baseName ?? "").trim();
332
+ if (base.length === 0) {
333
+ return reading;
334
+ }
335
+
336
+ const video = String(videoBaseName ?? "").trim();
337
+ const followsVideo = nameFollows(base, video);
338
+ const text = followsVideo ? base.slice(video.length) : base;
339
+ const tokens = tokenizeName(text);
340
+ if (tokens.length === 0) {
341
+ return reading;
342
+ }
343
+
344
+ const leftovers = [];
345
+ let sawHiToken = false;
346
+ // Backwards: the LAST language in the name is the one that counts, which is
347
+ // Jellyfin's documented rule and what its parser does by walking this way.
348
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
349
+ const token = tokens[index];
350
+ const word = token.trim().toLowerCase();
351
+ if (word.length === 0) {
352
+ continue;
353
+ }
354
+ if (FORCED_FLAGS.has(word)) {
355
+ reading.isForced = true;
356
+ continue;
357
+ }
358
+ if (DEFAULT_FLAGS.has(word)) {
359
+ reading.isDefault = true;
360
+ continue;
361
+ }
362
+ // `hi` is both Hindi and "hearing impaired". Which it is depends on whether
363
+ // another language is named — Jellyfin: "`hi` by itself will resolve as a
364
+ // Hindi language track, while `hi` in conjunction with another language
365
+ // identifier … will use the other language and tag it as hearing impaired."
366
+ // It cannot be settled here, because the other language may still be to the
367
+ // left, so it is remembered and settled below.
368
+ if (word === "hi") {
369
+ sawHiToken = true;
370
+ continue;
371
+ }
372
+ if (HEARING_IMPAIRED_FLAGS.has(word)) {
373
+ reading.isHearingImpaired = true;
374
+ continue;
375
+ }
376
+ const language = parseLanguageTag(token);
377
+ if (language) {
378
+ if (reading.language === null) {
379
+ reading.language = language;
380
+ }
381
+ continue;
382
+ }
383
+ if (!followsVideo) {
384
+ break;
385
+ }
386
+ leftovers.unshift(token.trim());
387
+ }
388
+
389
+ if (sawHiToken) {
390
+ if (reading.language === null) {
391
+ reading.language = lookupLanguage("hi");
392
+ } else {
393
+ reading.isHearingImpaired = true;
394
+ }
395
+ }
396
+
397
+ const title = leftovers.filter((part) => part.length > 0).join(" ").trim();
398
+ reading.title = title.length > 0 ? title : null;
399
+ return reading;
400
+ }
401
+
402
+ /**
403
+ * The language a path's FOLDERS state, looking from the innermost outwards.
404
+ *
405
+ * A whole segment is tried first (`ENG`, `Russian`, `рус`), then its words —
406
+ * which is what reads `Rus Sound` as Russian. A word is only accepted from a
407
+ * multi-word segment when it is at least three letters, because two-letter
408
+ * codes are also ordinary words (`no`, `id`, `it`) and a folder called
409
+ * `No Subs` does not mean Norwegian.
410
+ *
411
+ * A segment made only of words that name a KIND of track — `Subs`, `Субтитры` —
412
+ * states no language and is skipped rather than searched, so a future table
413
+ * entry cannot turn one into a language by accident.
414
+ *
415
+ * @param {string[]} folders - Folders above the file, outermost first.
416
+ * @returns {{ code: string, name: string } | null}
417
+ */
418
+ export function languageFromFolderNames(folders) {
419
+ const segments = Array.isArray(folders) ? folders : [];
420
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
421
+ const segment = String(segments[index] ?? "").trim();
422
+ if (segment.length === 0 || isNeutralWord(segment)) {
423
+ continue;
424
+ }
425
+ const whole = parseLanguageTag(segment);
426
+ if (whole) {
427
+ return whole;
428
+ }
429
+ const words = segment.split(/[^\p{L}]+/u).filter((word) => word.length >= 3);
430
+ for (const word of words) {
431
+ const found = lookupLanguage(word);
432
+ if (found) {
433
+ return found;
434
+ }
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Reading a whole sidecar path: its name AND the folders above it.
442
+ //
443
+ // This half used to live in the browser (`subtitle-utils.js`), which is also
444
+ // where the pairing was decided — two answers about the same file, computed on
445
+ // two sides, compared nowhere. Measured 2026-09-04 over the 115 torrents in
446
+ // `Dropbox/trn`: 1249 video files, 1239 pairings agreed and 10 did not, every
447
+ // one of the ten a `<video base>.<language>.<ext>` name that the browser paired
448
+ // and the proxy did not. The proxy warms what it paired and the browser offers
449
+ // what it paired, so a disagreement reached the viewer as a track nobody had
450
+ // warmed — a wait of 27.7 s in the field measurement of 2026-08-31.
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /**
454
+ * Remove the file extension from a name.
455
+ *
456
+ * @param {string} name
457
+ * @returns {string}
458
+ */
459
+ export function stripExtension(name) {
460
+ const text = typeof name === "string" ? name : "";
461
+ const dot = text.lastIndexOf(".");
462
+ return dot > 0 ? text.slice(0, dot) : text;
463
+ }
464
+
465
+ /**
466
+ * The bracketed groups of a name, in order.
467
+ *
468
+ * @param {string} text
469
+ * @returns {string[]}
470
+ */
471
+ function bracketTokens(text) {
472
+ const source = typeof text === "string" ? text : "";
473
+ const tokens = [];
474
+ for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
475
+ const token = match[1].trim();
476
+ if (token.length > 0) {
477
+ tokens.push(token);
478
+ }
479
+ }
480
+ return tokens;
481
+ }
482
+
483
+ /**
484
+ * Bracketed groups that describe the ENCODE rather than a team — resolution,
485
+ * codec, source, audio format, bit depth. A releaser's name is what is left
486
+ * after these are set aside.
487
+ *
488
+ * @param {string} token
489
+ * @returns {boolean}
490
+ */
491
+ function isTechnicalToken(token) {
492
+ const text = String(token ?? "").trim().toLowerCase();
493
+ if (text.length === 0) {
494
+ return true;
495
+ }
496
+ // A release hash: `[78EFD746]`. It identifies the file, not its author.
497
+ if (/^[0-9a-f]{4,10}$/.test(text) && /\d/.test(text)) {
498
+ return true;
499
+ }
500
+ if (/^\d{3,4}[pi]$/.test(text) || /^\d{3,4}x\d{3,4}$/.test(text)) {
501
+ return true;
502
+ }
503
+ if (/^(x|h)\.?26[45]$/.test(text) || /^(hevc|avc|av1|vp9|xvid|divx)$/.test(text)) {
504
+ return true;
505
+ }
506
+ if (/^(aac|ac3|eac3|dts(-?hd)?|flac|mp3|opus|truehd|atmos|pcm|\d\.\d)$/.test(text)) {
507
+ return true;
508
+ }
509
+ if (/^(web-?rip|web-?dl|bd-?rip|blu-?ray|hdtv|dvd-?rip|remux|hdr\d*|dv|sdr|\d{1,2}bit)$/.test(text)) {
510
+ return true;
511
+ }
512
+ // A bracket that names a language names a language, not a team; and one that
513
+ // names the KIND of track (`[Subs]`) names neither.
514
+ return lookupLanguage(text) !== null || isNeutralWord(text);
515
+ }
516
+
517
+ /**
518
+ * What is left of a name after the language and the flags, as a TITLE — or
519
+ * nothing, when what is left only describes the encode.
520
+ *
521
+ * Jellyfin keeps every leftover word ("Any arbitrary text not parsable to a
522
+ * language or flag will be combined and used as the title of the stream"), and
523
+ * on its own library that is right, because the part it reads is what the owner
524
+ * deliberately appended. A torrent is not that tidy: `Film.1080p.rus.srt` beside
525
+ * `Film.mkv` leaves `1080p`, and a track labelled "Russian (1080p)" is worse
526
+ * than one labelled "Russian".
527
+ *
528
+ * @param {string | null} title
529
+ * @returns {string | null}
530
+ */
531
+ function titleOf(title) {
532
+ if (typeof title !== "string") {
533
+ return null;
534
+ }
535
+ const words = title.split(/\s+/).filter((word) => word.length > 0);
536
+ if (words.length === 0) {
537
+ return null;
538
+ }
539
+ // ALL of it, or none. A word is only weighed on its own when it stands on its
540
+ // own: inside a phrase, a word that happens to name a language is part of the
541
+ // phrase — `English Commentary` is a title, and dropping `English` from it
542
+ // would leave a label nobody wrote.
543
+ return words.every((word) => isTechnicalToken(word)) ? null : words.join(" ");
544
+ }
545
+
546
+ /**
547
+ * Who made a track that ships as its own file, when the torrent says so.
548
+ *
549
+ * The rule that keeps this honest: a bracketed group is only a releaser of the
550
+ * SIDECAR when it is not also in the video's own name. In the field case that
551
+ * settles it — a Russian dub named exactly like the picture,
552
+ * `[HorribleSubs] Drifters - 02 [1080p].mka`, sitting in `Rus Sound/` — every
553
+ * bracket it carries is the picture's, and attributing the dub to HorribleSubs
554
+ * would be inventing an author. The subtitles of the same release, in
555
+ * `Sub/[Stan WarHammer & Nesitach]/`, carry a bracket the video does not, and
556
+ * that one IS their author.
557
+ *
558
+ * The innermost folder is looked at before the file name, because that is where
559
+ * a release puts the team when several of them contributed.
560
+ *
561
+ * @param {object} params
562
+ * @param {string[]} params.folders - Folders above the file, innermost last.
563
+ * @param {string} params.fileName
564
+ * @param {string} [params.videoName] - The picture's own file name.
565
+ * @returns {string | null}
566
+ */
567
+ export function releaserFrom({ folders, fileName, videoName = "" }) {
568
+ const shared = new Set(
569
+ bracketTokens(stripExtension(String(videoName ?? ""))).map((token) => token.toLowerCase())
570
+ );
571
+ const sources = [...(Array.isArray(folders) ? folders : [])].reverse();
572
+ sources.push(stripExtension(String(fileName ?? "")));
573
+ for (const source of sources) {
574
+ for (const token of bracketTokens(source)) {
575
+ if (shared.has(token.toLowerCase()) || isTechnicalToken(token)) {
576
+ continue;
577
+ }
578
+ return token;
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ /**
585
+ * Language, flags and releaser for a track that ships as its own file.
586
+ *
587
+ * Reading order, and the reason for it: the FILE NAME first, then the folders.
588
+ * All five players surveyed read the name
589
+ * (`research/sidecar-naming-conventions-2026-09-01.md`); only VLC and Kodi treat
590
+ * a folder as a place rather than as a statement about language. A name is also
591
+ * per-file where a folder is per-group, so where both speak the name is the more
592
+ * specific of the two.
593
+ *
594
+ * A soundtrack is named by the same conventions a subtitle is — Jellyfin's own
595
+ * example set carries `Film.en.ac3` and `Film.german.ac3` beside `Film.de.srt` —
596
+ * so it is the same grammar over the same path.
597
+ *
598
+ * @param {object} params
599
+ * @param {string[]} params.folders
600
+ * @param {string} params.fileName
601
+ * @param {string} [params.videoName]
602
+ * @returns {{ code: string | null, name: string | null, releaser: string | null,
603
+ * isForced: boolean, isHearingImpaired: boolean, isDefault: boolean }}
604
+ */
605
+ export function sidecarNaming({ folders, fileName, videoName = "" }) {
606
+ const fromName = readSidecarName(
607
+ stripExtension(String(fileName ?? "")),
608
+ stripExtension(String(videoName ?? ""))
609
+ );
610
+ const language = fromName.language ?? languageFromFolderNames(folders);
611
+ return {
612
+ code: language?.code ?? null,
613
+ name: language?.name ?? null,
614
+ releaser: titleOf(fromName.title) ?? releaserFrom({ folders, fileName, videoName }),
615
+ isForced: fromName.isForced,
616
+ isHearingImpaired: fromName.isHearingImpaired,
617
+ isDefault: fromName.isDefault
618
+ };
619
+ }