Compare commits
584 Commits
7701ec5a7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95b5df5f5 | ||
| 2712775d4f | |||
| 80e509e2a0 | |||
| 0b333fcba8 | |||
| 7c5622ab5c | |||
| 557c46880e | |||
| e9b134fc9c | |||
| f03db2888f | |||
| 45341ae212 | |||
| 74930976c4 | |||
| de5d292599 | |||
| eab4d5e302 | |||
| 528af5bf8b | |||
| ee7efc33b7 | |||
| c36d180961 | |||
| d7385c01b1 | |||
| 5c64227f30 | |||
| 8b8f85bea7 | |||
| e75845b78a | |||
| a577567fdc | |||
| 3626edc231 | |||
| 30be4551ee | |||
| 610b5029a3 | |||
| 0e3d4b4b11 | |||
| 78bd003b7c | |||
| 352f32bca7 | |||
| 18bcaf9e31 | |||
| 15e08e1460 | |||
| c4f276b72d | |||
| c13e5541a6 | |||
| 6dfca350f0 | |||
| 1b19bf65de | |||
| 92c316f4a7 | |||
| eb90710dc6 | |||
| e556b0fe09 | |||
| b32873fc76 | |||
| bf13a4a1f8 | |||
| 73b4436f61 | |||
| 522a99ddf8 | |||
| 5679be866a | |||
| 51bdf5c53a | |||
| f3b6489d03 | |||
| 9487ff124a | |||
| dbda4b96a4 | |||
| a4bc38990d | |||
| 05ede20893 | |||
| 03e1990f11 | |||
| 5a3797b5df | |||
| 61900a508c | |||
| 413759c454 | |||
| 7215b1a4ee | |||
| 6b6256b839 | |||
| 28bb28c5e5 | |||
| 65358aa809 | |||
| e39e73bc6b | |||
| f5b38c519b | |||
| a595ade533 | |||
| 8ec647943e | |||
| 722f936734 | |||
| 8e11afb3b5 | |||
| 80b5aec448 | |||
| d06d6de4c4 | |||
| 1110cb1809 | |||
| 0d2459fa3c | |||
| 9f370448ed | |||
| 6a7cd55b47 | |||
| 217478c894 | |||
| 137c6ab644 | |||
| 539dd917f8 | |||
| 1317bbb532 | |||
| d3ddf5d228 | |||
| 3c82c32e87 | |||
| 7d59674649 | |||
| 7278676a2e | |||
| 0abee703a1 | |||
| 8c2562496a | |||
| a0d11e3a7b | |||
| a9bb16d8b5 | |||
| a31d71bca6 | |||
| 11ec256e18 | |||
| 7dfc54b3e3 | |||
| d2d94b919d | |||
| a3cfae33f2 | |||
| 04a9b0b0bd | |||
| 57a6aa8f7a | |||
| 9c19f0da1e | |||
| d1eafdd6de | |||
| a527981447 | |||
| 43fb5509f1 | |||
| b796560f77 | |||
| e1de04f72e | |||
| f4cacd7390 | |||
| 42fcc94773 | |||
| 992705a2b6 | |||
| 54f92725d9 | |||
| 9955c7b000 | |||
| 9ac41d3f9f | |||
| 489d025426 | |||
| 85e0dd5913 | |||
| 8ababab46b | |||
| 2952bd0e89 | |||
| 66fa70e6ad | |||
| 72c5c99737 | |||
| 3ed092c5c5 | |||
| 660ae022ea | |||
| f4bad31290 | |||
| 1af0ec351d | |||
| 11bdd9751b | |||
| 22f61a8c98 | |||
| b14aac130d | |||
| bf38ca59b7 | |||
| fda5620a54 | |||
| 021b39dc0d | |||
| 6681f535ac | |||
| 904483da15 | |||
| e43e8edaca | |||
| c7fceda2a8 | |||
| 5c77d90010 | |||
| 4516417c6b | |||
| 289ad25064 | |||
| 8561e0dca0 | |||
| 2476a21b86 | |||
| 2d4e4c6ae3 | |||
| 22b26da0dc | |||
| 37717fec69 | |||
| 40eb3a146e | |||
| db9ac7c28c | |||
| fbbee45650 | |||
| 54fb72e001 | |||
| 89447cbc15 | |||
| ba0e5c6d21 | |||
| ca67562a77 | |||
| 32fed1eee2 | |||
| 01776f4eef | |||
| 58abaaceb0 | |||
| 372c93ab52 | |||
| 025e926482 | |||
| 49abffb21a | |||
| 1800335719 | |||
| fed8bf76a0 | |||
| 08f58cf377 | |||
| d61ed31d1e | |||
| 0bf78f1ea3 | |||
| 886e412be5 | |||
| 2918fa5051 | |||
| 223c0fa89e | |||
| 72ee744161 | |||
| df9fd64123 | |||
| 3a824c9c7e | |||
| 59756095c2 | |||
| 5e12a496e7 | |||
| 86ba058825 | |||
| 4afa0b46f9 | |||
| 078dfe9a58 | |||
| 7f74e4bc55 | |||
| c6afc2bc53 | |||
| ed7dfea834 | |||
| be71ec91c8 | |||
| 1e6ac5e854 | |||
| 748c823172 | |||
| 44d4b7c342 | |||
| 37c5e66217 | |||
| cdcf6a5a2a | |||
| 094d560c30 | |||
| c5ae4e7d69 | |||
| 02dcde5786 | |||
| 4bae78ca56 | |||
| e9a167b2c9 | |||
| a74087aa2a | |||
| 6edb93d5af | |||
| 63221d72dc | |||
| b59ad79117 | |||
| ae88b92151 | |||
| f661c2ed6c | |||
| d81f59de05 | |||
| b648afa471 | |||
| 34923212c5 | |||
| 4ded4e789b | |||
| 42fc505068 | |||
| 6d5e9d0a2c | |||
| 1d9840f2b9 | |||
| 177081edd0 | |||
| 549f964e8e | |||
| 83dd4510df | |||
| 6465dc137c | |||
| ae00c04cf9 | |||
| 531566291b | |||
| 1010dd06d2 | |||
| 9acb9218c2 | |||
| 88c066398e | |||
| 87bc6228e7 | |||
| 1272800ca2 | |||
| 1e4dcd2208 | |||
| 490b2e3ed5 | |||
| ae1b323732 | |||
| aaf9cf41f3 | |||
| e502056f2f | |||
| cbf084e935 | |||
| 75520aa1a3 | |||
| 0480b70dfc | |||
| b4c759103b | |||
| eeb85d4aca | |||
| e54b9cef5e | |||
| 538a3f0138 | |||
| 2ad8605c9c | |||
| 3c4f5375ed | |||
| 5338c61a5d | |||
| f8e3f89e42 | |||
| ff4998f3f1 | |||
| c1475dd71c | |||
| 8345143bb0 | |||
| 34d2b37e94 | |||
| 07eae2d971 | |||
| b2f00e879a | |||
| 17a65c4926 | |||
| 0ed4ab3f82 | |||
| cf5462312e | |||
| d08961efbc | |||
| 379e2ff313 | |||
| 33c91e91ca | |||
| 0d31d46fae | |||
| 3aa8ce4638 | |||
| adcae02c18 | |||
| e9917730b8 | |||
| b171df07f2 | |||
| aba83d1109 | |||
| b67c4f211b | |||
|
|
ecab93de84 | ||
| 32b08245ca | |||
| a732e13ee8 | |||
| 4bb31e6f00 | |||
| 998a1d212a | |||
| 243ea85dbf | |||
| b0dee41eb8 | |||
| c6f24e07f0 | |||
| c68ee462e9 | |||
| 306d209701 | |||
| 860c8df639 | |||
| c7252f7297 | |||
| ad69ac7e6c | |||
| 7b59435f33 | |||
| 894c1adf97 | |||
| 2463d88c53 | |||
| f062395eac | |||
| ac8e583e23 | |||
| 9f973c178a | |||
| 734b39efd7 | |||
| e1da6ddb19 | |||
| c0dbe6be2a | |||
| 01af5b60bf | |||
| 7e76c28396 | |||
| 0c3be8a8d3 | |||
| f2822f068f | |||
| 6e7e1fc001 | |||
| f6ed2c673d | |||
| c777de9f06 | |||
| 2213293b57 | |||
| 1dd37a5aa2 | |||
| 1e5fe7f879 | |||
| 87b67744be | |||
| fbc4b3c90c | |||
| 697fa38577 | |||
| a9fa4d8d90 | |||
| 42017681f5 | |||
| 69c1c9d507 | |||
| b0aec3eaa9 | |||
| fe92997160 | |||
| 43019f2771 | |||
| c5035eb2a0 | |||
| a981028c5d | |||
| 7010f72a26 | |||
| a5875de32e | |||
| 52b04b17d1 | |||
| 812371dca4 | |||
| c1574160e8 | |||
| 6bd74a18f7 | |||
| 726861f933 | |||
| 8792cdc457 | |||
| d3ec9d9f6e | |||
| 0fe3a71627 | |||
| 577f289f81 | |||
| 3d855ab1eb | |||
| a0fff22e90 | |||
| dbe029e15e | |||
| b87f743615 | |||
| 61ff4a4fa2 | |||
| 07f6080451 | |||
| d420b9b741 | |||
| b216a74abf | |||
| 582c8d841c | |||
| a2e99dc4ec | |||
| da62913a93 | |||
| 952bc0b9c0 | |||
| b6668015e3 | |||
| cf4348e27e | |||
| 73939d3ba3 | |||
| cb353083aa | |||
| 40e74bd793 | |||
| 68086904a7 | |||
| 8351d850c5 | |||
| 426453612f | |||
| 9472cb5362 | |||
| 5584dcff12 | |||
| f6ea4d65d6 | |||
| 698ee33227 | |||
| 57c92f43c9 | |||
| 5ded3f9bd4 | |||
| abfda4d1db | |||
| 70cdbf3ceb | |||
| 4c340aed14 | |||
| 949618aecd | |||
| 36d8d48e90 | |||
| 0a44dfb474 | |||
| 25abee8930 | |||
| 54eebe5245 | |||
| e10e6a47c9 | |||
| 3b695e5d19 | |||
| 6c665f1624 | |||
| 75e5f69b33 | |||
| 4fc9d22ef7 | |||
| 216f9063bf | |||
| 40d57fd7ee | |||
| 0cb7ac19e4 | |||
| b3dec5ebf7 | |||
| 54708b93a4 | |||
| abe8f8e867 | |||
| e8d5da949c | |||
| a672ee861d | |||
| 180ab2bc6a | |||
| b620b68eec | |||
| 1d51e5f86d | |||
| 62f6d1cf0f | |||
| cbbfcc9828 | |||
| 56642c4150 | |||
| 2b30c06582 | |||
| 6ba16dd08e | |||
| 1b412798ef | |||
| de22cc6971 | |||
| 306468b4fd | |||
| c040b29819 | |||
| 1240af74fc | |||
| 08c92a940a | |||
| d4ca95de1a | |||
| ce8c618eba | |||
| 3a33df5d9b | |||
| a617feb269 | |||
| 2dccc72f5c | |||
| d7f844563b | |||
| 2eadc2835c | |||
| 6c3a379786 | |||
| ee715162ac | |||
| 02059ea607 | |||
| 69c9f87080 | |||
| b5c58f062d | |||
| c8bcabe804 | |||
| 8881491e18 | |||
| f6ac3ed818 | |||
| cba35c6a02 | |||
| ec7abdd760 | |||
| 2c5cc957c8 | |||
| 1810d11983 | |||
| f261a88e6e | |||
| a5de6de778 | |||
| e1e39d365b | |||
| d20d7a683c | |||
| 8f754d9467 | |||
| f0633dba46 | |||
| aad3b775cd | |||
| 5e1b577b34 | |||
| 4151525acf | |||
| c6427bf866 | |||
| 7b33a6d977 | |||
| 5d32173d7a | |||
| 480ce55117 | |||
| 7aa8aca4b5 | |||
| f88353f725 | |||
| 420817a849 | |||
| ed1677e5ec | |||
| 595c5e1eaa | |||
| 8fed96c6cf | |||
| 264314bd17 | |||
| 85afbe8e69 | |||
| 89bf8f90c3 | |||
| bb2dc447ac | |||
| 6efd7dca8c | |||
| 91641b9a95 | |||
| c47ac97c2b | |||
| fe4a200927 | |||
| 2a24565848 | |||
| 73b59856bf | |||
| c5f36887a1 | |||
| 328bc6229b | |||
| 8eb4c6156d | |||
| 34a9946959 | |||
| 6406b85a7e | |||
| bd6bd22478 | |||
| cf87a6770d | |||
| 10992a61a3 | |||
| 75fb9b020a | |||
| 44dfc1a122 | |||
| 38f9d70df2 | |||
| 090af0e97c | |||
| 02d936e798 | |||
| 2a5dc21afa | |||
| 2acf980da0 | |||
| 8675b9c20b | |||
| 796ce42576 | |||
| 649c754a78 | |||
| be0dfaf734 | |||
| ed59c9f8b5 | |||
| 3fa6f76de0 | |||
| 0de1a4d01b | |||
| 9f50a145aa | |||
| eb6c74f3a3 | |||
| d649989fc2 | |||
| b6319af4e0 | |||
| 90d1296d78 | |||
| 663e29113d | |||
| ec349f3714 | |||
| 6cecd9bac8 | |||
| 420c97704f | |||
| ac45080fd9 | |||
| 0ed3968f99 | |||
| eaa8574c03 | |||
| e22c2e7647 | |||
| 8b905f0080 | |||
| ec26913e65 | |||
| 120c0ce801 | |||
| 4fc8688837 | |||
| 29e684a65a | |||
| 6f26a59ca3 | |||
| f34ecf29e2 | |||
| bc75dcb138 | |||
| fbb4e90bd5 | |||
| 9c75282b59 | |||
| 07929eeeb5 | |||
| fee7653386 | |||
| ff33221867 | |||
| 4df0de7ce2 | |||
| 1b70e6500c | |||
| 3609c80cbc | |||
| 37db709b64 | |||
| fe92fefe5c | |||
| c0746b7300 | |||
| 47d3c17570 | |||
| fcfabd0980 | |||
| f50b5564fb | |||
| 92812c7f42 | |||
| 0074669d50 | |||
| 46c82ef0b7 | |||
| ecf1ae1f32 | |||
| 5b4ed5facc | |||
| 9e5bc8cdfc | |||
| ab1ee0c26c | |||
| cfd767e772 | |||
| 4512bbdbe5 | |||
| f578f2b495 | |||
| d9c4363205 | |||
| a02f45260b | |||
| 6bff7bfaa3 | |||
| 616e98dbd7 | |||
| 6241b27959 | |||
| da8c75f1aa | |||
| b5f48389b7 | |||
| 347f88ce0a | |||
| 8a9295c03a | |||
| 8cc4e42ff1 | |||
| 43ec2d106e | |||
| 4291c4999e | |||
| 68b7426fe9 | |||
| 274b0b055d | |||
| 30c9de86f1 | |||
| a613862b9e | |||
| 07725c01c9 | |||
| 54434e8066 | |||
| 3123fd374e | |||
| 608d9e6598 | |||
| 9380e27a8f | |||
| 2d79c3e1ad | |||
| 068ff8ec54 | |||
| b22aa29547 | |||
| f11bc865b5 | |||
| 6d3773794d | |||
| 93d567710f | |||
| bc230f753e | |||
| 909f43ff75 | |||
| 1ef16a42ff | |||
| e94d6a4e67 | |||
| eeb9e796fa | |||
| 302683b7ac | |||
| 2b3d6cc38a | |||
| 362d53f4c7 | |||
| 44fa4bbe35 | |||
| c70fb28d2d | |||
| d4883d9228 | |||
| 5ae2a41c1b | |||
| 37356851c2 | |||
| 3588582e85 | |||
| 1ae42bf3fd | |||
| e109194340 | |||
| d2d14a27c2 | |||
| a8a179185d | |||
| 58d63fd30a | |||
| c9dcec9a44 | |||
| 45a7a18b0d | |||
| a7822611a2 | |||
| 27bda397f9 | |||
| ec4ea435c9 | |||
| c529c3354f | |||
| 6cbb6209c4 | |||
| 653f8f85e1 | |||
| c2b92ad5a4 | |||
| e24d832cf0 | |||
| 6545e898bb | |||
| 99133f5d3e | |||
| c9e4925850 | |||
| f46999a2f6 | |||
| 7f6256c7bd | |||
| 2cd270e7e3 | |||
| 23852e53a6 | |||
| b32c8d632d | |||
| 1d211dd118 | |||
| 57a4272a92 | |||
| b856af7b7e | |||
| 3893718c39 | |||
| a95a339c0c | |||
| 5fe34eb1c9 | |||
| 7f67ebcf8c | |||
| 2dae9fdd37 | |||
| 98ea153d0b | |||
| 217aabb1f5 | |||
| 05bf1616be | |||
| 32274d79f3 | |||
| cda8bd55c2 | |||
| a6054ccf88 | |||
| abe91b71e6 | |||
| ec205ebe24 | |||
| 2816a85457 | |||
| 4773b2e406 | |||
| e945d1952c | |||
| c81d01d592 | |||
| 9311933b48 | |||
| e9bb8ee05c | |||
| f0d95f7b82 | |||
| c8cbaf58a9 | |||
| 8cee6af44a | |||
| e82e0b0bec | |||
| 29b139fb03 | |||
| 55929d0c3c | |||
| f5e4048158 | |||
| e13df784e0 | |||
| 71ec3d3548 | |||
| 2e9eaf8fef | |||
| 1c17d6334f | |||
| 579373642c | |||
| 95264a371d | |||
| b8eee45c9c | |||
| 5b4c7f3729 | |||
| 3ccbcd2023 | |||
| fa465630ab | |||
| 108f037818 | |||
| 894f2166ae | |||
| a39dbed851 | |||
| 3c9eb791b9 | |||
| 7fb34525cc | |||
| 367a4592c5 | |||
| c3a80af469 | |||
| 3dccde9e7e | |||
| b8bbee6a33 | |||
| c083e35672 | |||
| 316ed27938 | |||
| a85327bd25 | |||
| 9ca8e9e8bf | |||
| 93a2f181e5 | |||
| 9dac4cbc44 | |||
| d87b3a79e5 | |||
| dd0a8bd306 | |||
| bfae7745a6 | |||
| 6187afad71 | |||
| 6d1322b2b7 | |||
| a1890e22cb | |||
| 480d780701 | |||
| bc7fe27eb5 | |||
| c67797e72d |
@@ -0,0 +1,134 @@
|
|||||||
|
# Fix 06: improve player loading and recovery states
|
||||||
|
|
||||||
|
Priority: P1
|
||||||
|
Risk: Low to medium
|
||||||
|
Primary benefit: Perceived performance, accessibility, and error recovery
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
The player displays an animated spinner over a black frame while loading. During the audited cold start, this state lasted almost ten seconds. The spinner does not explain whether the application is finding a source, loading metadata, buffering media, retrying, or stuck. It also lacks a useful live status for assistive technology.
|
||||||
|
|
||||||
|
Several player buttons also appeared without accessible names in the browser accessibility tree.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make waits understandable without adding noisy progress theater. The loading UI should expose a small state machine, announce meaningful changes, and always provide a recovery path.
|
||||||
|
|
||||||
|
## Proposed state model
|
||||||
|
|
||||||
|
```text
|
||||||
|
idle
|
||||||
|
resolving_source
|
||||||
|
loading_media
|
||||||
|
buffering
|
||||||
|
retrying
|
||||||
|
ready
|
||||||
|
unavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
State meanings:
|
||||||
|
|
||||||
|
- `resolving_source`: waiting for the episode API/provider result.
|
||||||
|
- `loading_media`: source is known; waiting for manifest/metadata.
|
||||||
|
- `buffering`: metadata exists but playback has stalled.
|
||||||
|
- `retrying`: refreshing the source after a media error.
|
||||||
|
- `unavailable`: all bounded attempts failed.
|
||||||
|
- `ready`: overlay hidden.
|
||||||
|
|
||||||
|
Do not attempt to show a fake percentage. The application does not know enough to estimate completion accurately.
|
||||||
|
|
||||||
|
## Visual behavior
|
||||||
|
|
||||||
|
- Show the spinner immediately to acknowledge the action.
|
||||||
|
- Keep text visually quiet for short waits.
|
||||||
|
- After roughly 3 seconds, show a short status such as “Loading video…” or “Finding a source…”.
|
||||||
|
- After roughly 8-10 seconds, show “This is taking longer than usual” and a Retry action.
|
||||||
|
- On terminal failure, show Retry and Back to details.
|
||||||
|
- Keep episode title/context visible where possible.
|
||||||
|
- Avoid replacing the whole page; recovery stays inside the player.
|
||||||
|
|
||||||
|
## Accessibility behavior
|
||||||
|
|
||||||
|
Use a stable status element inside the player:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
<span data-loading-message>Loading video…</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
- Do not announce every buffering event; debounce announcements to avoid screen-reader noise.
|
||||||
|
- Use `aria-busy="true"` on the player container while a transition is active.
|
||||||
|
- Move focus only for terminal errors, not normal loading.
|
||||||
|
- Give play/pause, mute, fullscreen, seek, and navigation buttons stable accessible names.
|
||||||
|
- Ensure disabled controls expose native `disabled` or an equivalent semantic state.
|
||||||
|
|
||||||
|
## State ownership
|
||||||
|
|
||||||
|
Centralize overlay changes in one module/function. Currently media event handlers manipulate `loading.style.display` directly in several places. Replace scattered writes with a function such as `setPlayerLoadState(state, options)`.
|
||||||
|
|
||||||
|
That function should:
|
||||||
|
|
||||||
|
- Update `data-load-state` on the player root.
|
||||||
|
- Toggle visibility.
|
||||||
|
- Set `aria-busy`.
|
||||||
|
- Update status copy.
|
||||||
|
- Start/cancel the long-wait timer.
|
||||||
|
- Expose Retry only in retryable states.
|
||||||
|
|
||||||
|
## Retry policy
|
||||||
|
|
||||||
|
- Episode payload failure: one ordinary fallback navigation, unless already on that URL.
|
||||||
|
- Media source error: refresh the current mode source once.
|
||||||
|
- Repeated media failure: stop automatic retries and show Retry.
|
||||||
|
- User Retry: force source refresh, reset media element, and reload.
|
||||||
|
- Never create an infinite retry loop.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `templates/components/video_player.gohtml`
|
||||||
|
- `static/player/main.ts`
|
||||||
|
- `static/player/source.ts`
|
||||||
|
- `static/player/video.ts`
|
||||||
|
- `static/player/state.ts`
|
||||||
|
- `static/player/controls.ts`
|
||||||
|
- Player browser-flow tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Loader appears immediately on source change.
|
||||||
|
- Status text appears only after the delay.
|
||||||
|
- Ready metadata hides the loader and clears timers.
|
||||||
|
- Waiting after ready enters buffered/loading state without repeated announcements.
|
||||||
|
- One media error attempts one refresh.
|
||||||
|
- Repeated failure exposes Retry and stops looping.
|
||||||
|
- Retry performs a forced source refresh.
|
||||||
|
- Every icon-only control has an accessible name.
|
||||||
|
- `aria-busy` accurately follows transition state.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Record client-side phase durations without sensitive URLs:
|
||||||
|
|
||||||
|
- Source resolution.
|
||||||
|
- Media metadata wait.
|
||||||
|
- Buffering count and duration.
|
||||||
|
- Retry count.
|
||||||
|
- Terminal unavailable state.
|
||||||
|
|
||||||
|
The existing HLS profiling hook can remain for HLS-specific detail; add source-independent timing for direct media too.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ship the centralized state function and accessible labels first without changing retry counts. Then enable delayed copy and user Retry. This separates semantic/visual regressions from source-refresh behavior changes. Verify with direct media and HLS sources.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- No indefinite spinner exists without text or a recovery action.
|
||||||
|
- A useful status appears after 3 seconds.
|
||||||
|
- Retry appears after the long-wait threshold or a terminal failure.
|
||||||
|
- Player root exposes `aria-busy` during loading.
|
||||||
|
- All icon-only player controls have accessible names.
|
||||||
|
- Retry loops are bounded.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Fix 07: represent recommendation refresh state
|
||||||
|
|
||||||
|
Priority: P1
|
||||||
|
Risk: Medium
|
||||||
|
Primary benefit: Honest background behavior and automatic completion
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
Recommendation computation already runs in the background, which is the right architecture. The cache contract loses an important distinction, however:
|
||||||
|
|
||||||
|
- The user has no usable watchlist signal.
|
||||||
|
- Recommendations are currently being computed.
|
||||||
|
- Computation failed.
|
||||||
|
- Stale recommendations exist while refresh runs.
|
||||||
|
|
||||||
|
On a cold cache, `getCachedTopPicksForYou` starts a goroutine and immediately returns an empty anime slice. The UI therefore renders “No top picks yet—add a few anime” even when the user just added an anime and a roughly ten-second computation is active. Results appear only after a manual reload.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Preserve asynchronous computation while returning an explicit state that lets the UI show the truth and update automatically.
|
||||||
|
|
||||||
|
## Proposed domain model
|
||||||
|
|
||||||
|
Add a typed state to recommendation results:
|
||||||
|
|
||||||
|
```text
|
||||||
|
empty no usable signals after a completed computation
|
||||||
|
refreshing no result yet; computation in progress
|
||||||
|
ready current result available
|
||||||
|
stale previous result shown while refresh runs
|
||||||
|
failed no result and the latest computation failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Include only the minimum supporting metadata needed by the UI, such as `retryAfter` or `updatedAt`. Do not expose internal provider errors.
|
||||||
|
|
||||||
|
## Cache behavior
|
||||||
|
|
||||||
|
### First request, no entry
|
||||||
|
|
||||||
|
1. Create an entry with `refreshing=true`.
|
||||||
|
2. Start the background computation.
|
||||||
|
3. Return `refreshing` with no items.
|
||||||
|
|
||||||
|
### Fresh data
|
||||||
|
|
||||||
|
Return `ready` immediately.
|
||||||
|
|
||||||
|
### Expired data
|
||||||
|
|
||||||
|
1. Preserve the old data.
|
||||||
|
2. Mark refresh in progress.
|
||||||
|
3. Return `stale` with old items.
|
||||||
|
4. Refresh in the background.
|
||||||
|
|
||||||
|
### Watchlist mutation
|
||||||
|
|
||||||
|
Do not delete the cache entry. Mark it stale and start refresh immediately. This preserves existing recommendations while the new profile is computed.
|
||||||
|
|
||||||
|
### Completed empty result
|
||||||
|
|
||||||
|
Store a completed `empty` result so every request does not restart computation.
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
- With stale data: keep serving stale and record refresh failure.
|
||||||
|
- Without data: return `failed` with Retry.
|
||||||
|
- Use bounded retry/backoff; do not restart on every poll.
|
||||||
|
|
||||||
|
## HTMX update flow
|
||||||
|
|
||||||
|
The simplest UI is an outer fragment that polls only while state is `refreshing`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section
|
||||||
|
hx-get="/api/catalog/top-pick"
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-busy="true">
|
||||||
|
<p role="status">Preparing recommendations…</p>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the server returns `ready`, `stale`, `empty`, or `failed`, render markup without the polling trigger. Replacing the polling element naturally stops polling. HTTP status `286` can also stop polling, but removing the trigger from the final fragment is easier to inspect and does not depend on status-specific behavior.
|
||||||
|
|
||||||
|
Use a two-second interval rather than aggressive sub-second polling. The job takes seconds and does not need real-time granularity.
|
||||||
|
|
||||||
|
For the full Top Picks page, use the same state-aware fragment or a small page-specific status endpoint. Avoid duplicating state rules between home and `/top-picks`.
|
||||||
|
|
||||||
|
## Request synchronization
|
||||||
|
|
||||||
|
Ensure one client poll is active at a time. If a poll response can exceed the interval, use an HTMX synchronization policy or a longer interval. Server-side cache locking/singleflight remains authoritative; client synchronization is only a traffic guard.
|
||||||
|
|
||||||
|
## Copy
|
||||||
|
|
||||||
|
- Refreshing with no data: “Preparing recommendations…”
|
||||||
|
- Stale: keep cards visible; optional subtle “Updating…” text.
|
||||||
|
- Completed empty: current “Add a few anime…” guidance.
|
||||||
|
- Failed with no data: “Recommendations could not be prepared.” plus Retry.
|
||||||
|
|
||||||
|
Do not show provider or scoring implementation details to the user.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/anime/recommendations.go`
|
||||||
|
- Recommendation/cache domain types
|
||||||
|
- Recommendation invalidator interface and watchlist integration
|
||||||
|
- `internal/anime/catalog_handler.go`
|
||||||
|
- `templates/index.gohtml`
|
||||||
|
- `templates/top_picks.gohtml`
|
||||||
|
- Recommendation and handler tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Cold cache returns `refreshing`, not `empty`.
|
||||||
|
- Completed empty computation returns `empty` and is cached.
|
||||||
|
- Fresh data returns `ready`.
|
||||||
|
- Expired data returns stale cards and starts exactly one refresh.
|
||||||
|
- Watchlist mutation preserves stale cards and starts refresh immediately.
|
||||||
|
- Failure preserves stale data when available.
|
||||||
|
- Final fragments do not continue polling.
|
||||||
|
- Home and Top Picks use the same state rules.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Track:
|
||||||
|
|
||||||
|
- Refresh started/completed/failed.
|
||||||
|
- Refresh duration.
|
||||||
|
- Ready/stale/refreshing/empty responses.
|
||||||
|
- Poll count per refresh.
|
||||||
|
- Number of refreshes joined or suppressed.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ship the state contract, server rendering, and templates together. During rollout, tolerate missing/zero state as `empty` only for compatibility with old tests; remove that fallback after all call sites are migrated.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- A cold recommendation build never displays the completed-empty message.
|
||||||
|
- Results appear without manual page reload.
|
||||||
|
- Existing cards remain visible during refresh.
|
||||||
|
- Exactly one computation runs per cache key.
|
||||||
|
- Polling stops after a terminal state.
|
||||||
|
- Failure has a visible bounded Retry path.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- [HTMX polling and progress pattern](https://github.com/bigskysoftware/htmx/blob/v2.0.4/www/content/examples/progress-bar.md)
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Fix 08: cache Simulcast discovery
|
||||||
|
|
||||||
|
Priority: P2
|
||||||
|
Risk: Low to medium
|
||||||
|
Primary benefit: Simulcast page latency and provider load
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
`HandleSimulcast` first calls `LatestAvailableSeason`, which requests the next calendar season from AllAnime. It then calls `GetSimulcast`, which requests the selected season. These calls are serial.
|
||||||
|
|
||||||
|
The audit measured 1.38s before the page was ready. Seasonal data changes slowly relative to page views, so repeated synchronous provider requests are unnecessary.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Cache seasonal show lists and latest-season discovery, reuse the next-season lookup when it is also the selected season, and serve stale data while refresh occurs.
|
||||||
|
|
||||||
|
## Proposed cache
|
||||||
|
|
||||||
|
Use one shared seasonal cache keyed by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalized season | year
|
||||||
|
```
|
||||||
|
|
||||||
|
The latest-season check should call the same cached seasonal fetch function. If it fetches next season, `GetSimulcast` can reuse that entry rather than issuing another provider request.
|
||||||
|
|
||||||
|
Suggested policy:
|
||||||
|
|
||||||
|
- Fresh TTL: 30 minutes.
|
||||||
|
- Stale window: 6 hours.
|
||||||
|
- Maximum entries: 40 seasons, comfortably covering active navigation without unbounded growth.
|
||||||
|
- Empty next-season result: cache for 10 minutes, because availability can appear around season boundaries.
|
||||||
|
- Provider error: serve stale data when available.
|
||||||
|
|
||||||
|
## Stale-while-refresh behavior
|
||||||
|
|
||||||
|
1. Fresh entry: return immediately.
|
||||||
|
2. Stale entry: return stale immediately and start one background refresh.
|
||||||
|
3. Missing entry: fetch synchronously because there is no usable page data.
|
||||||
|
4. Failed missing fetch: preserve current error behavior.
|
||||||
|
|
||||||
|
Use singleflight keyed by season/year to collapse concurrent misses.
|
||||||
|
|
||||||
|
## Latest-season caching
|
||||||
|
|
||||||
|
The latest season is a derived value. It can either:
|
||||||
|
|
||||||
|
- Be cached separately for 30-60 minutes, or
|
||||||
|
- Be recomputed cheaply from the shared next-season cache.
|
||||||
|
|
||||||
|
Prefer the second option so there is one source of truth. Around a calendar-season rollover, use a shorter empty-result TTL to discover newly published shows promptly.
|
||||||
|
|
||||||
|
## Data ownership
|
||||||
|
|
||||||
|
Cache provider show values before converting them into template/domain anime values. Clone slices on read so handlers cannot mutate cached storage accidentally.
|
||||||
|
|
||||||
|
An in-memory cache is sufficient initially. Persisting seasonal data in SQLite adds migration and invalidation complexity without evidence that restart cold starts are a major problem.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/anime/discovery.go`
|
||||||
|
- `internal/anime/simulcast.go`
|
||||||
|
- `internal/anime/catalog_handler.go`
|
||||||
|
- Anime module wiring if a cache dependency is separated
|
||||||
|
- Simulcast/cache tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Two requests for the same season call the provider once within TTL.
|
||||||
|
- Latest-season lookup populates the shared seasonal cache.
|
||||||
|
- Concurrent misses collapse into one provider call.
|
||||||
|
- Stale data is served while one refresh runs.
|
||||||
|
- Empty next season uses the shorter TTL.
|
||||||
|
- Different season/year keys do not collide.
|
||||||
|
- Cached slices are not mutable by callers.
|
||||||
|
- Provider failure serves stale data when available.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Track seasonal cache hit, miss, stale-hit, refresh duration, provider error, and empty-next-season result. Do not log full provider payloads.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ship in-memory caching without changing routes or templates. A configuration flag is optional but probably unnecessary because the fallback is simply cache miss behavior.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Warm Simulcast requests do not call AllAnime.
|
||||||
|
- Latest-season and selected-season requests reuse the same cached fetch path.
|
||||||
|
- Warm page readiness is below 500ms locally and trends below 1 second at p75 in production.
|
||||||
|
- Seasonal data refreshes within the chosen TTL.
|
||||||
|
- Cache memory is bounded.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Fix 09: deduplicate canonical episode refreshes
|
||||||
|
|
||||||
|
Priority: P2
|
||||||
|
Risk: Medium
|
||||||
|
Primary benefit: Provider load and cold anime-detail completion
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
The anime detail page loads release information and audio availability as independent HTMX fragments. Both can call `EpisodeService.GetCanonicalEpisodes` at nearly the same time.
|
||||||
|
|
||||||
|
During the cold audit, logs showed two episode refreshes for the same anime starting together. The Jikan layer collapsed part of its own refresh, but provider-ID resolution, availability checks, merging, and cache writes still ran twice.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow independent fragments to remain asynchronous while ensuring only one canonical-episode refresh runs for a given anime and refresh mode.
|
||||||
|
|
||||||
|
## Where to deduplicate
|
||||||
|
|
||||||
|
Keep the existing fast cache checks outside the shared refresh. Only enter request collapsing after the service has decided that provider/Jikan refresh work is required.
|
||||||
|
|
||||||
|
Suggested key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
anime_id | refresh_policy
|
||||||
|
```
|
||||||
|
|
||||||
|
At minimum distinguish ordinary refresh from an explicit force refresh. A force refresh must not silently join a request that is intentionally serving an existing fresh cache entry.
|
||||||
|
|
||||||
|
## Proposed flow
|
||||||
|
|
||||||
|
1. Validate anime input.
|
||||||
|
2. Read the canonical episode cache.
|
||||||
|
3. Return immediately when it is fresh and the caller did not force refresh.
|
||||||
|
4. Enter a keyed singleflight refresh.
|
||||||
|
5. Recheck the cache inside the shared function; another request may have completed between steps 2 and 4.
|
||||||
|
6. Fetch Jikan/provider data once.
|
||||||
|
7. Merge and write the canonical cache once.
|
||||||
|
8. Return cloned result data to all waiting callers.
|
||||||
|
|
||||||
|
The second cache check is important. It prevents back-to-back refreshes caused by requests that both observed the same stale entry before either joined the shared call.
|
||||||
|
|
||||||
|
## Context and cancellation
|
||||||
|
|
||||||
|
Do not let one browser cancellation terminate useful work for all joined callers.
|
||||||
|
|
||||||
|
Recommended policy:
|
||||||
|
|
||||||
|
- Create a service-owned bounded refresh context using a strict timeout.
|
||||||
|
- Preserve tracing/request values when safe, but detach individual request cancellation.
|
||||||
|
- Let each waiting caller stop waiting when its own context is cancelled.
|
||||||
|
- Allow the shared refresh to finish for the cache and remaining callers.
|
||||||
|
- Stop all refreshes during application shutdown through a service lifecycle context if one is available.
|
||||||
|
|
||||||
|
Using the first caller's context directly is simpler but creates a fragile leader/follower relationship: closing one detail fragment can fail the other fragment's refresh.
|
||||||
|
|
||||||
|
## Result ownership
|
||||||
|
|
||||||
|
Canonical episode results contain slices. Return a clone to callers or treat the type as immutable by contract. Do not let one handler mutate data shared with another joined request or cache entry.
|
||||||
|
|
||||||
|
## Error behavior
|
||||||
|
|
||||||
|
- If refresh fails and stale canonical data is allowed by current policy, serve stale as today.
|
||||||
|
- All joined callers should observe the same refresh error/result.
|
||||||
|
- Do not negative-cache provider failures beyond the existing failure/backoff policy.
|
||||||
|
- Preserve failure counters and next-refresh timestamps exactly once.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/episodes/service/service.go`
|
||||||
|
- Optional helper under `internal/episodes/service/`
|
||||||
|
- Episode service constructor/state
|
||||||
|
- Episode service concurrency tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Two concurrent ordinary refreshes call Jikan/provider once.
|
||||||
|
- Cache is rechecked inside the shared refresh.
|
||||||
|
- Force and ordinary refresh policies do not collide incorrectly.
|
||||||
|
- One caller cancellation does not cancel the refresh for another caller.
|
||||||
|
- All waiters receive equivalent cloned results.
|
||||||
|
- Failed refresh records failure once.
|
||||||
|
- Stale fallback behavior remains unchanged.
|
||||||
|
- Race detector passes under concurrent refresh tests.
|
||||||
|
|
||||||
|
Run the focused tests with `-race` because this change introduces shared state.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Track:
|
||||||
|
|
||||||
|
- Refresh owner count.
|
||||||
|
- Joined caller count.
|
||||||
|
- Cache hit after joining/recheck.
|
||||||
|
- Shared refresh duration and result.
|
||||||
|
- Caller cancellation while waiting.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
No route or template changes are required. Ship behind the service boundary, then verify that duplicate `episodes_refresh_start` events for one anime disappear under concurrent fragment loads.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Concurrent release-info and audio-availability loads trigger one canonical refresh.
|
||||||
|
- No additional provider request occurs after the inner cache recheck.
|
||||||
|
- Caller cancellation does not poison unrelated waiters.
|
||||||
|
- Existing stale/failure semantics remain intact.
|
||||||
|
- `go test -race ./internal/episodes/...` passes.
|
||||||
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Fix 10: parallelize watch-data assembly
|
||||||
|
|
||||||
|
Priority: P2
|
||||||
|
Risk: Medium
|
||||||
|
Primary benefit: Watch-page server response time
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
After anime metadata and canonical episodes are available, `BuildWatchData` performs several independent operations serially:
|
||||||
|
|
||||||
|
- Stream/mode-source resolution.
|
||||||
|
- Watch progress and watchlist status lookup.
|
||||||
|
- Season/relation loading.
|
||||||
|
- Skip-segment lookup.
|
||||||
|
|
||||||
|
The audited watch page spent roughly 0.46s in the server on the first tested request. Media startup was the larger problem, but shortening server work makes the shell and source token available sooner.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Run independent work concurrently without hiding errors, breaking cancellation, or increasing provider load uncontrollably.
|
||||||
|
|
||||||
|
## Dependency boundary
|
||||||
|
|
||||||
|
Keep these steps serial:
|
||||||
|
|
||||||
|
1. Fetch anime metadata.
|
||||||
|
2. Ensure the local anime row.
|
||||||
|
3. Build title candidates.
|
||||||
|
4. Load canonical episodes.
|
||||||
|
5. Resolve requested mode/fallback policy from episode availability.
|
||||||
|
|
||||||
|
After step 5, these operations can run concurrently:
|
||||||
|
|
||||||
|
- Resolve stream source for the requested/fallback mode.
|
||||||
|
- Load watch progress/status.
|
||||||
|
- Load seasons/relations.
|
||||||
|
- Load skip-segment overrides.
|
||||||
|
|
||||||
|
Build the final payload only after all required results have joined.
|
||||||
|
|
||||||
|
## Error classification
|
||||||
|
|
||||||
|
Not every operation has the same severity:
|
||||||
|
|
||||||
|
- Stream resolution: required for playable success; preserve current no-stream error behavior.
|
||||||
|
- Progress lookup: user-state concern; decide whether failure should fail the page or render from zero. Do not silently change existing semantics.
|
||||||
|
- Seasons/relations: optional enhancement; current code already logs and returns an empty list on failure.
|
||||||
|
- Skip segments: optional enhancement; current code logs and continues.
|
||||||
|
|
||||||
|
Represent these policies explicitly instead of relying on whichever goroutine returns first.
|
||||||
|
|
||||||
|
## Concurrency mechanism
|
||||||
|
|
||||||
|
Use a small fixed group of goroutines tied to the request context. Four operations are enough; do not introduce a generic worker pool.
|
||||||
|
|
||||||
|
Each goroutine writes only to its own result variable. Join before reading any result. Run the race detector.
|
||||||
|
|
||||||
|
If using an error group, do not let an optional seasons error cancel required stream resolution. Either:
|
||||||
|
|
||||||
|
- Handle optional errors inside their goroutines and return nil, or
|
||||||
|
- Use separate result wrappers and join all operations before applying severity rules.
|
||||||
|
|
||||||
|
## Provider pressure
|
||||||
|
|
||||||
|
Stream resolution and season/relation loading may both touch external systems when caches are cold. Concurrency can reduce latency but increase burst pressure. Mitigations:
|
||||||
|
|
||||||
|
- Land Fix 05 source caching and Fix 09 deduplication first where practical.
|
||||||
|
- Rely on existing Jikan rate limiting/cache behavior.
|
||||||
|
- Keep concurrency bounded to the fixed set above.
|
||||||
|
- Measure upstream retry/rate-limit events before and after rollout.
|
||||||
|
|
||||||
|
## Cancellation
|
||||||
|
|
||||||
|
- Request cancellation should stop all request-owned operations.
|
||||||
|
- Background warm-up should remain separately bounded and should not inherit an already cancelled context.
|
||||||
|
- Do not detach operations that are useful only for the current response.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/playback/watch_data.go`
|
||||||
|
- Playback service tests
|
||||||
|
- Optional observability timing helpers
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- All independent functions start before the slowest one completes.
|
||||||
|
- Payload matches the previous serial implementation.
|
||||||
|
- Required stream failure produces the same page error.
|
||||||
|
- Optional season/segment failures continue with empty data and log once.
|
||||||
|
- Request cancellation stops outstanding request work.
|
||||||
|
- No result variable race occurs.
|
||||||
|
- Existing mode fallback remains correct.
|
||||||
|
|
||||||
|
Use deterministic fake functions/channels rather than timing-sensitive sleeps in concurrency tests.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Add spans/timers for:
|
||||||
|
|
||||||
|
- Anime metadata.
|
||||||
|
- Canonical episodes.
|
||||||
|
- Stream resolution.
|
||||||
|
- Progress lookup.
|
||||||
|
- Seasons lookup.
|
||||||
|
- Segment lookup.
|
||||||
|
- Total `BuildWatchData`.
|
||||||
|
|
||||||
|
The goal is to confirm that total time approaches the slowest parallel branch rather than the sum of all branches.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Refactor with behavior-preserving tests first. Ship after source caching if possible. Compare total watch-data duration, upstream request count, and rate-limit errors.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Independent branches overlap in tests and tracing.
|
||||||
|
- Watch-page data is behaviorally identical.
|
||||||
|
- Optional failures retain current fallback behavior.
|
||||||
|
- Server watch-page duration decreases without increased provider error rate.
|
||||||
|
- `go test -race ./internal/playback/...` passes.
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Fix 11: make watchlist updates local-first
|
||||||
|
|
||||||
|
Priority: P2
|
||||||
|
Risk: Low to medium
|
||||||
|
Primary benefit: Existing-anime watchlist mutation latency
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
`watchlistService.UpdateEntry` calls `GetAnimeByID` before entering the transaction for every update. It later checks whether the anime row already exists.
|
||||||
|
|
||||||
|
This means changing an existing watchlist entry can wait on Jikan even though all required anime data is already stored locally. During the audit, adding an uncached anime took roughly 0.59s on the server. The frontend hides much of this with optimistic UI, but unnecessary network dependency still increases failure and rollback risk.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Existing anime rows: update watchlist state using SQLite only.
|
||||||
|
- Missing anime rows: fetch metadata, insert/upsert anime, then create the watchlist entry.
|
||||||
|
- Preserve atomic watchlist writes and optimistic client rollback.
|
||||||
|
|
||||||
|
## Proposed flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
validate request
|
||||||
|
-> read anime row
|
||||||
|
-> exists: skip Jikan
|
||||||
|
-> missing: fetch Jikan metadata
|
||||||
|
-> transaction
|
||||||
|
-> ensure anime row when fetched
|
||||||
|
-> read existing watchlist progress
|
||||||
|
-> upsert watchlist entry
|
||||||
|
-> invalidate/start recommendation refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transaction strategy
|
||||||
|
|
||||||
|
Perform the initial existence check outside the write transaction so a slow network request never holds a SQLite transaction open.
|
||||||
|
|
||||||
|
Inside the transaction:
|
||||||
|
|
||||||
|
- Recheck or use `INSERT ... ON CONFLICT` when metadata was fetched; another request may have inserted the anime meanwhile.
|
||||||
|
- Preserve existing progress fields exactly as the current implementation does.
|
||||||
|
- Upsert the watchlist entry atomically.
|
||||||
|
|
||||||
|
The preflight read is an optimization hint, not an integrity guarantee. Database constraints and the transaction remain authoritative.
|
||||||
|
|
||||||
|
## Missing-anime failure
|
||||||
|
|
||||||
|
If the anime row is missing and Jikan fetch fails, return a clear service error before attempting a foreign-key-invalid watchlist insert. The client should roll back its optimistic state and show the existing failure toast.
|
||||||
|
|
||||||
|
Do not create placeholder anime rows unless a separate product decision defines acceptable placeholder title/image semantics. The current schema requires meaningful non-null anime data.
|
||||||
|
|
||||||
|
## Existing-anime refresh policy
|
||||||
|
|
||||||
|
Skipping Jikan means local metadata may age. That is a separate concern from changing watchlist status. If metadata refresh is needed:
|
||||||
|
|
||||||
|
- Queue/best-effort refresh in the background after the mutation, or
|
||||||
|
- Let existing catalog/detail refresh paths maintain metadata.
|
||||||
|
|
||||||
|
Do not keep the user action blocked solely to refresh display metadata.
|
||||||
|
|
||||||
|
## Recommendation invalidation
|
||||||
|
|
||||||
|
Invalidate or start recommendation refresh only after the transaction commits. Integrate with Fix 07 so existing recommendations become stale and refresh begins immediately.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/watchlist/service.go`
|
||||||
|
- Watchlist repository interface only if a more direct existence query is added
|
||||||
|
- Watchlist service tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Existing anime update does not call Jikan.
|
||||||
|
- Missing anime fetches Jikan once and inserts anime before watchlist entry.
|
||||||
|
- Concurrent missing-anime updates remain correct under conflict.
|
||||||
|
- Missing anime plus provider failure returns a clear error and writes nothing.
|
||||||
|
- Existing progress is preserved.
|
||||||
|
- Recommendation invalidation occurs only after commit.
|
||||||
|
- Transaction failure does not invalidate recommendations.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Track local-hit versus metadata-fetch update paths and mutation duration. This should make it obvious whether most watchlist actions are DB-only.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
This is an internal service refactor with no contract change. Preserve the existing API and optimistic frontend. Verify mutation latency and rollback rate after release.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Status changes for known anime issue no external request.
|
||||||
|
- Missing anime is inserted safely before the watchlist entry.
|
||||||
|
- Failed metadata fetch writes nothing.
|
||||||
|
- Existing progress remains unchanged.
|
||||||
|
- Recommendation invalidation happens after a successful commit only.
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Fix 12: reduce the Reviews payload
|
||||||
|
|
||||||
|
Priority: P2
|
||||||
|
Risk: Medium
|
||||||
|
Primary benefit: Initial HTML transfer, DOM size, and scanability
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
The audited Reviews page took 1.20s and returned about 234KB of initial HTML. Every review body is rendered in full. One long review can dominate the viewport and response size before the user has chosen to read it.
|
||||||
|
|
||||||
|
HTTP compression from Fix 02 will produce a large immediate improvement because review text compresses well, but it will not reduce DOM size, layout work, or the visual wall of text.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Preserve access to full reviews.
|
||||||
|
- Render a compact, useful initial list.
|
||||||
|
- Fetch a full review body only when requested.
|
||||||
|
- Keep pagination/infinite loading intact.
|
||||||
|
- Preserve server-side HTML escaping and accessibility.
|
||||||
|
|
||||||
|
## Recommended design
|
||||||
|
|
||||||
|
Render every review card returned by the upstream page, but render only a bounded preview body initially.
|
||||||
|
|
||||||
|
Each card includes:
|
||||||
|
|
||||||
|
- Existing author/date/score/tags/reactions.
|
||||||
|
- Review preview, for example 900-1200 Unicode characters.
|
||||||
|
- “Read more” button only when truncated.
|
||||||
|
- Stable review ID and source page in the expansion request.
|
||||||
|
|
||||||
|
When expanded, HTMX requests a server-rendered full-body fragment and swaps only that card's body. The full body can include a “Collapse” action that restores the preview locally or requests the preview fragment.
|
||||||
|
|
||||||
|
Do not use a CSS line clamp alone: it improves appearance but still transfers and lays out the full text.
|
||||||
|
|
||||||
|
## Preview generation
|
||||||
|
|
||||||
|
Generate previews on the server using Unicode runes, not byte slicing. Prefer these rules:
|
||||||
|
|
||||||
|
1. Normalize only presentation-safe whitespace; preserve paragraph intent.
|
||||||
|
2. Stop near the configured limit at a word boundary.
|
||||||
|
3. Add an ellipsis only when truncated.
|
||||||
|
4. Keep the original full review unchanged in the domain/provider cache.
|
||||||
|
5. Return `IsTruncated` explicitly to the template.
|
||||||
|
|
||||||
|
Avoid summarization or rewriting. The preview must be a faithful prefix of the user's review.
|
||||||
|
|
||||||
|
## Full-review endpoint
|
||||||
|
|
||||||
|
One possible route:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /anime/:animeID/reviews/:reviewID?source_page=N
|
||||||
|
```
|
||||||
|
|
||||||
|
The handler:
|
||||||
|
|
||||||
|
1. Validates anime ID, review ID, and source page.
|
||||||
|
2. Loads the relevant Jikan review page through the existing cache.
|
||||||
|
3. Finds the exact review ID.
|
||||||
|
4. Renders a full-review-body fragment.
|
||||||
|
5. Returns `404` if the review is absent instead of guessing another page.
|
||||||
|
|
||||||
|
The source page parameter prevents scanning every upstream page. Do not trust it without validating that the returned review ID matches.
|
||||||
|
|
||||||
|
An even simpler alternative is linking to the existing external review URL. Use that only if leaving the application is acceptable product behavior.
|
||||||
|
|
||||||
|
## Pagination behavior
|
||||||
|
|
||||||
|
Continue rendering every review metadata card from each upstream page, so no reviews are skipped. The existing intersection sentinel can continue loading subsequent pages. Those appended cards should also use previews.
|
||||||
|
|
||||||
|
Prevent repeated sentinel requests with the current `once` behavior and preserve an explicit failure state if the next page cannot load.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Use a real button for Read more/Collapse.
|
||||||
|
- Set `aria-expanded` and `aria-controls` against a stable review-body ID.
|
||||||
|
- After expansion, keep focus on the same control or move it only if necessary.
|
||||||
|
- Do not hide spoiler labels when truncating.
|
||||||
|
- Expanded text remains ordinary readable HTML text, not a modal.
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
- Initial and expansion handlers should use the existing Jikan cache.
|
||||||
|
- Do not add shared caching of user-specific page chrome.
|
||||||
|
- Compression applies to both full pages and fragments.
|
||||||
|
- A small server-side preview computation needs no separate cache unless profiling proves otherwise.
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `internal/anime/reviews_handler.go`
|
||||||
|
- `internal/anime/service.go` or a presentation mapper
|
||||||
|
- Review display/domain types
|
||||||
|
- `templates/reviews.gohtml`
|
||||||
|
- Anime handler registration
|
||||||
|
- Review handler/template tests
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Short review renders fully without Read more.
|
||||||
|
- Long review truncates at a valid Unicode/word boundary.
|
||||||
|
- Preview is a faithful prefix.
|
||||||
|
- Full endpoint returns the exact review by ID and page.
|
||||||
|
- Wrong review/page combination returns `404`.
|
||||||
|
- HTMX fragment contains full escaped text.
|
||||||
|
- Appended pagination cards also use previews.
|
||||||
|
- Spoiler/preliminary labels and reactions remain intact.
|
||||||
|
- `aria-expanded`/`aria-controls` are correct.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Track initial Reviews response bytes, server duration, expansion requests, and next-page failures. Do not log review text.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Ship HTTP compression first.
|
||||||
|
2. Add preview fields and templates behind a conservative preview length.
|
||||||
|
3. Add full expansion endpoint and browser verification.
|
||||||
|
4. Adjust preview length from real usage, not solely byte targets.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Initial uncompressed Reviews HTML is below 100KB for the audited anime.
|
||||||
|
- Full review text remains available on demand.
|
||||||
|
- No review is skipped because of previewing.
|
||||||
|
- Expansion requires no full page reload.
|
||||||
|
- Unicode text, spoiler state, and keyboard behavior remain correct.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Frontend performance fix pack
|
||||||
|
|
||||||
|
This folder turns the findings in [`../audit.md`](../audit.md) into implementation-ready plans. Each plan explains the problem, the proposed design, affected code, edge cases, tests, rollout strategy, observability, and acceptance criteria.
|
||||||
|
|
||||||
|
These are planning documents only. No application source has been changed.
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
| Order | Priority | Fix | Expected outcome | Depends on |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | P0 | [Minify frontend bundles](01-minify-frontend-bundles.md) | Smaller JavaScript, especially on the watch page | None |
|
||||||
|
| 2 | P0 | [Compress and cache static responses](02-static-compression-and-caching.md) | Avoid transferring 1.16MB of raw player JavaScript and repeated revalidation | Fix 1 recommended |
|
||||||
|
| 3 | P1 | [Optimize logo and icon assets](03-image-asset-optimization.md) | Remove roughly 500KB of oversized first-load images | None |
|
||||||
|
| 4 | P0 | [Make manual episode changes asynchronous](04-async-episode-navigation.md) | Remove full-page reloads between episodes | None |
|
||||||
|
| 5 | P0 | [Cache and prefetch playback sources](05-playback-source-cache-and-prefetch.md) | Reduce time from player shell to playable video | Fix 4 recommended |
|
||||||
|
| 6 | P1 | [Improve player loading and recovery states](06-player-loading-and-recovery.md) | Make unavoidable waits understandable and accessible | Can ship independently |
|
||||||
|
| 7 | P1 | [Represent recommendation refresh state](07-recommendation-refresh-state.md) | Stop showing a false empty state during background work | None |
|
||||||
|
| 8 | P2 | [Cache Simulcast discovery](08-simulcast-cache.md) | Remove repeated serial provider requests | None |
|
||||||
|
| 9 | P2 | [Deduplicate canonical episode refreshes](09-canonical-episode-singleflight.md) | Prevent duplicate provider work on anime details | None |
|
||||||
|
| 10 | P2 | [Parallelize watch-data assembly](10-parallel-watch-data-assembly.md) | Reduce watch-page server time without changing behavior | Fix 9 recommended |
|
||||||
|
| 11 | P2 | [Make watchlist updates local-first](11-watchlist-local-first-update.md) | Keep existing-anime status changes DB-only | None |
|
||||||
|
| 12 | P2 | [Reduce Reviews payload](12-reviews-payload-reduction.md) | Lower initial HTML size and rendering cost | Fix 2 recommended |
|
||||||
|
|
||||||
|
## Delivery slices
|
||||||
|
|
||||||
|
### Slice A: low-risk transfer wins
|
||||||
|
|
||||||
|
Ship fixes 1-3 together. They do not change product behavior and should be the safest first release. Capture bundle sizes and response headers before and after deployment.
|
||||||
|
|
||||||
|
### Slice B: playback responsiveness
|
||||||
|
|
||||||
|
Ship fix 4 first, then fix 5. Fix 6 can ship with either. Keep ordinary page navigation as a fallback until asynchronous transitions have proven reliable.
|
||||||
|
|
||||||
|
### Slice C: honest background work
|
||||||
|
|
||||||
|
Ship fix 7. It changes recommendation-state semantics, the handler contract, and the rendered UI together.
|
||||||
|
|
||||||
|
### Slice D: provider and payload cleanup
|
||||||
|
|
||||||
|
Ship fixes 8-12 independently. They are smaller optimizations and should not block the P0 work.
|
||||||
|
|
||||||
|
## Shared performance gates
|
||||||
|
|
||||||
|
- Do not replace the current server-rendered architecture with a client-side router.
|
||||||
|
- Keep every asynchronous action recoverable through an ordinary URL or retry action.
|
||||||
|
- Bound all caches by item count and TTL.
|
||||||
|
- Never cache user-specific HTML in shared static caches.
|
||||||
|
- Preserve request cancellation through `context.Context` and `AbortController`.
|
||||||
|
- Add measurements before optimizing so regressions are visible.
|
||||||
|
- Validate on a throttled connection as well as localhost; localhost hides transfer and network latency.
|
||||||
|
|
||||||
|
## Suggested release targets
|
||||||
|
|
||||||
|
These targets are directional and should be confirmed under representative production conditions:
|
||||||
|
|
||||||
|
- Home catalog ready: under 1 second at p75.
|
||||||
|
- Search results after debounce: under 500ms at p75.
|
||||||
|
- Watch page shell: under 800ms at p75.
|
||||||
|
- Warm episode change: under 1.5 seconds to playable at p75.
|
||||||
|
- Cold episode start: under 4 seconds to playable at p75, with a useful status shown after 3 seconds.
|
||||||
|
- Versioned static assets: no revalidation during their one-year cache lifetime.
|
||||||
|
- Reviews initial HTML: under 100KB uncompressed.
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
[tools]
|
[tools]
|
||||||
go = "1.25.7"
|
|
||||||
bun = "1.3.14"
|
bun = "1.3.14"
|
||||||
just = "1.53.0"
|
just = "1.53.0"
|
||||||
golangci-lint = "2.12.2"
|
golangci-lint = "2.12.2"
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,7 +1,7 @@
|
|||||||
# MyAnimeList
|
# MyAnimeList
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" />
|
<img src="/static/assets/logo-128.png" alt="MyAnimeList logo" width="120" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -168,6 +168,17 @@ Configuration is loaded from environment variables, and a local `.env` file is r
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Public Watchlist JSON
|
||||||
|
|
||||||
|
`GET /api/public/users/{user_id}/watchlist` returns a versioned, agent-friendly JSON view of a
|
||||||
|
user's complete watchlist. It includes status summaries, episode progress,
|
||||||
|
added/completed/last-watched dates, and localized titles.
|
||||||
|
|
||||||
|
`GET /watchlist/export` downloads the current user's watchlist as `watchlist.json`.
|
||||||
|
|
||||||
|
This endpoint is intentionally unauthenticated. A deployment that should keep watchlist and
|
||||||
|
playback data private must restrict access to `/api/public/` at the network or reverse-proxy layer.
|
||||||
|
|
||||||
### Repository Map
|
### Repository Map
|
||||||
|
|
||||||
| Path | Responsibility |
|
| Path | Responsibility |
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ dependency monitoring.
|
|||||||
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
|
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
|
||||||
secrets, provider tokens, session data, or production databases to the repository.
|
secrets, provider tokens, session data, or production databases to the repository.
|
||||||
|
|
||||||
|
The read-only `/api/public/users/{user_id}/watchlist` endpoint intentionally exposes watchlist
|
||||||
|
statuses, episode progress, and watchlist dates without authentication. Restrict `/api/public/` at
|
||||||
|
the network or reverse-proxy layer when that data should not be public.
|
||||||
|
|
||||||
## Dependency Security
|
## Dependency Security
|
||||||
|
|
||||||
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
|
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
|
||||||
|
|||||||
159
bun.lock
159
bun.lock
@@ -9,7 +9,6 @@
|
|||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
|
||||||
"@tailwindcss/cli": "^4.3.0",
|
"@tailwindcss/cli": "^4.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
@@ -82,111 +81,111 @@
|
|||||||
|
|
||||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
||||||
|
|
||||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="],
|
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg=="],
|
||||||
|
|
||||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
|
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.73.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ=="],
|
||||||
|
|
||||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
|
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.73.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA=="],
|
||||||
|
|
||||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
|
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.73.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg=="],
|
||||||
|
|
||||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
|
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.73.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
|
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="],
|
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
|
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="],
|
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
|
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.73.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="],
|
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="],
|
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
|
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.73.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
|
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw=="],
|
||||||
|
|
||||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
|
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q=="],
|
||||||
|
|
||||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
|
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.73.0", "", { "os": "none", "cpu": "arm64" }, "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw=="],
|
||||||
|
|
||||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
|
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.73.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w=="],
|
||||||
|
|
||||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="],
|
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.73.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg=="],
|
||||||
|
|
||||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
|
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="],
|
||||||
|
|
||||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||||
|
|
||||||
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
|
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
|
||||||
|
|
||||||
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
|
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
|
||||||
|
|
||||||
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
|
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
|
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
|
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
|
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
|
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
|
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
|
||||||
|
|
||||||
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
|
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
|
||||||
|
|
||||||
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
|
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
|
||||||
|
|
||||||
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
|
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
|
||||||
|
|
||||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||||
|
|
||||||
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
|
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.2", "", { "dependencies": { "@parcel/watcher": "2.5.1", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "enhanced-resolve": "5.21.6", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.2" }, "bin": { "tailwindcss": "./dist/index.mjs" } }, "sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw=="],
|
||||||
|
|
||||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
@@ -198,29 +197,31 @@
|
|||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
"lefthook": ["lefthook@2.1.9", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.9", "lefthook-darwin-x64": "2.1.9", "lefthook-freebsd-arm64": "2.1.9", "lefthook-freebsd-x64": "2.1.9", "lefthook-linux-arm64": "2.1.9", "lefthook-linux-x64": "2.1.9", "lefthook-openbsd-arm64": "2.1.9", "lefthook-openbsd-x64": "2.1.9", "lefthook-windows-arm64": "2.1.9", "lefthook-windows-x64": "2.1.9" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ=="],
|
||||||
|
|
||||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg=="],
|
||||||
|
|
||||||
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ=="],
|
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ=="],
|
||||||
|
|
||||||
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA=="],
|
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA=="],
|
||||||
|
|
||||||
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg=="],
|
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw=="],
|
||||||
|
|
||||||
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g=="],
|
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg=="],
|
||||||
|
|
||||||
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw=="],
|
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w=="],
|
||||||
|
|
||||||
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A=="],
|
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw=="],
|
||||||
|
|
||||||
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg=="],
|
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA=="],
|
||||||
|
|
||||||
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw=="],
|
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA=="],
|
||||||
|
|
||||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
@@ -248,46 +249,48 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
|
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
|
||||||
|
|
||||||
"oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="],
|
"oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="],
|
||||||
|
|
||||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
|
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
|
||||||
|
|
||||||
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ namespace: mal
|
|||||||
images:
|
images:
|
||||||
- name: main
|
- name: main
|
||||||
newName: reg.milasholsting.dk/apps/mal
|
newName: reg.milasholsting.dk/apps/mal
|
||||||
newTag: sha-8fd7c11
|
newTag: sha-2712775
|
||||||
|
|||||||
@@ -111,6 +111,33 @@ func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetJikanCacheStatsCountsRowsAndExpiry(t *testing.T) {
|
||||||
|
sqlDB := newTestCacheDB(t)
|
||||||
|
defer func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
oldest := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
insertCachedResponse(t, sqlDB, "expired", TopAnimeResponse{Data: []Anime{{MalID: 1}}}, oldest)
|
||||||
|
insertCachedResponse(t, sqlDB, "fresh", TopAnimeResponse{Data: []Anime{{MalID: 2}}}, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
stats, err := db.New(sqlDB).GetJikanCacheStats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetJikanCacheStats: %v", err)
|
||||||
|
}
|
||||||
|
if stats.TotalRows != 2 {
|
||||||
|
t.Fatalf("TotalRows = %d, want 2", stats.TotalRows)
|
||||||
|
}
|
||||||
|
if stats.ExpiredRows != 1 {
|
||||||
|
t.Fatalf("ExpiredRows = %d, want 1", stats.ExpiredRows)
|
||||||
|
}
|
||||||
|
if stats.OldestExpiresAtSeconds != oldest.Unix() {
|
||||||
|
t.Fatalf("OldestExpiresAtSeconds = %d, want %d", stats.OldestExpiresAtSeconds, oldest.Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestCacheDB(t *testing.T) *sql.DB {
|
func newTestCacheDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ type Episode struct {
|
|||||||
MalID int `json:"mal_id"`
|
MalID int `json:"mal_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Episode string `json:"episode"`
|
Episode string `json:"episode"`
|
||||||
|
Aired string `json:"aired"`
|
||||||
Filler bool `json:"filler"`
|
Filler bool `json:"filler"`
|
||||||
Recap bool `json:"recap"`
|
Recap bool `json:"recap"`
|
||||||
Images *EpisodeImages `json:"images,omitempty"`
|
Images *EpisodeImages `json:"images,omitempty"`
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AvailableEpisodes struct {
|
type AvailableEpisodes struct {
|
||||||
Sub []string
|
Sub []string
|
||||||
Dub []string
|
Dub []string
|
||||||
Raw []string
|
Raw []string
|
||||||
|
Titles map[int]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
|
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
|
||||||
@@ -30,18 +31,25 @@ func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Contex
|
|||||||
|
|
||||||
sub := episodeNums(append(available.Sub, available.Raw...))
|
sub := episodeNums(append(available.Sub, available.Raw...))
|
||||||
dub := episodeNums(available.Dub)
|
dub := episodeNums(available.Dub)
|
||||||
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
|
return domain.EpisodeAvailability{Sub: sub, Dub: dub, Titles: available.Titles}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
||||||
graphqlQuery := `query($showId: String!) {
|
graphqlQuery := `query($showId: String!, $start: Float!, $end: Float!) {
|
||||||
show(_id: $showId) {
|
show(_id: $showId) {
|
||||||
availableEpisodesDetail
|
availableEpisodesDetail
|
||||||
lastEpisodeInfo
|
}
|
||||||
|
episodeInfos(showId: $showId, episodeNumStart: $start, episodeNumEnd: $end) {
|
||||||
|
episodeIdNum
|
||||||
|
notes
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
|
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{
|
||||||
|
"showId": showID,
|
||||||
|
"start": 1,
|
||||||
|
"end": 100000,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AvailableEpisodes{}, err
|
return AvailableEpisodes{}, err
|
||||||
}
|
}
|
||||||
@@ -61,10 +69,25 @@ func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID stri
|
|||||||
return AvailableEpisodes{}, errors.New("invalid detail")
|
return AvailableEpisodes{}, errors.New("invalid detail")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
titles := map[int]string{}
|
||||||
|
infos, _ := data["episodeInfos"].([]any)
|
||||||
|
for _, value := range infos {
|
||||||
|
info, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
number := numericInt(info["episodeIdNum"])
|
||||||
|
title := plainText(stringValue(info["notes"]))
|
||||||
|
if number > 0 && title != "" {
|
||||||
|
titles[number] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return AvailableEpisodes{
|
return AvailableEpisodes{
|
||||||
Sub: stringsFrom(detail["sub"]),
|
Sub: stringsFrom(detail["sub"]),
|
||||||
Dub: stringsFrom(detail["dub"]),
|
Dub: stringsFrom(detail["dub"]),
|
||||||
Raw: stringsFrom(detail["raw"]),
|
Raw: stringsFrom(detail["raw"]),
|
||||||
|
Titles: titles,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type AllAnimeProvider struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
utlsClient *http.Client
|
utlsClient *http.Client
|
||||||
extractor *providerExtractor
|
extractor *providerExtractor
|
||||||
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAllAnimeProvider() *AllAnimeProvider {
|
func NewAllAnimeProvider() *AllAnimeProvider {
|
||||||
@@ -38,6 +39,7 @@ func NewAllAnimeProvider() *AllAnimeProvider {
|
|||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
extractor: newProviderExtractor(),
|
extractor: newProviderExtractor(),
|
||||||
|
baseURL: allAnimeBaseURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +47,16 @@ func (c *AllAnimeProvider) Name() string {
|
|||||||
return "AllAnime"
|
return "AllAnime"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) apiBaseURL() string {
|
||||||
|
if c.baseURL != "" {
|
||||||
|
return c.baseURL
|
||||||
|
}
|
||||||
|
return allAnimeBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||||
showID := c.showID(ctx, animeID, titleCandidates, mode)
|
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
||||||
if showID == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +98,7 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
|||||||
return nil, fmt.Errorf("marshal graphql payload: %w", err)
|
return nil, fmt.Errorf("marshal graphql payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBaseURL()+"/api", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create graphql request: %w", err)
|
return nil, fmt.Errorf("create graphql request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,6 +250,29 @@ func TestBuildStreamSource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetStreamsRequiresExactMalIDMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"wrong-show","malId":"1","name":"Wrong Anime"}]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("GetStreams should not fetch episode sources without an exact MAL ID match")
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.GetStreams(context.Background(), 62076, []string{"Super no Ura de Yani Suu Futari"}, "1", "sub")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "show not found") {
|
||||||
|
t.Fatalf("GetStreams() error = %v, want show not found", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
|
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,80 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
aaEpoch = "4128"
|
||||||
|
aaBuildID = "9"
|
||||||
|
aaKeyAHex = "b1a9a4d051988f1b1b12dbb747439d9bd64b09ea17835600a7eaa4de87c1ad87"
|
||||||
|
aaKeyB64 = "k7DLdv5SGiuEyGUtcncl5wQOR7r4aenLfDV3AOBKlAU="
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||||
|
aaCryptoVersion byte = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func xorKey() ([]byte, error) {
|
||||||
|
keyA, err := hex.DecodeString(aaKeyAHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode key_a: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyB, err := base64.StdEncoding.DecodeString(aaKeyB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode key_b: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = keyA[i] ^ keyB[i]
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAALease(queryHash string) (string, error) {
|
||||||
|
aesKey, err := xorKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := (time.Now().UnixMilli() / 300_000) * 300_000
|
||||||
|
|
||||||
|
ivSeed := fmt.Sprintf("%s:%s:%s:%d", aaEpoch, aaBuildID, queryHash, ts)
|
||||||
|
ivHash := sha256.Sum256([]byte(ivSeed))
|
||||||
|
iv := ivHash[:12]
|
||||||
|
|
||||||
|
payload := fmt.Sprintf(`{"v":%d,"ts":%d,"epoch":%s,"buildId":"%s","qh":"%s"}`, aaCryptoVersion, ts, aaEpoch, aaBuildID, queryHash)
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create gcm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nil, iv, []byte(payload), nil)
|
||||||
|
|
||||||
|
envelope := append([]byte{aaCryptoVersion}, iv...)
|
||||||
|
envelope = append(envelope, ciphertext...)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(envelope), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type aesCandidate struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
func decryptTobeparsed(encoded string) ([]byte, error) {
|
func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -29,30 +93,39 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
|
|||||||
iv := raw[1:13]
|
iv := raw[1:13]
|
||||||
cipherText := raw[13 : len(raw)-16]
|
cipherText := raw[13 : len(raw)-16]
|
||||||
|
|
||||||
for _, keyStr := range aesKeys {
|
candidates := make([]aesCandidate, 0, len(aesKeys)+1)
|
||||||
key := sha256.Sum256([]byte(keyStr))
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key[:])
|
xorK, err := xorKey()
|
||||||
|
if err == nil {
|
||||||
|
candidates = append(candidates, aesCandidate{key: xorK})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyStr := range aesKeys {
|
||||||
|
k := sha256.Sum256([]byte(keyStr))
|
||||||
|
candidates = append(candidates, aesCandidate{key: k[:]})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
block, err := aes.NewCipher(candidate.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if version == 1 {
|
if version == 1 {
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err == nil {
|
||||||
|
combined := append(append([]byte{}, cipherText...), raw[len(raw)-16:]...)
|
||||||
|
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
||||||
|
if openErr == nil && json.Valid(plainText) {
|
||||||
|
return plainText, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plainText := tryDecryptCTR(block, iv, cipherText)
|
plainText := tryDecryptCTR(block, iv, cipherText)
|
||||||
if json.Valid(plainText) {
|
if json.Valid(plainText) {
|
||||||
return plainText, nil
|
return plainText, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err == nil {
|
|
||||||
tag := raw[len(raw)-16:]
|
|
||||||
combined := append(append([]byte{}, cipherText...), tag...)
|
|
||||||
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
|
||||||
if openErr == nil && json.Valid(plainText) {
|
|
||||||
return plainText, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("decryption failed")
|
return nil, errors.New("decryption failed")
|
||||||
@@ -159,6 +232,10 @@ func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]a
|
|||||||
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
|
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := parsed["data"]; ok {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
|
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
|
||||||
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,17 +150,17 @@ func TestGraphqlRequestWithHash_Plain(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
utlsClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodPost {
|
||||||
t.Errorf("method = %q, want GET", req.Method)
|
t.Errorf("method = %q, want POST", req.Method)
|
||||||
}
|
|
||||||
if !strings.Contains(req.URL.String(), episodeQueryHash) {
|
|
||||||
t.Errorf("url should contain hash, got %q", req.URL.String())
|
|
||||||
}
|
}
|
||||||
if req.Header.Get("Referer") != allAnimeReferer {
|
if req.Header.Get("Referer") != allAnimeReferer {
|
||||||
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||||
}
|
}
|
||||||
|
if req.Header.Get("x-build-id") != "9" {
|
||||||
|
t.Errorf("x-build-id = %q", req.Header.Get("x-build-id"))
|
||||||
|
}
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -190,7 +190,7 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
|
|||||||
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
|
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
|
||||||
|
|
||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
utlsClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
|
||||||
}),
|
}),
|
||||||
@@ -205,7 +205,11 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
|
|||||||
t.Fatalf("graphqlRequestWithHash: %v", err)
|
t.Fatalf("graphqlRequestWithHash: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sources := nestedSlice(result, "episode", "sourceUrls")
|
ep, ok := result["episode"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("result missing episode key")
|
||||||
|
}
|
||||||
|
sources := nestedSlice(ep, "sourceUrls")
|
||||||
if len(sources) != 1 {
|
if len(sources) != 1 {
|
||||||
t.Fatalf("got %d sources, want 1", len(sources))
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
}
|
}
|
||||||
@@ -215,7 +219,7 @@ func TestGraphqlRequestWithHash_Non200(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
utlsClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
}),
|
}),
|
||||||
@@ -235,7 +239,7 @@ func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
utlsClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||||
}),
|
}),
|
||||||
@@ -258,12 +262,6 @@ func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
|
|||||||
|
|
||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
t.Error("fallback POST should not be called")
|
|
||||||
return nil, nil
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
utlsClient: &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||||
}),
|
}),
|
||||||
@@ -292,15 +290,15 @@ func TestGetEpisodeSources_FallbackPost(t *testing.T) {
|
|||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
req.Body.Close()
|
||||||
|
if strings.Contains(string(body), `"extensions":`) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||||
|
}
|
||||||
fallbackCalled = true
|
fallbackCalled = true
|
||||||
return mockStringResponse(http.StatusOK, sourceResponse), nil
|
return mockStringResponse(http.StatusOK, sourceResponse), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
utlsClient: &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
extractor: newProviderExtractor(),
|
extractor: newProviderExtractor(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,11 +323,6 @@ func TestGetEpisodeSources_BothFail(t *testing.T) {
|
|||||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
utlsClient: &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
extractor: newProviderExtractor(),
|
extractor: newProviderExtractor(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,17 +336,19 @@ func TestGetAvailableEpisodes(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
body string
|
body string
|
||||||
wantSub int
|
wantSub int
|
||||||
wantDub int
|
wantDub int
|
||||||
wantErr bool
|
wantTitle string
|
||||||
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "sub and dub available",
|
name: "sub and dub available",
|
||||||
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`,
|
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]}},"episodeInfos":[{"episodeIdNum":1,"notes":"The Beginning"}]}}`,
|
||||||
wantSub: 3,
|
wantSub: 3,
|
||||||
wantDub: 1,
|
wantDub: 1,
|
||||||
|
wantTitle: "The Beginning",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sub only",
|
name: "sub only",
|
||||||
@@ -394,10 +389,18 @@ func TestGetAvailableEpisodes(t *testing.T) {
|
|||||||
if len(available.Dub) != tt.wantDub {
|
if len(available.Dub) != tt.wantDub {
|
||||||
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
|
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
|
||||||
}
|
}
|
||||||
|
assertEpisodeTitle(t, available, tt.wantTitle)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertEpisodeTitle(t *testing.T, available AvailableEpisodes, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if want != "" && available.Titles[1] != want {
|
||||||
|
t.Errorf("episode 1 title = %q, want %q", available.Titles[1], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearch(t *testing.T) {
|
func TestSearch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -454,11 +457,11 @@ func TestGetStreams_FullSuccess(t *testing.T) {
|
|||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusOK, searchBody), nil
|
body, _ := io.ReadAll(req.Body)
|
||||||
}),
|
req.Body.Close()
|
||||||
},
|
if strings.Contains(string(body), `"search":`) {
|
||||||
utlsClient: &http.Client{
|
return mockStringResponse(http.StatusOK, searchBody), nil
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
}
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -489,12 +492,6 @@ func TestGetStreams_ShowNotFound(t *testing.T) {
|
|||||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
utlsClient: &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
t.Error("should not call episode sources when show not found")
|
|
||||||
return nil, nil
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
extractor: newProviderExtractor(),
|
extractor: newProviderExtractor(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,11 +507,11 @@ func TestGetStreams_NoSources(t *testing.T) {
|
|||||||
provider := &AllAnimeProvider{
|
provider := &AllAnimeProvider{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
|
body, _ := io.ReadAll(req.Body)
|
||||||
}),
|
req.Body.Close()
|
||||||
},
|
if strings.Contains(string(body), `"search":`) {
|
||||||
utlsClient: &http.Client{
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
}
|
||||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
|
|||||||
TranslationType: mode,
|
TranslationType: mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
|
data, err := graphql.Post[searchData](ctx, c.httpClient, c.apiBaseURL()+"/api", searchQuery, vars, graphql.PostOptions{
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
"Referer": allAnimeReferer,
|
"Referer": allAnimeReferer,
|
||||||
"User-Agent": defaultUserAgent,
|
"User-Agent": defaultUserAgent,
|
||||||
@@ -99,36 +99,6 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
|
|
||||||
targetMalIDStr := strconv.Itoa(animeID)
|
|
||||||
fallbackID := ""
|
|
||||||
|
|
||||||
for _, title := range titleCandidates {
|
|
||||||
searchResults, err := c.Search(ctx, title, mode)
|
|
||||||
if err != nil || len(searchResults) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
|
|
||||||
return showID
|
|
||||||
}
|
|
||||||
if fallbackID == "" {
|
|
||||||
fallbackID = searchResults[0].ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackID
|
|
||||||
}
|
|
||||||
|
|
||||||
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
|
|
||||||
for _, res := range searchResults {
|
|
||||||
if res.MalID == targetMalID {
|
|
||||||
return res.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
||||||
for _, mode := range []string{"sub", "dub"} {
|
for _, mode := range []string{"sub", "dub"} {
|
||||||
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
||||||
|
|||||||
164
integrations/playback/allanime/sequels.go
Normal file
164
integrations/playback/allanime/sequels.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
stdhtml "html"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProviderShow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
EnglishName string
|
||||||
|
Description string
|
||||||
|
MalID int
|
||||||
|
Status string
|
||||||
|
Thumbnail string
|
||||||
|
Type string
|
||||||
|
Year int
|
||||||
|
EpisodeCount int
|
||||||
|
SubEpisodes []int
|
||||||
|
DubEpisodes []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) DirectSequels(ctx context.Context, show ProviderShow) ([]string, error) {
|
||||||
|
query := `query($showId: String!) { show(_id: $showId) { relatedShows } }`
|
||||||
|
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": show.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, _ := result["data"].(map[string]any)
|
||||||
|
rawShow, _ := data["show"].(map[string]any)
|
||||||
|
relations, _ := rawShow["relatedShows"].([]any)
|
||||||
|
ids := make([]string, 0, len(relations))
|
||||||
|
for _, raw := range relations {
|
||||||
|
relation, _ := raw.(map[string]any)
|
||||||
|
id, _ := relation["showId"].(string)
|
||||||
|
if relation["relation"] == "sequel" && id != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) GetProviderShow(ctx context.Context, showID string) (ProviderShow, error) {
|
||||||
|
query := `query($showId: String!) { show(_id: $showId) { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } }`
|
||||||
|
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": showID})
|
||||||
|
if err != nil {
|
||||||
|
return ProviderShow{}, err
|
||||||
|
}
|
||||||
|
data, _ := result["data"].(map[string]any)
|
||||||
|
raw, _ := data["show"].(map[string]any)
|
||||||
|
if raw == nil {
|
||||||
|
return ProviderShow{}, fmt.Errorf("allanime: show %s not found", showID)
|
||||||
|
}
|
||||||
|
return providerShowFrom(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) SeasonalShows(ctx context.Context, season string, year int) ([]ProviderShow, error) {
|
||||||
|
const pageSize = 40
|
||||||
|
query := `query($search: SearchInput, $page: Int) { shows(search: $search, limit: 40, page: $page, countryOrigin: ALL) { edges { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } } }`
|
||||||
|
if season == "" {
|
||||||
|
return nil, errors.New("allanime: season is required")
|
||||||
|
}
|
||||||
|
search := map[string]any{
|
||||||
|
"allowAdult": false,
|
||||||
|
"allowUnknown": false,
|
||||||
|
"season": strings.ToUpper(season[:1]) + strings.ToLower(season[1:]),
|
||||||
|
"types": []string{"TV"},
|
||||||
|
"includeTypes": true,
|
||||||
|
}
|
||||||
|
out := make([]ProviderShow, 0)
|
||||||
|
seen := make(map[int]bool)
|
||||||
|
for page := 1; page <= 20; page++ {
|
||||||
|
result, err := c.graphqlRequest(ctx, query, map[string]any{"search": search, "page": page})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, _ := result["data"].(map[string]any)
|
||||||
|
shows, _ := data["shows"].(map[string]any)
|
||||||
|
edges, _ := shows["edges"].([]any)
|
||||||
|
newestYear := 0
|
||||||
|
for _, edge := range edges {
|
||||||
|
raw, _ := edge.(map[string]any)
|
||||||
|
show := providerShowFrom(raw)
|
||||||
|
newestYear = max(newestYear, show.Year)
|
||||||
|
if seen[show.MalID] || !isPlayableSeasonShow(show, year) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[show.MalID] = true
|
||||||
|
out = append(out, show)
|
||||||
|
}
|
||||||
|
if len(edges) < pageSize || (newestYear > 0 && newestYear < year) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlayableSeasonShow(show ProviderShow, year int) bool {
|
||||||
|
return show.Year == year && show.MalID > 0 && show.Type == "TV" && max(len(show.SubEpisodes), len(show.DubEpisodes)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerShowFrom(raw map[string]any) ProviderShow {
|
||||||
|
detail, _ := raw["availableEpisodesDetail"].(map[string]any)
|
||||||
|
season, _ := raw["season"].(map[string]any)
|
||||||
|
return ProviderShow{
|
||||||
|
ID: stringValue(raw["_id"]),
|
||||||
|
Name: stringValue(raw["name"]),
|
||||||
|
EnglishName: stringValue(raw["englishName"]),
|
||||||
|
Description: plainText(stringValue(raw["description"])),
|
||||||
|
MalID: intValue(raw["malId"]),
|
||||||
|
Status: stringValue(raw["status"]),
|
||||||
|
Thumbnail: stringValue(raw["thumbnail"]),
|
||||||
|
Type: stringValue(raw["type"]),
|
||||||
|
Year: numericInt(season["year"]),
|
||||||
|
EpisodeCount: intValue(raw["episodeCount"]),
|
||||||
|
SubEpisodes: episodeNums(stringsFrom(detail["sub"])),
|
||||||
|
DubEpisodes: episodeNums(stringsFrom(detail["dub"])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainText(value string) string {
|
||||||
|
tokenizer := html.NewTokenizer(bytes.NewBufferString(value))
|
||||||
|
var out strings.Builder
|
||||||
|
for {
|
||||||
|
switch tokenizer.Next() {
|
||||||
|
case html.ErrorToken:
|
||||||
|
return out.String()
|
||||||
|
case html.TextToken:
|
||||||
|
text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.Len() > 0 {
|
||||||
|
out.WriteByte(' ')
|
||||||
|
}
|
||||||
|
out.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) string {
|
||||||
|
valueString, _ := value.(string)
|
||||||
|
return valueString
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(value any) int {
|
||||||
|
n, _ := strconv.Atoi(stringValue(value))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericInt(value any) int {
|
||||||
|
if n := intValue(value); n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n, _ := value.(float64)
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
100
integrations/playback/allanime/sequels_test.go
Normal file
100
integrations/playback/allanime/sequels_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDirectSequelsReturnsOnlyPlayableSequels(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"show":{"relatedShows":[
|
||||||
|
{"relation":"sequel","showId":"season-2"},
|
||||||
|
{"relation":"side story","showId":"ova"}
|
||||||
|
]}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewAllAnimeProvider()
|
||||||
|
provider.httpClient = server.Client()
|
||||||
|
provider.baseURL = server.URL
|
||||||
|
providerShow := ProviderShow{ID: "season-1"}
|
||||||
|
|
||||||
|
got, err := provider.DirectSequels(context.Background(), providerShow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DirectSequels: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != "season-2" {
|
||||||
|
t.Fatalf("DirectSequels = %v, want [season-2]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderShowKeepsOnlyPositiveIntegerEpisodes(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"show":{"_id":"season-2","name":"Example Season 2","englishName":"Example Season 2","description":"A useful<br><i>synopsis</i> & summary.","malId":"42","status":"Finished","episodeCount":"2","availableEpisodesDetail":{"sub":["2","1","0","1.5"],"dub":["1"],"raw":[]}}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewAllAnimeProvider()
|
||||||
|
provider.httpClient = server.Client()
|
||||||
|
provider.baseURL = server.URL
|
||||||
|
|
||||||
|
got, err := provider.GetProviderShow(context.Background(), "season-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProviderShow: %v", err)
|
||||||
|
}
|
||||||
|
if got.MalID != 42 || len(got.SubEpisodes) != 2 || len(got.DubEpisodes) != 1 {
|
||||||
|
t.Fatalf("GetProviderShow = %+v", got)
|
||||||
|
}
|
||||||
|
if got.Description != "A useful synopsis & summary." {
|
||||||
|
t.Fatalf("Description = %q", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeasonalShowsReturnsPlayableTVAnime(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var request struct {
|
||||||
|
Variables struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
Search map[string]any `json:"search"`
|
||||||
|
} `json:"variables"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := request.Variables.Search["year"]; ok {
|
||||||
|
t.Fatal("seasonal request must filter normalized years locally")
|
||||||
|
}
|
||||||
|
edges := make([]map[string]any, 0)
|
||||||
|
switch request.Variables.Page {
|
||||||
|
case 1:
|
||||||
|
for range 40 {
|
||||||
|
edges = append(edges, map[string]any{"_id": "empty", "malId": "12", "type": "TV", "availableEpisodesDetail": map[string]any{"sub": []string{}, "dub": []string{}}})
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
edges = append(edges,
|
||||||
|
map[string]any{"_id": "tv", "name": "Summer Show", "malId": "10", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": "2026"}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
|
||||||
|
map[string]any{"_id": "old", "name": "Old Summer Show", "malId": "11", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": 2025}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"shows": map[string]any{"edges": edges}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewAllAnimeProvider()
|
||||||
|
provider.httpClient = server.Client()
|
||||||
|
provider.baseURL = server.URL
|
||||||
|
|
||||||
|
got, err := provider.SeasonalShows(context.Background(), "summer", 2026)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeasonalShows: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].MalID != 10 || got[0].Year != 2026 {
|
||||||
|
t.Fatalf("SeasonalShows = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package allanime
|
package allanime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,11 +173,15 @@ func isHTTPURL(value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||||
|
referer := allAnimeReferer
|
||||||
|
if strings.Contains(strings.ToLower(provider), "yt-mp4") {
|
||||||
|
referer = allAnimeSiteURL
|
||||||
|
}
|
||||||
return StreamSource{
|
return StreamSource{
|
||||||
URL: url,
|
URL: url,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Type: sourceType,
|
Type: sourceType,
|
||||||
Referer: allAnimeReferer,
|
Referer: referer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,28 +245,24 @@ func stringMapValue(item map[string]any, key string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
||||||
req, err := newHashRequest(ctx, showID, episode, mode)
|
req, err := c.newHashRequest(ctx, showID, episode, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create GET request: %w", err)
|
return nil, fmt.Errorf("create POST request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", defaultUserAgent)
|
req.Header.Set("User-Agent", defaultUserAgent)
|
||||||
req.Header.Set("Accept", "*/*")
|
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
|
||||||
req.Header.Set("Accept-Encoding", "identity")
|
|
||||||
req.Header.Set("Referer", allAnimeReferer)
|
req.Header.Set("Referer", allAnimeReferer)
|
||||||
req.Header.Set("Origin", allAnimeOrigin)
|
req.Header.Set("Origin", allAnimeOrigin)
|
||||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
req.Header.Set("x-build-id", "9")
|
||||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
|
||||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
||||||
|
|
||||||
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
|
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute POST request (aaReq)", "read response")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusCode != http.StatusOK {
|
if statusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
|
return nil, fmt.Errorf("POST status %d: %s", statusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := parseGraphQLResponse(respBody, "decode response")
|
parsed, err := parseGraphQLResponse(respBody, "decode response")
|
||||||
@@ -289,15 +290,33 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
|
|||||||
return nil, errors.New("no usable data in response")
|
return nil, errors.New("no usable data in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
func (c *AllAnimeProvider) newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||||
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
|
aaReq, err := makeAALease(episodeQueryHash)
|
||||||
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create aa lease: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
payload := map[string]any{
|
||||||
params.Set("variables", varsJSON)
|
"variables": map[string]any{
|
||||||
params.Set("extensions", extJSON)
|
"showId": showID,
|
||||||
|
"translationType": strings.ToLower(mode),
|
||||||
|
"episodeString": episode,
|
||||||
|
},
|
||||||
|
"extensions": map[string]any{
|
||||||
|
"persistedQuery": map[string]any{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": episodeQueryHash,
|
||||||
|
},
|
||||||
|
"aaReq": aaReq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api", allAnimeBaseURL), bytes.NewReader(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectStreamType(sourceURL string) string {
|
func detectStreamType(sourceURL string) string {
|
||||||
|
|||||||
272
integrations/tvmaze/client.go
Normal file
272
integrations/tvmaze/client.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package tvmaze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
seasonNumberPattern = regexp.MustCompile(`(?i)(?:\bseason\s+(\d+)|\b(\d+)(?:st|nd|rd|th)\s+season)\b`)
|
||||||
|
seasonSuffixPattern = regexp.MustCompile(`(?i)\s+(?:(?:season\s+\d+)|(?:\d+(?:st|nd|rd|th)\s+season))(?:\s+part\s+\d+)?\s*$`)
|
||||||
|
partNumberPattern = regexp.MustCompile(`(?i)\bpart\s+(\d+)\b`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://api.tvmaze.com"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchResult struct {
|
||||||
|
Show show `json:"show"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type show struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type episode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Season int `json:"season"`
|
||||||
|
Airdate string `json:"airdate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
baseURL: baseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Name() string {
|
||||||
|
return "TVmaze"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) {
|
||||||
|
matches := map[int]struct{}{}
|
||||||
|
for _, candidate := range titleSearchCandidates(titleCandidates, 6) {
|
||||||
|
results, err := c.search(ctx, candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
addExactMatches(matches, results, normalizeTitle(candidate))
|
||||||
|
if len(matches) == 1 {
|
||||||
|
for id := range matches {
|
||||||
|
return strconv.Itoa(id), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return "", errors.New("tvmaze: multiple exact show matches")
|
||||||
|
}
|
||||||
|
return "", errors.New("tvmaze: no exact show match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueTitleCandidates(candidates []string, limit int) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
unique := make([]string, 0, min(len(candidates), limit))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
normalized := normalizeTitle(candidate)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
unique = append(unique, candidate)
|
||||||
|
if len(unique) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func addExactMatches(matches map[int]struct{}, results []searchResult, normalizedTitle string) {
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Show.ID <= 0 || normalizeTitle(result.Show.Name) != normalizedTitle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Show.Type != "" && !strings.EqualFold(result.Show.Type, "Animation") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches[result.Show.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime domain.Anime, episodeCount int) (map[int]string, error) {
|
||||||
|
var episodes []episode
|
||||||
|
if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
episodes, err := episodesForAnimeSeason(episodes, anime, episodeCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
titles := make(map[int]string, len(episodes))
|
||||||
|
for i, item := range episodes {
|
||||||
|
title := strings.TrimSpace(item.Name)
|
||||||
|
if title != "" {
|
||||||
|
titles[i+1] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(titles) == 0 {
|
||||||
|
return nil, errors.New("tvmaze: show has no episode titles")
|
||||||
|
}
|
||||||
|
return titles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleSearchCandidates(candidates []string, limit int) []string {
|
||||||
|
expanded := make([]string, 0, len(candidates)*2)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
parent := strings.TrimSpace(seasonSuffixPattern.ReplaceAllString(candidate, ""))
|
||||||
|
if parent != "" && parent != strings.TrimSpace(candidate) {
|
||||||
|
expanded = append(expanded, parent)
|
||||||
|
}
|
||||||
|
expanded = append(expanded, candidate)
|
||||||
|
}
|
||||||
|
return uniqueTitleCandidates(expanded, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func episodesForAnimeSeason(episodes []episode, anime domain.Anime, episodeCount int) ([]episode, error) {
|
||||||
|
seasons := groupEpisodesBySeason(episodes)
|
||||||
|
season := animeSeasonNumber(anime)
|
||||||
|
if season == 0 {
|
||||||
|
season = seasonForYear(seasons, anime.Year)
|
||||||
|
}
|
||||||
|
if season == 0 {
|
||||||
|
season = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := seasons[season]
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return nil, fmt.Errorf("tvmaze: season %d has no episodes", season)
|
||||||
|
}
|
||||||
|
if episodeCount <= 0 || episodeCount >= len(selected) {
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
if animePartNumber(anime) > 1 {
|
||||||
|
return selected[len(selected)-episodeCount:], nil
|
||||||
|
}
|
||||||
|
return selected[:episodeCount], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupEpisodesBySeason(episodes []episode) map[int][]episode {
|
||||||
|
seasons := map[int][]episode{}
|
||||||
|
for _, item := range episodes {
|
||||||
|
if item.Season > 0 {
|
||||||
|
seasons[item.Season] = append(seasons[item.Season], item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seasons
|
||||||
|
}
|
||||||
|
|
||||||
|
func animeSeasonNumber(anime domain.Anime) int {
|
||||||
|
return titleNumber(animeTitles(anime), seasonNumberPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func animePartNumber(anime domain.Anime) int {
|
||||||
|
return titleNumber(animeTitles(anime), partNumberPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleNumber(titles []string, pattern *regexp.Regexp) int {
|
||||||
|
for _, title := range titles {
|
||||||
|
match := pattern.FindStringSubmatch(title)
|
||||||
|
if len(match) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range match[1:] {
|
||||||
|
number, err := strconv.Atoi(value)
|
||||||
|
if err == nil && number > 0 {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func animeTitles(anime domain.Anime) []string {
|
||||||
|
titles := make([]string, 0, 3+len(anime.TitleSynonyms))
|
||||||
|
titles = append(titles, anime.Title, anime.TitleEnglish, anime.TitleJapanese)
|
||||||
|
titles = append(titles, anime.TitleSynonyms...)
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
|
func seasonForYear(seasons map[int][]episode, year int) int {
|
||||||
|
if year <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
matchedSeason := 0
|
||||||
|
for season, episodes := range seasons {
|
||||||
|
if len(episodes) == 0 || !strings.HasPrefix(episodes[0].Airdate, strconv.Itoa(year)+"-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matchedSeason != 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
matchedSeason = season
|
||||||
|
}
|
||||||
|
return matchedSeason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) {
|
||||||
|
var results []searchResult
|
||||||
|
err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results)
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL()+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tvmaze: create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "mal/1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tvmaze: request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("tvmaze: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(target); err != nil {
|
||||||
|
return fmt.Errorf("tvmaze: decode response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) apiURL() string {
|
||||||
|
if c.baseURL != "" {
|
||||||
|
return c.baseURL
|
||||||
|
}
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTitle(value string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||||
|
return unicode.ToLower(r)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, strings.TrimSpace(value))
|
||||||
|
}
|
||||||
107
integrations/tvmaze/client_test.go
Normal file
107
integrations/tvmaze/client_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package tvmaze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveEpisodeProviderIDRequiresExactAnimatedTitle(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/search/shows" {
|
||||||
|
t.Fatalf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"show":{"id":1,"name":"May I Ask for One Final Thing Extra","type":"Animation"}},
|
||||||
|
{"show":{"id":80712,"name":"May I Ask for One Final Thing?","type":"Animation"}},
|
||||||
|
{"show":{"id":99,"name":"May I Ask for One Final Thing?","type":"Scripted"}}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newTestClient(server)
|
||||||
|
id, err := client.ResolveEpisodeProviderID(context.Background(), 59846, []string{"May I Ask for One Final Thing?"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != "80712" {
|
||||||
|
t.Fatalf("id = %q, want 80712", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEpisodeTitlesUsesAiringOrder(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/shows/80712/episodes" {
|
||||||
|
t.Fatalf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"season":1,"number":1,"name":"Wrong season"},
|
||||||
|
{"season":2,"number":1,"name":" First title "},
|
||||||
|
{"season":2,"number":2,"name":"Second title"},
|
||||||
|
{"season":2,"number":3,"name":"Future title"},
|
||||||
|
{"season":3,"number":1,"name":"Another wrong season"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newTestClient(server)
|
||||||
|
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "80712", domain.Anime{Anime: jikan.Anime{
|
||||||
|
Title: "Example 2nd Season",
|
||||||
|
}}, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[int]string{1: "First title", 2: "Second title"}
|
||||||
|
if !reflect.DeepEqual(titles, want) {
|
||||||
|
t.Fatalf("titles = %#v, want %#v", titles, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEpisodeTitlesSelectsSplitCourWithinSeason(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"season":2,"number":1,"name":"Cour one A"},
|
||||||
|
{"season":2,"number":2,"name":"Cour one B"},
|
||||||
|
{"season":2,"number":3,"name":"Cour two A"},
|
||||||
|
{"season":2,"number":4,"name":"Cour two B"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newTestClient(server)
|
||||||
|
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "14459", domain.Anime{Anime: jikan.Anime{
|
||||||
|
Title: "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||||
|
}}, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[int]string{1: "Cour two A", 2: "Cour two B"}
|
||||||
|
if !reflect.DeepEqual(titles, want) {
|
||||||
|
t.Fatalf("titles = %#v, want %#v", titles, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTitleSearchCandidatesUseParentShowName(t *testing.T) {
|
||||||
|
got := titleSearchCandidates([]string{
|
||||||
|
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||||
|
}, 6)
|
||||||
|
want := []string{
|
||||||
|
"Re:Zero kara Hajimeru Isekai Seikatsu",
|
||||||
|
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("candidates = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClient(server *httptest.Server) *Client {
|
||||||
|
return &Client{httpClient: server.Client(), baseURL: server.URL}
|
||||||
|
}
|
||||||
@@ -4,10 +4,64 @@ import (
|
|||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleSimulcast(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
|
||||||
|
"CurrentPath": "/simulcast",
|
||||||
|
"User": server.CurrentUser(c),
|
||||||
|
"SimulcastURL": simulcastURL(c),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleSimulcastContent(c *gin.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
current := calendarSeason(now.Year(), int(now.Month()))
|
||||||
|
latest := h.discoverySvc.LatestAvailableSeason(c.Request.Context(), current)
|
||||||
|
selected := seasonSelection(c.Query("season"), c.Query("year"), current, latest)
|
||||||
|
data, err := h.discoverySvc.GetSimulcast(c.Request.Context(), selected)
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(), "simulcast_fetch_failed", "anime", "", nil, err)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), server.CurrentUserID(c), data.Animes)
|
||||||
|
previous, next := seasonNavigation(selected, 2018, latest)
|
||||||
|
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
|
||||||
|
"_fragment": "simulcast_content",
|
||||||
|
"CurrentPath": "/simulcast",
|
||||||
|
"User": server.CurrentUser(c),
|
||||||
|
"Animes": data.Animes,
|
||||||
|
"Season": data.Season,
|
||||||
|
"SeasonLabel": strings.ToUpper(data.Season[:1]) + data.Season[1:],
|
||||||
|
"Year": data.Year,
|
||||||
|
"SeasonOptions": seasonOptions(2018, latest),
|
||||||
|
"Previous": previous,
|
||||||
|
"Next": next,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulcastURL(c *gin.Context) string {
|
||||||
|
query := url.Values{}
|
||||||
|
if season := c.Query("season"); season != "" {
|
||||||
|
query.Set("season", season)
|
||||||
|
}
|
||||||
|
if year := c.Query("year"); year != "" {
|
||||||
|
query.Set("year", year)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encoded := query.Encode(); encoded != "" {
|
||||||
|
return "/api/simulcast?" + encoded
|
||||||
|
}
|
||||||
|
return "/api/simulcast"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
||||||
"User": server.CurrentUser(c),
|
"User": server.CurrentUser(c),
|
||||||
|
|||||||
64
internal/anime/catalog_handler_test.go
Normal file
64
internal/anime/catalog_handler_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/integrations/playback/allanime"
|
||||||
|
"mal/templates"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type countingSeasonalProvider struct {
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *countingSeasonalProvider) SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error) {
|
||||||
|
p.calls++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimulcastPageDefersProviderFetch(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
provider := &countingSeasonalProvider{}
|
||||||
|
handler := NewAnimeHandler(nil, nil, nil, newSeasonDiscoveryService(provider))
|
||||||
|
renderer, err := templates.ProvideRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvideRenderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.HTMLRender = renderer
|
||||||
|
router.GET("/simulcast", handler.HandleSimulcast)
|
||||||
|
router.GET("/api/simulcast", handler.HandleSimulcastContent)
|
||||||
|
|
||||||
|
page := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(page, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/simulcast?season=winter&year=2024", nil))
|
||||||
|
if page.Code != http.StatusOK {
|
||||||
|
t.Fatalf("page status = %d, want %d", page.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("provider calls during page render = %d, want 0", provider.calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page.Body.String(), `hx-get="/api/simulcast?season=winter&year=2024"`) {
|
||||||
|
t.Fatalf("page did not preserve the selected season in the deferred request:\n%s", page.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(fragment, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/simulcast?season=winter&year=2024", nil))
|
||||||
|
if fragment.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fragment status = %d, want %d", fragment.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("provider calls after fragment render = %d, want 2", provider.calls)
|
||||||
|
}
|
||||||
|
if strings.Contains(fragment.Body.String(), "<!DOCTYPE html>") {
|
||||||
|
t.Fatal("fragment response unexpectedly rendered the full page")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fragment.Body.String(), `id="simulcast-content"`) {
|
||||||
|
t.Fatalf("fragment response is missing its replacement root:\n%s", fragment.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,15 +28,10 @@ type animeEpisodeCountDisplay struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func listedEpisodeCount(episodes []domain.EpisodeData) int {
|
type animeReleaseInfoDisplay struct {
|
||||||
count := 0
|
Count int
|
||||||
for _, episode := range episodes {
|
Label string
|
||||||
if episode.MalID <= 0 || episode.IsRecap {
|
Status string
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
|
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
|
||||||
@@ -56,15 +52,18 @@ func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||||
|
info := h.animeReleaseInfo(ctx, anime, now)
|
||||||
|
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) animeReleaseInfo(ctx context.Context, anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
|
||||||
if h.episodeSvc != nil {
|
if h.episodeSvc != nil {
|
||||||
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
|
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if count := len(episodeList.Episodes); count > 0 {
|
return releaseInfoFromCanonical(anime, episodeList)
|
||||||
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
observability.Warn(
|
observability.Warn(
|
||||||
"anime_episode_availability_count_fetch_failed",
|
"anime_episode_availability_count_fetch_failed",
|
||||||
@@ -78,45 +77,63 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.svc != nil && anime.Airing {
|
return animeInitialReleaseInfo(anime, now)
|
||||||
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
}
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID)
|
func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
|
||||||
if err == nil {
|
info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
|
||||||
if count := listedEpisodeCount(episodes); count > 0 {
|
if count := len(episodeList.Episodes); count > 0 {
|
||||||
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"}
|
info.Count = count
|
||||||
}
|
info.Label = canonicalEpisodeCountLabel(episodeList.Source)
|
||||||
} else {
|
}
|
||||||
observability.Warn(
|
return info
|
||||||
"anime_episode_count_fetch_failed",
|
}
|
||||||
"anime",
|
|
||||||
"",
|
func canonicalEpisodeCountLabel(source string) string {
|
||||||
map[string]any{
|
if source == "jikan_fallback" || source == "legacy_disabled" {
|
||||||
"anime_id": anime.MalID,
|
return "Estimated aired episodes"
|
||||||
},
|
}
|
||||||
err,
|
return "Available episodes"
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
func animeInitialReleaseInfo(anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
|
||||||
|
if isCurrentlyAiring(anime) {
|
||||||
|
return animeReleaseInfoDisplay{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info := animeReleaseInfoDisplay{Status: strings.TrimSpace(anime.Status)}
|
||||||
if anime.Episodes > 0 {
|
if anime.Episodes > 0 {
|
||||||
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
info.Count = anime.Episodes
|
||||||
|
info.Label = "Total episodes"
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
if count := releasedEpisodeCount(anime, now); count > 0 {
|
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||||
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
info.Count = count
|
||||||
|
info.Label = "Estimated aired episodes"
|
||||||
}
|
}
|
||||||
return animeEpisodeCountDisplay{}
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||||
if anime.Episodes > 0 {
|
info := animeInitialReleaseInfo(anime, now)
|
||||||
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trustedAnimeStatus(anime domain.Anime, canonicalEpisodes int) string {
|
||||||
|
if canonicalEpisodes == 0 && isCurrentlyAiring(anime) {
|
||||||
|
return "Not yet aired"
|
||||||
}
|
}
|
||||||
if count := releasedEpisodeCount(anime, now); count > 0 {
|
if status := strings.TrimSpace(anime.Status); status != "" {
|
||||||
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
return status
|
||||||
}
|
}
|
||||||
return animeEpisodeCountDisplay{}
|
if anime.Airing {
|
||||||
|
return "Currently Airing"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrentlyAiring(anime domain.Anime) bool {
|
||||||
|
return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing")
|
||||||
}
|
}
|
||||||
|
|
||||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||||
@@ -203,7 +220,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
episodesCount := animeInitialEpisodeCount(anime, time.Now())
|
releaseInfo := animeInitialReleaseInfo(anime, time.Now())
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
"Anime": anime,
|
"Anime": anime,
|
||||||
@@ -213,8 +230,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|||||||
"WatchlistIDs": watchlistIDs,
|
"WatchlistIDs": watchlistIDs,
|
||||||
"ContinueWatchingEp": ep,
|
"ContinueWatchingEp": ep,
|
||||||
"ContinueWatchingTime": cwSeconds,
|
"ContinueWatchingTime": cwSeconds,
|
||||||
"EpisodesCount": episodesCount.Count,
|
"ReleaseInfo": releaseInfo,
|
||||||
"EpisodesCountLabel": episodesCount.Label,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +281,12 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
|
|||||||
case "statistics":
|
case "statistics":
|
||||||
data, err := h.svc.GetStatistics(ctx, id)
|
data, err := h.svc.GetStatistics(ctx, id)
|
||||||
return data, "anime_statistics", err
|
return data, "anime_statistics", err
|
||||||
case "episode-count":
|
case "episode-count", "release-info":
|
||||||
anime, err := h.svc.GetAnimeByID(ctx, id)
|
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil
|
return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", nil
|
||||||
case "audio-availability":
|
case "audio-availability":
|
||||||
anime, err := h.svc.GetAnimeByID(ctx, id)
|
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
22
internal/anime/discovery.go
Normal file
22
internal/anime/discovery.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/integrations/playback/allanime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type seasonProvider interface {
|
||||||
|
SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeasonDiscoveryService struct {
|
||||||
|
provider seasonProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeasonDiscoveryService(provider *allanime.AllAnimeProvider) *SeasonDiscoveryService {
|
||||||
|
return newSeasonDiscoveryService(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSeasonDiscoveryService(provider seasonProvider) *SeasonDiscoveryService {
|
||||||
|
return &SeasonDiscoveryService{provider: provider}
|
||||||
|
}
|
||||||
@@ -3,17 +3,15 @@ package anime
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnimeHandler struct {
|
type AnimeHandler struct {
|
||||||
svc Service
|
svc Service
|
||||||
watchlistSvc domain.WatchlistService
|
watchlistSvc domain.WatchlistService
|
||||||
episodeSvc domain.EpisodeService
|
episodeSvc domain.EpisodeService
|
||||||
scheduleCache map[string]cachedWeekSchedule
|
discoverySvc *SeasonDiscoveryService
|
||||||
sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
@@ -23,12 +21,12 @@ type Service interface {
|
|||||||
WarmDetailSections(id int)
|
WarmDetailSections(id int)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
|
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService, discoverySvc *SeasonDiscoveryService) *AnimeHandler {
|
||||||
return &AnimeHandler{
|
return &AnimeHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
watchlistSvc: watchlistSvc,
|
watchlistSvc: watchlistSvc,
|
||||||
episodeSvc: episodeSvc,
|
episodeSvc: episodeSvc,
|
||||||
scheduleCache: make(map[string]cachedWeekSchedule),
|
discoverySvc: discoverySvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +61,8 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/search", h.HandleSearch)
|
r.GET("/search", h.HandleSearch)
|
||||||
r.GET("/top-picks", h.HandleTopPicks)
|
r.GET("/top-picks", h.HandleTopPicks)
|
||||||
r.GET("/browse", h.HandleBrowse)
|
r.GET("/browse", h.HandleBrowse)
|
||||||
|
r.GET("/simulcast", h.HandleSimulcast)
|
||||||
|
r.GET("/api/simulcast", h.HandleSimulcastContent)
|
||||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||||
|
|||||||
@@ -115,20 +115,6 @@ func TestReleasedEpisodeCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListedEpisodeCount(t *testing.T) {
|
|
||||||
episodes := []domain.EpisodeData{
|
|
||||||
{MalID: 1, Title: "Episode 1"},
|
|
||||||
{MalID: 2, Title: "Episode 2"},
|
|
||||||
{MalID: 3, Title: "Recap", IsRecap: true},
|
|
||||||
{Title: "missing id"},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := listedEpisodeCount(episodes)
|
|
||||||
if got != 2 {
|
|
||||||
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
||||||
episodeSvc := &stubEpisodeService{
|
episodeSvc := &stubEpisodeService{
|
||||||
episodes: domain.CanonicalEpisodeList{
|
episodes: domain.CanonicalEpisodeList{
|
||||||
@@ -140,7 +126,7 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
MalID: 59970,
|
MalID: 59970,
|
||||||
@@ -160,9 +146,120 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnimeReleaseInfoUsesCanonicalEpisodes(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Source: "AllAnime",
|
||||||
|
Episodes: []domain.CanonicalEpisode{
|
||||||
|
{Number: 1},
|
||||||
|
{Number: 2},
|
||||||
|
{Number: 3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
|
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 59970,
|
||||||
|
Status: "Currently Airing",
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 12,
|
||||||
|
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 3 || got.Label != "Available episodes" || got.Status != "Currently Airing" {
|
||||||
|
t.Fatalf("animeReleaseInfo() = %+v, want 3 available episodes and current status", got)
|
||||||
|
}
|
||||||
|
if episodeSvc.called != 1 {
|
||||||
|
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeReleaseInfoDoesNotCallJikanFallbackAvailable(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Source: "jikan_fallback",
|
||||||
|
Episodes: []domain.CanonicalEpisode{
|
||||||
|
{Number: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
|
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Status: "Currently Airing",
|
||||||
|
Airing: true,
|
||||||
|
}}, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 1 || got.Label != "Estimated aired episodes" {
|
||||||
|
t.Fatalf("animeReleaseInfo() = %+v, want estimated aired episode count", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeReleaseInfoMarksAiringAnimeWithoutCanonicalEpisodesAsNotYetAired(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Source: "jikan_fallback",
|
||||||
|
ReleaseChecked: true,
|
||||||
|
Episodes: []domain.CanonicalEpisode{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
|
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Status: "Currently Airing",
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 6,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 0 || got.Label != "" || got.Status != "Not yet aired" {
|
||||||
|
t.Fatalf("animeReleaseInfo() = %+v, want not-yet-aired status without count", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeEpisodeCountStopsWhenCanonicalEpisodesAreEmpty(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Source: "AllAnime",
|
||||||
|
Episodes: []domain.CanonicalEpisode{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
|
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 12,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 0 || got.Label != "" {
|
||||||
|
t.Fatalf("animeEpisodeCount() = %+v, want empty display", got)
|
||||||
|
}
|
||||||
|
if episodeSvc.called != 1 {
|
||||||
|
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeInitialReleaseInfoDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
|
||||||
|
got := animeInitialReleaseInfo(domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Status: "Currently Airing",
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 6,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 0 || got.Label != "" || got.Status != "" {
|
||||||
|
t.Fatalf("animeInitialReleaseInfo() = %+v, want empty unverified airing display", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
||||||
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
|
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
|
||||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
MalID: 59970,
|
MalID: 59970,
|
||||||
@@ -175,7 +272,7 @@ func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
|
func TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
|
||||||
episodeSvc := &stubEpisodeService{
|
episodeSvc := &stubEpisodeService{
|
||||||
episodes: domain.CanonicalEpisodeList{
|
episodes: domain.CanonicalEpisodeList{
|
||||||
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
|
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
|
||||||
@@ -185,12 +282,13 @@ func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
|
|||||||
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
|
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
|
||||||
MalID: 59970,
|
MalID: 59970,
|
||||||
Airing: true,
|
Airing: true,
|
||||||
|
Status: "Currently Airing",
|
||||||
Episodes: 12,
|
Episodes: 12,
|
||||||
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
||||||
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
if got.Count != 12 || got.Label != "Total episodes" {
|
if got.Count != 0 || got.Label != "" {
|
||||||
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
|
t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
|
||||||
}
|
}
|
||||||
if episodeSvc.called != 0 {
|
if episodeSvc.called != 0 {
|
||||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
|
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
|
||||||
@@ -279,7 +377,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
|||||||
},
|
},
|
||||||
err: tt.err,
|
err: tt.err,
|
||||||
}
|
}
|
||||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||||
|
|
||||||
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
|
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
|
||||||
Anime: jikan.Anime{MalID: 52991},
|
Anime: jikan.Anime{MalID: 52991},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
NewAnimeRepository,
|
NewAnimeRepository,
|
||||||
|
NewSeasonDiscoveryService,
|
||||||
fx.Annotate(
|
fx.Annotate(
|
||||||
NewAnimeService,
|
NewAnimeService,
|
||||||
fx.As(new(Service)),
|
fx.As(new(Service)),
|
||||||
@@ -17,6 +18,7 @@ var Module = fx.Options(
|
|||||||
fx.As(new(domain.AnimeSearchService)),
|
fx.As(new(domain.AnimeSearchService)),
|
||||||
fx.As(new(domain.AnimeDetailsService)),
|
fx.As(new(domain.AnimeDetailsService)),
|
||||||
fx.As(new(domain.AnimePlaybackService)),
|
fx.As(new(domain.AnimePlaybackService)),
|
||||||
|
fx.As(new(domain.RecommendationInvalidator)),
|
||||||
),
|
),
|
||||||
NewAnimeHandler,
|
NewAnimeHandler,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,12 +4,136 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"mal/internal/anime/recommendations"
|
"mal/internal/anime/recommendations"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"maps"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
type recommendationComputeFunc func(context.Context, string, int) (domain.CatalogSectionData, error)
|
||||||
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
|
|
||||||
|
type topPicksCacheKey struct {
|
||||||
|
userID string
|
||||||
|
limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
type topPicksCacheEntry struct {
|
||||||
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
|
data domain.CatalogSectionData
|
||||||
|
updatedAt time.Time
|
||||||
|
hasData bool
|
||||||
|
refreshing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type topPicksCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[topPicksCacheKey]*topPicksCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
const topPicksRefreshTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
func (s *animeService) GetTopPickForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
|
data := s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit)
|
||||||
|
if len(data.Animes) > recommendations.TopPickLimit {
|
||||||
|
data.Animes = data.Animes[:recommendations.TopPickLimit]
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) GetTopPicksForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
|
return s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) fetchTopPicksForYou(ctx context.Context, userID string, limit int) (domain.CatalogSectionData, error) {
|
||||||
|
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) getCachedTopPicksForYou(userID string, limit int) domain.CatalogSectionData {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
if userID == "" {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := topPicksCacheKey{userID: userID, limit: limit}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.topPicksCache.mu.Lock()
|
||||||
|
entry := s.topPicksCache.entries[key]
|
||||||
|
if entry != nil && entry.hasData {
|
||||||
|
data := cloneCatalogSectionData(entry.data)
|
||||||
|
if now.Sub(entry.updatedAt) >= s.topPicksCacheTTL && !entry.refreshing {
|
||||||
|
entry.refreshing = true
|
||||||
|
go s.refreshTopPicksForYou(key)
|
||||||
|
}
|
||||||
|
s.topPicksCache.mu.Unlock()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry == nil {
|
||||||
|
entry = &topPicksCacheEntry{}
|
||||||
|
s.topPicksCache.entries[key] = entry
|
||||||
|
}
|
||||||
|
if !entry.refreshing {
|
||||||
|
entry.refreshing = true
|
||||||
|
go s.refreshTopPicksForYou(key)
|
||||||
|
}
|
||||||
|
s.topPicksCache.mu.Unlock()
|
||||||
|
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) refreshTopPicksForYou(key topPicksCacheKey) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), topPicksRefreshTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, err := s.computeTopPicks(ctx, key.userID, key.limit)
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(ctx,
|
||||||
|
"top_picks_refresh_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{"user_id": key.userID, "limit": key.limit},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
s.topPicksCache.mu.Lock()
|
||||||
|
if entry := s.topPicksCache.entries[key]; entry != nil {
|
||||||
|
entry.refreshing = false
|
||||||
|
}
|
||||||
|
s.topPicksCache.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.topPicksCache.mu.Lock()
|
||||||
|
s.topPicksCache.entries[key] = &topPicksCacheEntry{
|
||||||
|
data: cloneCatalogSectionData(data),
|
||||||
|
updatedAt: time.Now(),
|
||||||
|
hasData: true,
|
||||||
|
}
|
||||||
|
s.topPicksCache.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *animeService) InvalidateTopPicksForUser(userID string) {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
if userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.topPicksCache.mu.Lock()
|
||||||
|
defer s.topPicksCache.mu.Unlock()
|
||||||
|
for key := range s.topPicksCache.entries {
|
||||||
|
if key.userID == userID {
|
||||||
|
delete(s.topPicksCache.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCatalogSectionData(data domain.CatalogSectionData) domain.CatalogSectionData {
|
||||||
|
data.Animes = append([]domain.Anime(nil), data.Animes...)
|
||||||
|
data.ContinueWatching = append(data.ContinueWatching[:0:0], data.ContinueWatching...)
|
||||||
|
if data.WatchlistMap != nil {
|
||||||
|
watchlistMap := make(map[int64]bool, len(data.WatchlistMap))
|
||||||
|
maps.Copy(watchlistMap, data.WatchlistMap)
|
||||||
|
data.WatchlistMap = watchlistMap
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,47 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScoreRecommendationCandidateBuildsRationaleFromProfileMatches(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: map[int]float64{1: 2.0},
|
||||||
|
themes: map[int]float64{10: 1.5},
|
||||||
|
studios: map[int]float64{20: 1.0},
|
||||||
|
demographics: map[int]float64{30: 1.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
|
MalID: 10,
|
||||||
|
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||||
|
Themes: []jikan.NamedEntity{{MalID: 10, Name: "School"}},
|
||||||
|
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
|
||||||
|
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
|
||||||
|
}, 5.0, 0)
|
||||||
|
|
||||||
|
want := []string{"Action", "School", "Production I.G", "Shounen"}
|
||||||
|
if !slices.Equal(candidate.rationale, want) {
|
||||||
|
t.Fatalf("expected profile match rationale %v, got %v", want, candidate.rationale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRecommendationCandidateOmitsRationaleWhenSignalsAreWeak(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: map[int]float64{1: 2.0},
|
||||||
|
themes: map[int]float64{},
|
||||||
|
studios: map[int]float64{},
|
||||||
|
demographics: map[int]float64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
|
MalID: 10,
|
||||||
|
}, 5.0, 0)
|
||||||
|
|
||||||
|
if len(candidate.rationale) != 0 {
|
||||||
|
t.Fatalf("expected no rationale for weak signals, got %v", candidate.rationale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
|
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
|
||||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func rerankRecommendationCandidates(candidates []recommendationCandidate, limit
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
selected = append(selected, domain.Anime{Anime: candidate.anime, RecommendationRationale: candidate.rationale})
|
||||||
features := diversityFeatures(candidate.anime)
|
features := diversityFeatures(candidate.anime)
|
||||||
seen.add(features)
|
seen.add(features)
|
||||||
recent = append(recent, features)
|
recent = append(recent, features)
|
||||||
|
|||||||
@@ -48,9 +48,23 @@ func scoreRecommendationCandidate(
|
|||||||
themeMatches: themes,
|
themeMatches: themes,
|
||||||
studioMatches: studios,
|
studioMatches: studios,
|
||||||
demographicMatches: demos,
|
demographicMatches: demos,
|
||||||
|
rationale: buildRecommendationRationale(profile, candidate),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildRecommendationRationale(profile userTasteProfile, candidate jikan.Anime) []string {
|
||||||
|
rationale := make([]string, 0, 4)
|
||||||
|
rationale = append(rationale, matchedEntityNames(profile.genres, candidate.Genres)...)
|
||||||
|
rationale = append(rationale, matchedEntityNames(profile.themes, candidate.Themes)...)
|
||||||
|
rationale = append(rationale, matchedEntityNames(profile.studios, candidate.Studios)...)
|
||||||
|
rationale = append(rationale, matchedEntityNames(profile.demographics, candidate.Demographics)...)
|
||||||
|
|
||||||
|
if len(rationale) > 4 {
|
||||||
|
return rationale[:4]
|
||||||
|
}
|
||||||
|
return rationale
|
||||||
|
}
|
||||||
|
|
||||||
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
|
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
|
||||||
var score float64
|
var score float64
|
||||||
|
|
||||||
@@ -115,3 +129,22 @@ func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity)
|
|||||||
|
|
||||||
return matches, score
|
return matches, score
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchedEntityNames(weights map[int]float64, entities []jikan.NamedEntity) []string {
|
||||||
|
if len(weights) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, 1)
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.Name == "" || weights[entity.MalID] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, entity.Name)
|
||||||
|
if len(names) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type recommendationCandidate struct {
|
|||||||
themeMatches int
|
themeMatches int
|
||||||
studioMatches int
|
studioMatches int
|
||||||
demographicMatches int
|
demographicMatches int
|
||||||
|
rationale []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type userTasteProfile struct {
|
type userTasteProfile struct {
|
||||||
|
|||||||
164
internal/anime/recommendations_cache_test.go
Normal file
164
internal/anime/recommendations_cache_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/anime/recommendations"
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetTopPicksForYouReturnsEmptyOnCacheMissAndRefreshesInBackground(t *testing.T) {
|
||||||
|
refreshed := make(chan struct{}, 1)
|
||||||
|
svc := NewAnimeService(nil, nil)
|
||||||
|
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||||
|
refreshed <- struct{}{}
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 7}}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPicksForYou cache miss: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Animes) != 0 {
|
||||||
|
t.Fatalf("cache miss animes = %+v, want empty while refresh runs", got.Animes)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRefresh(t, refreshed)
|
||||||
|
|
||||||
|
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPicksForYou cache hit: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Animes) != 1 || got.Animes[0].MalID != 7 {
|
||||||
|
t.Fatalf("cache hit animes = %+v, want anime 7", got.Animes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopPickAndTopPicksShareCache(t *testing.T) {
|
||||||
|
refreshed := make(chan struct{}, 1)
|
||||||
|
limits := make(chan int, 1)
|
||||||
|
svc := NewAnimeService(nil, nil)
|
||||||
|
svc.computeTopPicks = func(_ context.Context, _ string, limit int) (domain.CatalogSectionData, error) {
|
||||||
|
limits <- limit
|
||||||
|
animes := make([]domain.Anime, recommendations.TopPickLimit+1)
|
||||||
|
for i := range animes {
|
||||||
|
animes[i].MalID = i + 1
|
||||||
|
}
|
||||||
|
refreshed <- struct{}{}
|
||||||
|
return domain.CatalogSectionData{Animes: animes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.GetTopPickForYou(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("GetTopPickForYou cache miss: %v", err)
|
||||||
|
}
|
||||||
|
waitForRefresh(t, refreshed)
|
||||||
|
|
||||||
|
carousel, err := svc.GetTopPickForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPickForYou cache hit: %v", err)
|
||||||
|
}
|
||||||
|
if len(carousel.Animes) != recommendations.TopPickLimit {
|
||||||
|
t.Fatalf("carousel animes = %d, want %d", len(carousel.Animes), recommendations.TopPickLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPicksForYou shared cache: %v", err)
|
||||||
|
}
|
||||||
|
if len(all.Animes) != recommendations.TopPickLimit+1 {
|
||||||
|
t.Fatalf("all animes = %d, want %d", len(all.Animes), recommendations.TopPickLimit+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit := <-limits; limit != recommendations.TopPicksLimit {
|
||||||
|
t.Fatalf("computed limit = %d, want %d", limit, recommendations.TopPicksLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTopPicksForYouReturnsStaleDataWhenRefreshFails(t *testing.T) {
|
||||||
|
svc := NewAnimeService(nil, nil)
|
||||||
|
svc.topPicksCacheTTL = time.Nanosecond
|
||||||
|
refreshed := make(chan struct{}, 2)
|
||||||
|
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||||
|
refreshed <- struct{}{}
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 11}}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("prime cache: %v", err)
|
||||||
|
}
|
||||||
|
waitForRefresh(t, refreshed)
|
||||||
|
|
||||||
|
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||||
|
refreshed <- struct{}{}
|
||||||
|
return domain.CatalogSectionData{}, errors.New("provider unavailable")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
|
||||||
|
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stale GetTopPicksForYou: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Animes) != 1 || got.Animes[0].MalID != 11 {
|
||||||
|
t.Fatalf("stale animes = %+v, want anime 11", got.Animes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateTopPicksForUserRefreshesNextRequest(t *testing.T) {
|
||||||
|
refreshed := make(chan struct{}, 2)
|
||||||
|
results := []int{3, 4}
|
||||||
|
|
||||||
|
svc := newPickSvc(refreshed, &results)
|
||||||
|
|
||||||
|
primePickCache(t, svc, refreshed)
|
||||||
|
|
||||||
|
svc.InvalidateTopPicksForUser("user-1")
|
||||||
|
|
||||||
|
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPicksForYou after invalidation: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Animes) != 0 {
|
||||||
|
t.Fatalf("invalidated cache animes = %+v, want empty while refresh runs", got.Animes)
|
||||||
|
}
|
||||||
|
waitForRefresh(t, refreshed)
|
||||||
|
|
||||||
|
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTopPicksForYou refreshed cache: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Animes) != 1 || got.Animes[0].MalID != 4 {
|
||||||
|
t.Fatalf("refreshed animes = %+v, want anime 4", got.Animes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPickSvc(refreshed chan struct{}, results *[]int) *animeService {
|
||||||
|
svc := NewAnimeService(nil, nil)
|
||||||
|
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||||
|
id := (*results)[0]
|
||||||
|
*results = (*results)[1:]
|
||||||
|
refreshed <- struct{}{}
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: id}}}}, nil
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func primePickCache(t *testing.T, svc *animeService, refreshed chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("prime cache: %v", err)
|
||||||
|
}
|
||||||
|
waitForRefresh(t, refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRefresh(t *testing.T, refreshed chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-refreshed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("background refresh did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,10 @@ func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWa
|
|||||||
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||||
return r.queries.GetContinueWatchingEntries(ctx, userID)
|
return r.queries.GetContinueWatchingEntries(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *animeRepository) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||||
|
return r.queries.GetContinueWatchingCarouselEntries(ctx, db.GetContinueWatchingCarouselEntriesParams{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
package anime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"mal/integrations/animeschedule"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type cachedWeekSchedule struct {
|
|
||||||
fetchedAt time.Time
|
|
||||||
value animeschedule.WeekSchedule
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYearWeek(c *gin.Context) (int, int) {
|
|
||||||
year, _ := strconv.Atoi(c.Query("year"))
|
|
||||||
week, _ := strconv.Atoi(c.Query("week"))
|
|
||||||
if year <= 0 || week <= 0 {
|
|
||||||
now := time.Now()
|
|
||||||
y, w := now.ISOWeek()
|
|
||||||
if year <= 0 {
|
|
||||||
year = y
|
|
||||||
}
|
|
||||||
if week <= 0 {
|
|
||||||
week = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return year, week
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleTimezone(c *gin.Context) string {
|
|
||||||
timezone := strings.TrimSpace(c.Query("timezone"))
|
|
||||||
if timezone == "" {
|
|
||||||
return "UTC"
|
|
||||||
}
|
|
||||||
return timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
|
|
||||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
|
||||||
const ttl = 10 * time.Minute
|
|
||||||
|
|
||||||
h.Lock()
|
|
||||||
cached, ok := h.scheduleCache[cacheKey]
|
|
||||||
h.Unlock()
|
|
||||||
|
|
||||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
|
||||||
return cached.value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
|
|
||||||
if err != nil {
|
|
||||||
return animeschedule.WeekSchedule{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Lock()
|
|
||||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
|
||||||
h.Unlock()
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type scheduleDayView struct {
|
|
||||||
DateLabel string
|
|
||||||
WeekdayLabel string
|
|
||||||
Entries []animeschedule.Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
|
|
||||||
start := isoWeekStartMonday(year, week)
|
|
||||||
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
|
|
||||||
out := make([]scheduleDayView, 0, 7)
|
|
||||||
for i, wd := range order {
|
|
||||||
date := start.AddDate(0, 0, i)
|
|
||||||
entries := schedule.Days[wd]
|
|
||||||
sort.SliceStable(entries, func(i, j int) bool {
|
|
||||||
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
|
|
||||||
return entries[i].AirsAt.Before(entries[j].AirsAt)
|
|
||||||
}
|
|
||||||
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
|
|
||||||
})
|
|
||||||
out = append(out, scheduleDayView{
|
|
||||||
DateLabel: strings.ToUpper(date.Format("02 Jan")),
|
|
||||||
WeekdayLabel: wd.String(),
|
|
||||||
Entries: entries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func localTimeMinutes(localTime string) int {
|
|
||||||
for _, layout := range []string{"15:04", "03:04 PM"} {
|
|
||||||
t, err := time.Parse(layout, localTime)
|
|
||||||
if err == nil {
|
|
||||||
return t.Hour()*60 + t.Minute()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isoWeekStartMonday(year int, week int) time.Time {
|
|
||||||
// ISO week 1 is the week with the year's first Thursday in it.
|
|
||||||
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
|
|
||||||
// Move back to Monday
|
|
||||||
offset := int(time.Monday - jan4.Weekday())
|
|
||||||
if offset > 0 {
|
|
||||||
offset -= 7
|
|
||||||
}
|
|
||||||
week1Monday := jan4.AddDate(0, 0, offset)
|
|
||||||
return week1Monday.AddDate(0, 0, (week-1)*7)
|
|
||||||
}
|
|
||||||
|
|
||||||
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
|
|
||||||
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
|
|
||||||
return target.ISOWeek()
|
|
||||||
}
|
|
||||||
@@ -15,10 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type animeService struct {
|
type animeService struct {
|
||||||
jikan *jikan.Client
|
jikan *jikan.Client
|
||||||
repo domain.AnimeRepository
|
repo domain.AnimeRepository
|
||||||
|
topPicksCache *topPicksCache
|
||||||
|
topPicksCacheTTL time.Duration
|
||||||
|
computeTopPicks recommendationComputeFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const continueWatchingCarouselLimit int64 = 24
|
||||||
|
|
||||||
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||||
out := make([]domain.Anime, 0, len(in))
|
out := make([]domain.Anime, 0, len(in))
|
||||||
for _, a := range in {
|
for _, a := range in {
|
||||||
@@ -28,7 +33,14 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
|
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
|
||||||
return &animeService{jikan: jikan, repo: repo}
|
svc := &animeService{
|
||||||
|
jikan: jikan,
|
||||||
|
repo: repo,
|
||||||
|
topPicksCache: &topPicksCache{entries: map[topPicksCacheKey]*topPicksCacheEntry{}},
|
||||||
|
topPicksCacheTTL: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
svc.computeTopPicks = svc.fetchTopPicksForYou
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||||
@@ -56,7 +68,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
|||||||
if userID != "" && section == "Continue" {
|
if userID != "" && section == "Continue" {
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
var err error
|
var err error
|
||||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
cw, err = s.repo.GetContinueWatchingCarouselEntries(gCtx, userID, continueWatchingCarouselLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
|
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
56
internal/anime/service_test.go
Normal file
56
internal/anime/service_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/internal/db"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type catalogRepoStub struct {
|
||||||
|
watchlist []db.GetUserWatchListRow
|
||||||
|
allContinueRows []db.GetContinueWatchingEntriesRow
|
||||||
|
carouselContinueRows []db.GetContinueWatchingEntriesRow
|
||||||
|
carouselLimit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogRepoStub) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
|
||||||
|
return r.watchlist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogRepoStub) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
|
||||||
|
return db.WatchListEntry{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogRepoStub) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||||
|
return r.allContinueRows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogRepoStub) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||||
|
r.carouselLimit = limit
|
||||||
|
return r.carouselContinueRows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCatalogSectionLimitsContinueWatchingCarousel(t *testing.T) {
|
||||||
|
repo := &catalogRepoStub{
|
||||||
|
allContinueRows: make([]db.GetContinueWatchingEntriesRow, 30),
|
||||||
|
carouselContinueRows: []db.GetContinueWatchingEntriesRow{
|
||||||
|
{AnimeID: 30},
|
||||||
|
{AnimeID: 29},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewAnimeService(nil, repo)
|
||||||
|
|
||||||
|
got, err := svc.GetCatalogSection(context.Background(), "user-1", "Continue")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCatalogSection: %v", err)
|
||||||
|
}
|
||||||
|
if repo.carouselLimit != continueWatchingCarouselLimit {
|
||||||
|
t.Fatalf("carousel limit = %d, want %d", repo.carouselLimit, continueWatchingCarouselLimit)
|
||||||
|
}
|
||||||
|
if len(got.ContinueWatching) != len(repo.carouselContinueRows) {
|
||||||
|
t.Fatalf("len(ContinueWatching) = %d, want %d", len(got.ContinueWatching), len(repo.carouselContinueRows))
|
||||||
|
}
|
||||||
|
if got.ContinueWatching[0].AnimeID != 30 || got.ContinueWatching[1].AnimeID != 29 {
|
||||||
|
t.Fatalf("ContinueWatching = %+v, want anime IDs [30 29]", got.ContinueWatching)
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/anime/simulcast.go
Normal file
144
internal/anime/simulcast.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type animeSeason struct {
|
||||||
|
Season string
|
||||||
|
Year int
|
||||||
|
}
|
||||||
|
|
||||||
|
type seasonOption struct {
|
||||||
|
Season string
|
||||||
|
Year int
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasons = []string{"winter", "spring", "summer", "fall"}
|
||||||
|
|
||||||
|
func seasonOptions(firstYear int, latest animeSeason) []seasonOption {
|
||||||
|
options := make([]seasonOption, 0, (latest.Year-firstYear+1)*len(seasons))
|
||||||
|
for year := latest.Year; year >= firstYear; year-- {
|
||||||
|
start := len(seasons) - 1
|
||||||
|
if year == latest.Year {
|
||||||
|
start = seasonIndex(latest.Season)
|
||||||
|
}
|
||||||
|
for i := start; i >= 0; i-- {
|
||||||
|
season := seasons[i]
|
||||||
|
options = append(options, seasonOption{
|
||||||
|
Season: season,
|
||||||
|
Year: year,
|
||||||
|
Label: strings.ToUpper(season[:1]) + season[1:] + " " + strconv.Itoa(year),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func seasonIndex(season string) int {
|
||||||
|
for i, candidate := range seasons {
|
||||||
|
if strings.EqualFold(candidate, season) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func calendarSeason(year, month int) animeSeason {
|
||||||
|
index := (month - 1) / 3
|
||||||
|
if index < 0 || index >= len(seasons) {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
return animeSeason{Season: seasons[index], Year: year}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjacentSeason(season string, year, direction int) animeSeason {
|
||||||
|
index := 0
|
||||||
|
for i, candidate := range seasons {
|
||||||
|
if strings.EqualFold(candidate, season) {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += direction
|
||||||
|
if index < 0 {
|
||||||
|
index = len(seasons) - 1
|
||||||
|
year--
|
||||||
|
} else if index >= len(seasons) {
|
||||||
|
index = 0
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
return animeSeason{Season: seasons[index], Year: year}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seasonNavigation(selected animeSeason, firstYear int, latest animeSeason) (*animeSeason, *animeSeason) {
|
||||||
|
var previous *animeSeason
|
||||||
|
var next *animeSeason
|
||||||
|
if selected.Year > firstYear || selected.Season != "winter" {
|
||||||
|
value := adjacentSeason(selected.Season, selected.Year, -1)
|
||||||
|
previous = &value
|
||||||
|
}
|
||||||
|
value := adjacentSeason(selected.Season, selected.Year, 1)
|
||||||
|
if value.Year < latest.Year || value.Year == latest.Year && seasonIndex(value.Season) <= seasonIndex(latest.Season) {
|
||||||
|
next = &value
|
||||||
|
}
|
||||||
|
return previous, next
|
||||||
|
}
|
||||||
|
|
||||||
|
func seasonSelection(rawSeason, rawYear string, current, latest animeSeason) animeSeason {
|
||||||
|
season := strings.ToLower(strings.TrimSpace(rawSeason))
|
||||||
|
validSeason := slices.Contains(seasons, season)
|
||||||
|
year, err := strconv.Atoi(rawYear)
|
||||||
|
if !validSeason || err != nil || year < 2000 || year > latest.Year || year == latest.Year && seasonIndex(season) > seasonIndex(latest.Season) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return animeSeason{Season: season, Year: year}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeasonDiscoveryService) LatestAvailableSeason(ctx context.Context, current animeSeason) animeSeason {
|
||||||
|
next := adjacentSeason(current.Season, current.Year, 1)
|
||||||
|
shows, err := s.provider.SeasonalShows(ctx, next.Season, next.Year)
|
||||||
|
if err == nil && len(shows) > 0 {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimulcastData struct {
|
||||||
|
Animes []domain.Anime
|
||||||
|
Season string
|
||||||
|
Year int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeasonDiscoveryService) GetSimulcast(ctx context.Context, selected animeSeason) (SimulcastData, error) {
|
||||||
|
shows, err := s.provider.SeasonalShows(ctx, selected.Season, selected.Year)
|
||||||
|
if err != nil {
|
||||||
|
return SimulcastData{}, err
|
||||||
|
}
|
||||||
|
animes := make([]domain.Anime, 0, len(shows))
|
||||||
|
for _, show := range shows {
|
||||||
|
anime := jikan.Anime{
|
||||||
|
MalID: show.MalID,
|
||||||
|
Title: show.Name,
|
||||||
|
TitleEnglish: show.EnglishName,
|
||||||
|
Synopsis: show.Description,
|
||||||
|
Status: show.Status,
|
||||||
|
Type: show.Type,
|
||||||
|
Year: show.Year,
|
||||||
|
Episodes: show.EpisodeCount,
|
||||||
|
}
|
||||||
|
anime.Images.Webp.LargeImageURL = show.Thumbnail
|
||||||
|
animes = append(animes, domain.Anime{Anime: anime})
|
||||||
|
}
|
||||||
|
return SimulcastData{
|
||||||
|
Animes: animes,
|
||||||
|
Season: selected.Season,
|
||||||
|
Year: selected.Year,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
88
internal/anime/simulcast_test.go
Normal file
88
internal/anime/simulcast_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mal/integrations/playback/allanime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type seasonalProviderStub struct {
|
||||||
|
shows map[string][]allanime.ProviderShow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p seasonalProviderStub) SeasonalShows(_ context.Context, season string, year int) ([]allanime.ProviderShow, error) {
|
||||||
|
return p.shows[fmt.Sprintf("%s-%d", season, year)], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjacentSeasonCrossesYearBoundary(t *testing.T) {
|
||||||
|
previous := adjacentSeason("winter", 2026, -1)
|
||||||
|
if previous.Season != "fall" || previous.Year != 2025 {
|
||||||
|
t.Fatalf("previous = %+v", previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := adjacentSeason("fall", 2026, 1)
|
||||||
|
if next.Season != "winter" || next.Year != 2027 {
|
||||||
|
t.Fatalf("next = %+v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeasonSelectionRejectsInvalidValues(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
current := calendarSeason(now.Year(), int(now.Month()))
|
||||||
|
got := seasonSelection("monsoon", "nope", current, current)
|
||||||
|
if got.Season != "summer" || got.Year != 2026 {
|
||||||
|
t.Fatalf("seasonSelection = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeasonSelectionDefaultsToCalendarSeasonWhenNewerSeasonIsAvailable(t *testing.T) {
|
||||||
|
current := animeSeason{Season: "spring", Year: 2026}
|
||||||
|
latest := animeSeason{Season: "summer", Year: 2026}
|
||||||
|
got := seasonSelection("", "", current, latest)
|
||||||
|
if got != current {
|
||||||
|
t.Fatalf("seasonSelection = %+v, want %+v", got, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestAvailableSeasonIncludesPlayableNextSeason(t *testing.T) {
|
||||||
|
provider := seasonalProviderStub{shows: map[string][]allanime.ProviderShow{
|
||||||
|
"summer-2026": {{MalID: 1}},
|
||||||
|
}}
|
||||||
|
svc := newSeasonDiscoveryService(provider)
|
||||||
|
got := svc.LatestAvailableSeason(context.Background(), animeSeason{Season: "spring", Year: 2026})
|
||||||
|
if got.Season != "summer" || got.Year != 2026 {
|
||||||
|
t.Fatalf("LatestAvailableSeason = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeasonOptionsRunNewestFirstWithoutFutureSeasons(t *testing.T) {
|
||||||
|
got := seasonOptions(2025, animeSeason{Season: "summer", Year: 2026})
|
||||||
|
want := []string{"Summer 2026", "Spring 2026", "Winter 2026", "Fall 2025", "Summer 2025", "Spring 2025", "Winter 2025"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("len(seasonOptions) = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i].Label != want[i] {
|
||||||
|
t.Fatalf("seasonOptions[%d] = %q, want %q", i, got[i].Label, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeasonNavigationStopsAtSupportedRange(t *testing.T) {
|
||||||
|
current := animeSeason{Season: "summer", Year: 2026}
|
||||||
|
if previous, _ := seasonNavigation(animeSeason{Season: "winter", Year: 2018}, 2018, current); previous != nil {
|
||||||
|
t.Fatalf("previous = %+v, want nil", previous)
|
||||||
|
}
|
||||||
|
if _, next := seasonNavigation(current, 2018, current); next != nil {
|
||||||
|
t.Fatalf("next = %+v, want nil", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSeasonDefaultsToCalendarSeason(t *testing.T) {
|
||||||
|
got := calendarSeason(2026, 7)
|
||||||
|
if got.Season != "summer" || got.Year != 2026 {
|
||||||
|
t.Fatalf("calendarSeason = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/auth/cookie.go
Normal file
58
internal/auth/cookie.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"mal/internal/domain"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionCookieName = "session_id"
|
||||||
|
|
||||||
|
func setSessionCookie(c *gin.Context, value string, maxAge int) {
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Secure: isHTTPSRequest(c.Request),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPersistentSessionCookie(c *gin.Context, value string) {
|
||||||
|
setSessionCookie(c, value, int(domain.SessionLifetime.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(c *gin.Context) {
|
||||||
|
setSessionCookie(c, "", -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPSRequest(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
proto := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||||
|
proto = proto[:i]
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(proto), "https") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded := r.Header.Values("Forwarded")
|
||||||
|
for _, value := range forwarded {
|
||||||
|
for part := range strings.SplitSeq(value, ";") {
|
||||||
|
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||||
|
if ok && strings.EqualFold(key, "proto") && strings.EqualFold(strings.Trim(val, `"`), "https") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func NewAuthHandler(svc domain.AuthService) *AuthHandler {
|
|||||||
func (h *AuthHandler) Register(r *gin.Engine) {
|
func (h *AuthHandler) Register(r *gin.Engine) {
|
||||||
r.GET("/login", h.HandleLoginPage)
|
r.GET("/login", h.HandleLoginPage)
|
||||||
r.POST("/login", h.HandleLogin)
|
r.POST("/login", h.HandleLogin)
|
||||||
r.GET("/logout", h.HandleLogout)
|
r.POST("/logout", h.HandleLogout)
|
||||||
r.POST("/api/auth/login", h.HandleAPILogin)
|
r.POST("/api/auth/login", h.HandleAPILogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie("session_id", session.ID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
|
setPersistentSessionCookie(c, session.ID)
|
||||||
if c.GetHeader("HX-Request") == "true" {
|
if c.GetHeader("HX-Request") == "true" {
|
||||||
c.Header("HX-Redirect", "/")
|
c.Header("HX-Redirect", "/")
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -60,7 +60,7 @@ func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie("session_id", "", -1, "/", "", false, true)
|
clearSessionCookie(c)
|
||||||
c.Redirect(http.StatusSeeOther, "/login")
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -72,6 +73,85 @@ func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleLoginSetsHardenedSessionCookie(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*http.Request)
|
||||||
|
wantSecure bool
|
||||||
|
}{
|
||||||
|
{name: "local http", wantSecure: false},
|
||||||
|
{name: "direct https", configure: func(req *http.Request) { req.TLS = &tls.ConnectionState{} }, wantSecure: true},
|
||||||
|
{name: "forwarded https", configure: func(req *http.Request) { req.Header.Set("X-Forwarded-Proto", "https") }, wantSecure: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc := &fakeAuthService{}
|
||||||
|
router := gin.New()
|
||||||
|
NewAuthHandler(svc).Register(router)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader("username=alice&password=correct"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if tt.configure != nil {
|
||||||
|
tt.configure(req)
|
||||||
|
}
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
cookie := findSetCookie(t, rec, "session_id")
|
||||||
|
if cookie.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
|
||||||
|
}
|
||||||
|
if cookie.Secure != tt.wantSecure {
|
||||||
|
t.Fatalf("Secure = %v, want %v", cookie.Secure, tt.wantSecure)
|
||||||
|
}
|
||||||
|
if !cookie.HttpOnly {
|
||||||
|
t.Fatalf("expected HttpOnly cookie")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogoutRequiresPostAndClearsHardenedSessionCookie(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{}
|
||||||
|
router := gin.New()
|
||||||
|
NewAuthHandler(svc).Register(router)
|
||||||
|
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/logout", nil)
|
||||||
|
router.ServeHTTP(getRec, getReq)
|
||||||
|
if getRec.Code == http.StatusSeeOther {
|
||||||
|
t.Fatalf("GET /logout must not perform logout redirect")
|
||||||
|
}
|
||||||
|
|
||||||
|
postRec := httptest.NewRecorder()
|
||||||
|
postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
|
||||||
|
postReq.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
postReq.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
|
||||||
|
router.ServeHTTP(postRec, postReq)
|
||||||
|
|
||||||
|
if postRec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /logout status = %d, want %d", postRec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
if svc.loggedOutSessionID != "session-1" {
|
||||||
|
t.Fatalf("logged out session = %q, want session-1", svc.loggedOutSessionID)
|
||||||
|
}
|
||||||
|
cookie := findSetCookie(t, postRec, "session_id")
|
||||||
|
if cookie.MaxAge >= 0 {
|
||||||
|
t.Fatalf("MaxAge = %d, want deletion cookie", cookie.MaxAge)
|
||||||
|
}
|
||||||
|
if cookie.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
|
||||||
|
}
|
||||||
|
if !cookie.Secure {
|
||||||
|
t.Fatalf("expected Secure cookie for forwarded https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
|
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@@ -133,6 +213,7 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
|
|||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
|
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -148,6 +229,13 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
|
|||||||
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
|
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
|
||||||
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
|
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
|
||||||
}
|
}
|
||||||
|
cookie := findSetCookie(t, rec, "session_id")
|
||||||
|
if cookie.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
|
||||||
|
}
|
||||||
|
if !cookie.Secure {
|
||||||
|
t.Fatalf("expected Secure cookie for forwarded https")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
||||||
@@ -184,6 +272,26 @@ func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareAllowsPublicWatchlistAPI(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{validateErr: errors.New("no auth")}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(svc))
|
||||||
|
router.GET("/api/public/users/:userID/watchlist", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if svc.validateSessionCalled || svc.validateAPITokenCalled {
|
||||||
|
t.Fatalf("public watchlist endpoint should not authenticate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeAuthService struct {
|
type fakeAuthService struct {
|
||||||
user *domain.User
|
user *domain.User
|
||||||
|
|
||||||
@@ -253,3 +361,14 @@ func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID st
|
|||||||
s.revokedAPITokensForUser = userID
|
s.revokedAPITokensForUser = userID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findSetCookie(t *testing.T, rec *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
for _, cookie := range rec.Result().Cookies() {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing Set-Cookie %q in %v", name, rec.Header().Values("Set-Cookie"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ var publicRoutes = []publicRoute{
|
|||||||
// Pages.
|
// Pages.
|
||||||
{method: http.MethodGet, path: "/login"},
|
{method: http.MethodGet, path: "/login"},
|
||||||
{method: http.MethodPost, path: "/login"},
|
{method: http.MethodPost, path: "/login"},
|
||||||
{method: http.MethodGet, path: "/logout"},
|
{method: http.MethodPost, path: "/logout"},
|
||||||
|
|
||||||
|
// Crawler noise.
|
||||||
|
{method: http.MethodGet, path: "/robots.txt"},
|
||||||
|
{method: http.MethodGet, path: "/sitemap.xml"},
|
||||||
|
|
||||||
// Static assets.
|
// Static assets.
|
||||||
{path: "/static", prefix: true},
|
{path: "/static", prefix: true},
|
||||||
@@ -26,6 +30,9 @@ var publicRoutes = []publicRoute{
|
|||||||
|
|
||||||
// Auth API.
|
// Auth API.
|
||||||
{method: http.MethodPost, path: "/api/auth/login"},
|
{method: http.MethodPost, path: "/api/auth/login"},
|
||||||
|
|
||||||
|
// Explicitly public, read-only API resources.
|
||||||
|
{method: http.MethodGet, path: "/api/public/", prefix: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPublicRequest(method string, path string) bool {
|
func isPublicRequest(method string, path string) bool {
|
||||||
@@ -108,7 +115,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
|||||||
|
|
||||||
if usesCookieSession {
|
if usesCookieSession {
|
||||||
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
|
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
|
||||||
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
|
setPersistentSessionCookie(c, sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
internal/config/config_test.go
Normal file
110
internal/config/config_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstNonEmptyReturnsFirstNonBlank(t *testing.T) {
|
||||||
|
if got := firstNonEmpty("", "b", "c"); got != "b" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstNonEmptyReturnsEmptyWhenAllBlank(t *testing.T) {
|
||||||
|
if got := firstNonEmpty("", "", ""); got != "" {
|
||||||
|
t.Fatalf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstNonEmptySkipsWhitespaceOnly(t *testing.T) {
|
||||||
|
if got := firstNonEmpty(" ", "x"); got != "x" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruthyRecognises1TrueYesYOn(t *testing.T) {
|
||||||
|
for _, v := range []string{"1", "true", "True", "TRUE", "yes", "Yes", "y", "Y", "on", "ON"} {
|
||||||
|
if !truthy(v) {
|
||||||
|
t.Fatalf("truthy(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruthyReturnsFalseForEmptyAndGarbage(t *testing.T) {
|
||||||
|
for _, v := range []string{"", "0", "false", "no", "off", "maybe"} {
|
||||||
|
if truthy(v) {
|
||||||
|
t.Fatalf("truthy(%q) = true, want false", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(): %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != "3000" {
|
||||||
|
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
|
||||||
|
}
|
||||||
|
if cfg.DatabaseFile != "mal.db" {
|
||||||
|
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "mal.db")
|
||||||
|
}
|
||||||
|
if cfg.EpisodeAvailabilityMode != EpisodeAvailabilityModeAuto {
|
||||||
|
t.Fatalf("EpisodeAvailabilityMode = %q, want %q", cfg.EpisodeAvailabilityMode, EpisodeAvailabilityModeAuto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReadsEnv(t *testing.T) {
|
||||||
|
os.Setenv("PORT", "8080")
|
||||||
|
os.Setenv("DATABASE_FILE", "/tmp/test.db")
|
||||||
|
os.Setenv("MAL_CORS_ALLOW_ALL", "1")
|
||||||
|
os.Setenv("MAL_JIKAN_TRACE", "true")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("PORT")
|
||||||
|
os.Unsetenv("DATABASE_FILE")
|
||||||
|
os.Unsetenv("MAL_CORS_ALLOW_ALL")
|
||||||
|
os.Unsetenv("MAL_JIKAN_TRACE")
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(): %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != "8080" {
|
||||||
|
t.Fatalf("Port = %q, want %q", cfg.Port, "8080")
|
||||||
|
}
|
||||||
|
if cfg.DatabaseFile != "/tmp/test.db" {
|
||||||
|
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "/tmp/test.db")
|
||||||
|
}
|
||||||
|
if !cfg.CORSAllowAll {
|
||||||
|
t.Fatal("CORSAllowAll = false, want true")
|
||||||
|
}
|
||||||
|
if !cfg.JikanTrace {
|
||||||
|
t.Fatal("JikanTrace = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadInvalidEpisodeAvailabilityMode(t *testing.T) {
|
||||||
|
os.Setenv("EPISODE_AVAILABILITY_MODE", "invalid")
|
||||||
|
defer os.Unsetenv("EPISODE_AVAILABILITY_MODE")
|
||||||
|
|
||||||
|
_, err := Load()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid EPISODE_AVAILABILITY_MODE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEmptyPortDefaultsTo3000(t *testing.T) {
|
||||||
|
os.Setenv("PORT", "")
|
||||||
|
os.Setenv("DATABASE_FILE", "mal.db")
|
||||||
|
defer os.Unsetenv("PORT")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(): %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != "3000" {
|
||||||
|
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/database/data_fixes_test.go
Normal file
142
internal/database/data_fixes_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
dbfixes "mal/internal/database/fixes"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunDataFixListRunsUnappliedFixesAndRecordsSuccesses(t *testing.T) {
|
||||||
|
sqlDB := newEmptyTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
var applied []string
|
||||||
|
fixes := []dbfixes.Fix{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
|
applied = append(applied, "first")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
|
applied = append(applied, "second")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
|
||||||
|
t.Fatalf("runDataFixList: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(applied, []string{"first", "second"}) {
|
||||||
|
t.Fatalf("applied fixes = %v, want [first second]", applied)
|
||||||
|
}
|
||||||
|
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"first", "second"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDataFixListSkipsAlreadyAppliedFixes(t *testing.T) {
|
||||||
|
sqlDB := newEmptyTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE data_fixes (id TEXT PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
|
||||||
|
t.Fatalf("create data_fixes: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO data_fixes (id) VALUES ('already-applied')`); err != nil {
|
||||||
|
t.Fatalf("insert applied fix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ran bool
|
||||||
|
fixes := []dbfixes.Fix{
|
||||||
|
{
|
||||||
|
ID: "already-applied",
|
||||||
|
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
|
ran = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
|
||||||
|
t.Fatalf("runDataFixList: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ran {
|
||||||
|
t.Fatal("already applied fix ran")
|
||||||
|
}
|
||||||
|
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"already-applied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDataFixListDoesNotRecordFailedFix(t *testing.T) {
|
||||||
|
sqlDB := newEmptyTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
fixErr := errors.New("boom")
|
||||||
|
fixes := []dbfixes.Fix{
|
||||||
|
{
|
||||||
|
ID: "fails",
|
||||||
|
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
|
return fixErr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes)
|
||||||
|
if !errors.Is(err, fixErr) {
|
||||||
|
t.Fatalf("runDataFixList error = %v, want wrapped %v", err, fixErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAppliedDataFixes(context.Background(), t, sqlDB, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEmptyTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAppliedDataFixes(ctx context.Context, t *testing.T, sqlDB *sql.DB, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query data_fixes: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Errorf("close rows: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
t.Fatalf("scan data_fix id: %v", err)
|
||||||
|
}
|
||||||
|
got = append(got, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("iterate data_fixes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("applied data fixes = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||||
@@ -45,6 +46,116 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWatchlistCompletionDateFollowsCompletedStatus(t *testing.T) {
|
||||||
|
sqlDB := newMigratedTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
|
||||||
|
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Anime', 'image.jpg')`)
|
||||||
|
|
||||||
|
queries := db.New(sqlDB)
|
||||||
|
upsertTestWatchlistStatus(ctx, t, queries, "completed")
|
||||||
|
completedAt, estimated := testWatchlistCompletion(ctx, t, sqlDB)
|
||||||
|
if !completedAt.Valid {
|
||||||
|
t.Fatalf("completed status should record completed_at")
|
||||||
|
}
|
||||||
|
if estimated {
|
||||||
|
t.Fatalf("new completion date should be exact")
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertTestWatchlistStatus(ctx, t, queries, "watching")
|
||||||
|
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
|
||||||
|
if completedAt.Valid || estimated {
|
||||||
|
t.Fatalf("watching status completion = %v estimated=%v, want empty", completedAt, estimated)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertTestWatchlistStatus(ctx, t, queries, "completed")
|
||||||
|
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
|
||||||
|
if !completedAt.Valid || estimated {
|
||||||
|
t.Fatalf("re-completed status completion = %v estimated=%v, want exact date", completedAt, estimated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionDateMigrationMarksHistoricalEstimates(t *testing.T) {
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
goose.SetBaseFS(migrationsFS)
|
||||||
|
goose.SetLogger(goose.NopLogger())
|
||||||
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
|
t.Fatalf("set goose dialect: %v", err)
|
||||||
|
}
|
||||||
|
if err := goose.UpTo(sqlDB, "migrations", 25); err != nil {
|
||||||
|
t.Fatalf("migrate to version 25: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
|
||||||
|
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Audited', '1.jpg'), (2, 'Estimated', '2.jpg')`)
|
||||||
|
mustExecTestSQL(t, sqlDB, `
|
||||||
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
|
||||||
|
VALUES
|
||||||
|
('entry-1', 'user-1', 1, 'completed', '2026-06-20 20:00:00'),
|
||||||
|
('entry-2', 'user-1', 2, 'completed', '2026-06-21 21:00:00')`)
|
||||||
|
mustExecTestSQL(t, sqlDB, `
|
||||||
|
INSERT INTO audit_log (id, occurred_at, user_id, action, resource_type, resource_id)
|
||||||
|
VALUES ('audit-1', '2026-06-19 19:00:00', 'user-1', 'watch_completed', 'anime', '1')`)
|
||||||
|
|
||||||
|
if err := goose.UpTo(sqlDB, "migrations", 26); err != nil {
|
||||||
|
t.Fatalf("migrate to version 26: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHistoricalCompletion(ctx, t, sqlDB, 1, "2026-06-19T19:00:00Z", false)
|
||||||
|
assertHistoricalCompletion(ctx, t, sqlDB, 2, "2026-06-21T21:00:00Z", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExecTestSQL(t *testing.T, sqlDB *sql.DB, query string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := sqlDB.ExecContext(context.Background(), query); err != nil {
|
||||||
|
t.Fatalf("execute test SQL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertTestWatchlistStatus(ctx context.Context, t *testing.T, queries *db.Queries, status string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := queries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
|
||||||
|
ID: "entry-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
AnimeID: 1,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsert %s watchlist entry: %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWatchlistCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB) (sql.NullTime, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var completedAt sql.NullTime
|
||||||
|
var estimated bool
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = 'user-1' AND anime_id = 1`).Scan(&completedAt, &estimated); err != nil {
|
||||||
|
t.Fatalf("query completion date: %v", err)
|
||||||
|
}
|
||||||
|
return completedAt, estimated
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHistoricalCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, wantTime string, wantEstimated bool) {
|
||||||
|
t.Helper()
|
||||||
|
var completedAt string
|
||||||
|
var estimated bool
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE anime_id = ?`, animeID).Scan(&completedAt, &estimated); err != nil {
|
||||||
|
t.Fatalf("query completion for anime %d: %v", animeID, err)
|
||||||
|
}
|
||||||
|
if completedAt != wantTime || estimated != wantEstimated {
|
||||||
|
t.Fatalf("anime %d completion = %q estimated=%v, want %q estimated=%v", animeID, completedAt, estimated, wantTime, wantEstimated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanupExpiredJikanCache(t *testing.T) {
|
func TestCleanupExpiredJikanCache(t *testing.T) {
|
||||||
sqlDB := newMigratedTestDB(t)
|
sqlDB := newMigratedTestDB(t)
|
||||||
defer closeTestDB(t, sqlDB)
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
|
return runDataFixList(sqlDB, deps, dbfixes.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDataFixList(sqlDB *sql.DB, deps dbfixes.Dependencies, fixes []dbfixes.Fix) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fixes := dbfixes.All()
|
|
||||||
|
|
||||||
if len(fixes) == 0 {
|
if len(fixes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
183
internal/database/fixes/fixes_test.go
Normal file
183
internal/database/fixes/fixes_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package fixes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEpisodeAvailabilityBackfillSetsMissingNextRefreshAt(t *testing.T) {
|
||||||
|
sqlDB := newFixTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (1, '{}', NULL, '2000-01-01T00:00:00Z')`); err != nil {
|
||||||
|
t.Fatalf("insert missing next_refresh row: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (2, '{}', '2999-01-01T00:00:00Z', '2000-01-01T00:00:00Z')`); err != nil {
|
||||||
|
t.Fatalf("insert populated next_refresh row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
|
||||||
|
|
||||||
|
assertNextRefreshAtBackfilled(ctx, t, sqlDB, 1)
|
||||||
|
assertUpdatedAtChanged(ctx, t, sqlDB, 1, "2000-01-01T00:00:00Z")
|
||||||
|
assertNextRefreshAtEquals(ctx, t, sqlDB, 2, "2999-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
backfilledNextRefreshAt := queryNextRefreshAt(ctx, t, sqlDB, 1)
|
||||||
|
|
||||||
|
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
|
||||||
|
assertNextRefreshAtEquals(ctx, t, sqlDB, 1, backfilledNextRefreshAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvatarURLBackfillUsesDefaultAvatarForBlankURLs(t *testing.T) {
|
||||||
|
sqlDB := newFixTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('blank', 'alice', 'hash', '')`); err != nil {
|
||||||
|
t.Fatalf("insert blank avatar user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('existing', 'bob', 'hash', 'custom.png')`); err != nil {
|
||||||
|
t.Fatalf("insert existing avatar user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := Dependencies{DefaultAvatarURL: func(username string) string { return "avatar/" + username + ".png" }}
|
||||||
|
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
|
||||||
|
|
||||||
|
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
|
||||||
|
assertUserAvatarURL(ctx, t, sqlDB, "existing", "custom.png")
|
||||||
|
|
||||||
|
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
|
||||||
|
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAnimeMissingDurationSecondsOnlyReturnsRowsWithNullDuration(t *testing.T) {
|
||||||
|
sqlDB := newFixTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (1, 'Missing', 'missing.png', NULL)`); err != nil {
|
||||||
|
t.Fatalf("insert missing duration anime: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (2, 'Present', 'present.png', 1440)`); err != nil {
|
||||||
|
t.Fatalf("insert present duration anime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listAnimeMissingDurationSeconds: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("missing duration rows = %v, want 1 row", rows)
|
||||||
|
}
|
||||||
|
if rows[0].id != 1 || rows[0].titleOriginal != "Missing" {
|
||||||
|
t.Fatalf("missing duration row = %+v, want anime 1", rows[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFix(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, deps Dependencies) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, fix := range All() {
|
||||||
|
if fix.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
|
||||||
|
t.Fatalf("apply fix %s: %v", id, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("fix %s not registered", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryNextRefreshAt(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var v sql.NullString
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
|
||||||
|
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
|
||||||
|
}
|
||||||
|
if !v.Valid || v.String == "" {
|
||||||
|
t.Fatalf("next_refresh_at for %d = %q, valid=%v; want populated", animeID, v.String, v.Valid)
|
||||||
|
}
|
||||||
|
return v.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNextRefreshAtBackfilled(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) {
|
||||||
|
t.Helper()
|
||||||
|
queryNextRefreshAt(ctx, t, sqlDB, animeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUpdatedAtChanged(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, original string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var updatedAt string
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT updated_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&updatedAt); err != nil {
|
||||||
|
t.Fatalf("query updated_at for %d: %v", animeID, err)
|
||||||
|
}
|
||||||
|
if updatedAt == original {
|
||||||
|
t.Fatalf("updated_at for %d = %q, expected change", animeID, updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNextRefreshAtEquals(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var v sql.NullString
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
|
||||||
|
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
|
||||||
|
}
|
||||||
|
if !v.Valid {
|
||||||
|
t.Fatalf("next_refresh_at for %d is NULL", animeID)
|
||||||
|
}
|
||||||
|
if v.String != want {
|
||||||
|
t.Fatalf("next_refresh_at for %d = %q, want %q", animeID, v.String, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUserAvatarURL(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got string
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT avatar_url FROM user WHERE id = ?`, id).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("query avatar_url for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("avatar_url for %s = %q, want %q", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, statement := range []string{
|
||||||
|
`CREATE TABLE user (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, avatar_url TEXT NOT NULL DEFAULT '')`,
|
||||||
|
`CREATE TABLE anime (id INTEGER PRIMARY KEY, title_original TEXT NOT NULL, title_english TEXT, title_japanese TEXT, image_url TEXT NOT NULL, airing BOOLEAN, duration_seconds REAL)`,
|
||||||
|
`CREATE TABLE episode_availability_cache (anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME, retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME, failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||||
|
} {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
|
||||||
|
closeTestDB(t, sqlDB)
|
||||||
|
t.Fatalf("create test schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE watch_list_entry ADD COLUMN completed_at DATETIME;
|
||||||
|
ALTER TABLE watch_list_entry ADD COLUMN completed_at_estimated BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE watch_list_entry
|
||||||
|
SET completed_at = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT MAX(a.occurred_at)
|
||||||
|
FROM audit_log a
|
||||||
|
WHERE a.user_id = watch_list_entry.user_id
|
||||||
|
AND a.action = 'watch_completed'
|
||||||
|
AND a.resource_type = 'anime'
|
||||||
|
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
|
||||||
|
),
|
||||||
|
watch_list_entry.updated_at
|
||||||
|
),
|
||||||
|
completed_at_estimated = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM audit_log a
|
||||||
|
WHERE a.user_id = watch_list_entry.user_id
|
||||||
|
AND a.action = 'watch_completed'
|
||||||
|
AND a.resource_type = 'anime'
|
||||||
|
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
|
||||||
|
) THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
WHERE status = 'completed';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE watch_list_entry DROP COLUMN completed_at_estimated;
|
||||||
|
ALTER TABLE watch_list_entry DROP COLUMN completed_at;
|
||||||
93
internal/db/continue_watching_test.go
Normal file
93
internal/db/continue_watching_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetContinueWatchingCarouselEntriesLimitsNewestFirst(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
sqlDB := newContinueWatchingTestDB(ctx, t)
|
||||||
|
defer closeContinueWatchingTestDB(t, sqlDB)
|
||||||
|
seedContinueWatchingRows(ctx, t, sqlDB, 30)
|
||||||
|
|
||||||
|
rows, err := New(sqlDB).GetContinueWatchingCarouselEntries(ctx, GetContinueWatchingCarouselEntriesParams{
|
||||||
|
UserID: "user-1",
|
||||||
|
Limit: 24,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetContinueWatchingCarouselEntries: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 24 {
|
||||||
|
t.Fatalf("len(rows) = %d, want 24", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].AnimeID != 30 {
|
||||||
|
t.Fatalf("first AnimeID = %d, want 30", rows[0].AnimeID)
|
||||||
|
}
|
||||||
|
if rows[len(rows)-1].AnimeID != 7 {
|
||||||
|
t.Fatalf("last AnimeID = %d, want 7", rows[len(rows)-1].AnimeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newContinueWatchingTestDB(ctx context.Context, t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sqlDB.ExecContext(ctx, `
|
||||||
|
CREATE TABLE anime (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title_original TEXT NOT NULL,
|
||||||
|
title_english TEXT,
|
||||||
|
title_japanese TEXT,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
duration_seconds REAL
|
||||||
|
);
|
||||||
|
CREATE TABLE continue_watching_entry (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
anime_id INTEGER NOT NULL REFERENCES anime(id),
|
||||||
|
current_episode INTEGER,
|
||||||
|
current_time_seconds REAL NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds REAL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, anime_id)
|
||||||
|
);`)
|
||||||
|
if err != nil {
|
||||||
|
closeContinueWatchingTestDB(t, sqlDB)
|
||||||
|
t.Fatalf("create schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeContinueWatchingTestDB(t *testing.T, sqlDB *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContinueWatchingRows(ctx context.Context, t *testing.T, sqlDB *sql.DB, totalRows int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for i := 1; i <= totalRows; i++ {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url) VALUES (?, ?, ?)`, i, fmt.Sprintf("Anime %02d", i), "image.jpg"); err != nil {
|
||||||
|
t.Fatalf("insert anime %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('2026-01-01', printf('+%d minutes', ?)))`, fmt.Sprintf("cw-%02d", i), "user-1", i, i, 0, i); err != nil {
|
||||||
|
t.Fatalf("insert continue watching %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,38 @@ type JikanCache struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecommendationEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AnimeID sql.NullInt64 `json:"anime_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Source sql.NullString `json:"source"`
|
||||||
|
MetadataJson sql.NullString `json:"metadata_json"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationImpression struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AnimeID int64 `json:"anime_id"`
|
||||||
|
Rail string `json:"rail"`
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
RequestID sql.NullString `json:"request_id"`
|
||||||
|
MetadataJson sql.NullString `json:"metadata_json"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationProfileSnapshot struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ProfileJson string `json:"profile_json"`
|
||||||
|
SourceWindowStart sql.NullTime `json:"source_window_start"`
|
||||||
|
SourceWindowEnd sql.NullTime `json:"source_window_end"`
|
||||||
|
ComputedAt time.Time `json:"computed_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@@ -131,13 +163,15 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WatchListEntry struct {
|
type WatchListEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
|
CompletedAt sql.NullTime `json:"completed_at"`
|
||||||
|
CompletedAtEstimated bool `json:"completed_at_estimated"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Querier interface {
|
|||||||
GetAnime(ctx context.Context, id int64) (Anime, error)
|
GetAnime(ctx context.Context, id int64) (Anime, error)
|
||||||
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
||||||
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||||
|
GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error)
|
||||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||||
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||||
|
|||||||
@@ -68,12 +68,32 @@ RETURNING *;
|
|||||||
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: UpsertWatchListEntry :one
|
-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (
|
||||||
|
sqlc.arg(id),
|
||||||
|
sqlc.arg(user_id),
|
||||||
|
sqlc.arg(anime_id),
|
||||||
|
sqlc.arg(status),
|
||||||
|
sqlc.arg(current_episode),
|
||||||
|
sqlc.arg(current_time_seconds),
|
||||||
|
CASE WHEN sqlc.arg(status) = 'completed' THEN CURRENT_TIMESTAMP END,
|
||||||
|
0,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
current_episode = excluded.current_episode,
|
current_episode = excluded.current_episode,
|
||||||
current_time_seconds = excluded.current_time_seconds,
|
current_time_seconds = excluded.current_time_seconds,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN excluded.status != 'completed' THEN NULL
|
||||||
|
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
|
||||||
|
ELSE CURRENT_TIMESTAMP
|
||||||
|
END,
|
||||||
|
completed_at_estimated = CASE
|
||||||
|
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
|
||||||
|
THEN watch_list_entry.completed_at_estimated
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -117,6 +137,27 @@ JOIN anime a ON c.anime_id = a.id
|
|||||||
WHERE c.user_id = ?
|
WHERE c.user_id = ?
|
||||||
ORDER BY c.updated_at DESC;
|
ORDER BY c.updated_at DESC;
|
||||||
|
|
||||||
|
-- name: GetContinueWatchingCarouselEntries :many
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.user_id,
|
||||||
|
c.anime_id,
|
||||||
|
c.current_episode,
|
||||||
|
c.current_time_seconds,
|
||||||
|
c.duration_seconds,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
a.title_original,
|
||||||
|
a.title_english,
|
||||||
|
a.title_japanese,
|
||||||
|
a.image_url,
|
||||||
|
a.duration_seconds as anime_duration_seconds
|
||||||
|
FROM continue_watching_entry c
|
||||||
|
JOIN anime a ON c.anime_id = a.id
|
||||||
|
WHERE c.user_id = ?
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT ?;
|
||||||
|
|
||||||
-- name: DeleteContinueWatchingEntry :exec
|
-- name: DeleteContinueWatchingEntry :exec
|
||||||
DELETE FROM continue_watching_entry
|
DELETE FROM continue_watching_entry
|
||||||
WHERE user_id = ? AND anime_id = ?;
|
WHERE user_id = ? AND anime_id = ?;
|
||||||
@@ -127,7 +168,20 @@ WHERE user_id = ? AND anime_id = ? LIMIT 1;
|
|||||||
|
|
||||||
-- name: GetUserWatchList :many
|
-- name: GetUserWatchList :many
|
||||||
SELECT
|
SELECT
|
||||||
e.*,
|
e.id,
|
||||||
|
e.user_id,
|
||||||
|
e.anime_id,
|
||||||
|
e.status,
|
||||||
|
e.created_at,
|
||||||
|
e.updated_at,
|
||||||
|
e.current_episode,
|
||||||
|
e.last_episode_at,
|
||||||
|
e.current_time_seconds,
|
||||||
|
e.completed_at,
|
||||||
|
e.completed_at_estimated,
|
||||||
|
c.current_episode AS playback_current_episode,
|
||||||
|
c.current_time_seconds AS playback_current_time_seconds,
|
||||||
|
c.updated_at AS playback_updated_at,
|
||||||
a.title_original,
|
a.title_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -135,6 +189,7 @@ SELECT
|
|||||||
a.airing
|
a.airing
|
||||||
FROM watch_list_entry e
|
FROM watch_list_entry e
|
||||||
JOIN anime a ON e.anime_id = a.id
|
JOIN anime a ON e.anime_id = a.id
|
||||||
|
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
|
||||||
WHERE e.user_id = ?
|
WHERE e.user_id = ?
|
||||||
ORDER BY e.updated_at DESC;
|
ORDER BY e.updated_at DESC;
|
||||||
|
|
||||||
|
|||||||
@@ -380,6 +380,70 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.user_id,
|
||||||
|
c.anime_id,
|
||||||
|
c.current_episode,
|
||||||
|
c.current_time_seconds,
|
||||||
|
c.duration_seconds,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
a.title_original,
|
||||||
|
a.title_english,
|
||||||
|
a.title_japanese,
|
||||||
|
a.image_url,
|
||||||
|
a.duration_seconds as anime_duration_seconds
|
||||||
|
FROM continue_watching_entry c
|
||||||
|
JOIN anime a ON c.anime_id = a.id
|
||||||
|
WHERE c.user_id = ?
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetContinueWatchingCarouselEntriesParams struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Limit int64 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetContinueWatchingEntriesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetContinueWatchingEntriesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.AnimeID,
|
||||||
|
&i.CurrentEpisode,
|
||||||
|
&i.CurrentTimeSeconds,
|
||||||
|
&i.DurationSeconds,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.TitleOriginal,
|
||||||
|
&i.TitleEnglish,
|
||||||
|
&i.TitleJapanese,
|
||||||
|
&i.ImageUrl,
|
||||||
|
&i.AnimeDurationSeconds,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
|
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
c.id,
|
||||||
@@ -609,6 +673,7 @@ func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, e
|
|||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const getSession = `-- name: GetSession :one
|
const getSession = `-- name: GetSession :one
|
||||||
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
|
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -794,7 +859,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
|||||||
|
|
||||||
const getUserWatchList = `-- name: GetUserWatchList :many
|
const getUserWatchList = `-- name: GetUserWatchList :many
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
|
e.id,
|
||||||
|
e.user_id,
|
||||||
|
e.anime_id,
|
||||||
|
e.status,
|
||||||
|
e.created_at,
|
||||||
|
e.updated_at,
|
||||||
|
e.current_episode,
|
||||||
|
e.last_episode_at,
|
||||||
|
e.current_time_seconds,
|
||||||
|
e.completed_at,
|
||||||
|
e.completed_at_estimated,
|
||||||
|
c.current_episode AS playback_current_episode,
|
||||||
|
c.current_time_seconds AS playback_current_time_seconds,
|
||||||
|
c.updated_at AS playback_updated_at,
|
||||||
a.title_original,
|
a.title_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -802,25 +880,31 @@ SELECT
|
|||||||
a.airing
|
a.airing
|
||||||
FROM watch_list_entry e
|
FROM watch_list_entry e
|
||||||
JOIN anime a ON e.anime_id = a.id
|
JOIN anime a ON e.anime_id = a.id
|
||||||
|
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
|
||||||
WHERE e.user_id = ?
|
WHERE e.user_id = ?
|
||||||
ORDER BY e.updated_at DESC
|
ORDER BY e.updated_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetUserWatchListRow struct {
|
type GetUserWatchListRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
TitleOriginal string `json:"title_original"`
|
CompletedAt sql.NullTime `json:"completed_at"`
|
||||||
TitleEnglish sql.NullString `json:"title_english"`
|
CompletedAtEstimated bool `json:"completed_at_estimated"`
|
||||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
PlaybackCurrentEpisode sql.NullInt64 `json:"playback_current_episode"`
|
||||||
ImageUrl string `json:"image_url"`
|
PlaybackCurrentTimeSeconds sql.NullFloat64 `json:"playback_current_time_seconds"`
|
||||||
Airing sql.NullBool `json:"airing"`
|
PlaybackUpdatedAt sql.NullTime `json:"playback_updated_at"`
|
||||||
|
TitleOriginal string `json:"title_original"`
|
||||||
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
Airing sql.NullBool `json:"airing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
|
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
|
||||||
@@ -841,6 +925,11 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
&i.CurrentTimeSeconds,
|
&i.CurrentTimeSeconds,
|
||||||
|
&i.CompletedAt,
|
||||||
|
&i.CompletedAtEstimated,
|
||||||
|
&i.PlaybackCurrentEpisode,
|
||||||
|
&i.PlaybackCurrentTimeSeconds,
|
||||||
|
&i.PlaybackUpdatedAt,
|
||||||
&i.TitleOriginal,
|
&i.TitleOriginal,
|
||||||
&i.TitleEnglish,
|
&i.TitleEnglish,
|
||||||
&i.TitleJapanese,
|
&i.TitleJapanese,
|
||||||
@@ -861,7 +950,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getWatchListEntry = `-- name: GetWatchListEntry :one
|
const getWatchListEntry = `-- name: GetWatchListEntry :one
|
||||||
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry
|
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated FROM watch_list_entry
|
||||||
WHERE user_id = ? AND anime_id = ? LIMIT 1
|
WHERE user_id = ? AND anime_id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -883,13 +972,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
|
|||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
&i.CurrentTimeSeconds,
|
&i.CurrentTimeSeconds,
|
||||||
|
&i.CompletedAt,
|
||||||
|
&i.CompletedAtEstimated,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWatchingAnime = `-- name: GetWatchingAnime :many
|
const getWatchingAnime = `-- name: GetWatchingAnime :many
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
|
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.completed_at, e.completed_at_estimated,
|
||||||
a.title_original,
|
a.title_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -902,20 +993,22 @@ ORDER BY e.updated_at DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetWatchingAnimeRow struct {
|
type GetWatchingAnimeRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
TitleOriginal string `json:"title_original"`
|
CompletedAt sql.NullTime `json:"completed_at"`
|
||||||
TitleEnglish sql.NullString `json:"title_english"`
|
CompletedAtEstimated bool `json:"completed_at_estimated"`
|
||||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
TitleOriginal string `json:"title_original"`
|
||||||
ImageUrl string `json:"image_url"`
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
Airing sql.NullBool `json:"airing"`
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
Airing sql.NullBool `json:"airing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
|
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
|
||||||
@@ -936,6 +1029,8 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
|
|||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
&i.CurrentTimeSeconds,
|
&i.CurrentTimeSeconds,
|
||||||
|
&i.CompletedAt,
|
||||||
|
&i.CompletedAtEstimated,
|
||||||
&i.TitleOriginal,
|
&i.TitleOriginal,
|
||||||
&i.TitleEnglish,
|
&i.TitleEnglish,
|
||||||
&i.TitleJapanese,
|
&i.TitleJapanese,
|
||||||
@@ -1299,14 +1394,34 @@ func (q *Queries) UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEp
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (
|
||||||
|
?1,
|
||||||
|
?2,
|
||||||
|
?3,
|
||||||
|
?4,
|
||||||
|
?5,
|
||||||
|
?6,
|
||||||
|
CASE WHEN ?4 = 'completed' THEN CURRENT_TIMESTAMP END,
|
||||||
|
0,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
current_episode = excluded.current_episode,
|
current_episode = excluded.current_episode,
|
||||||
current_time_seconds = excluded.current_time_seconds,
|
current_time_seconds = excluded.current_time_seconds,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN excluded.status != 'completed' THEN NULL
|
||||||
|
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
|
||||||
|
ELSE CURRENT_TIMESTAMP
|
||||||
|
END,
|
||||||
|
completed_at_estimated = CASE
|
||||||
|
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
|
||||||
|
THEN watch_list_entry.completed_at_estimated
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds
|
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertWatchListEntryParams struct {
|
type UpsertWatchListEntryParams struct {
|
||||||
@@ -1338,6 +1453,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
|
|||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
&i.CurrentTimeSeconds,
|
&i.CurrentTimeSeconds,
|
||||||
|
&i.CompletedAt,
|
||||||
|
&i.CompletedAtEstimated,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type Anime struct {
|
type Anime struct {
|
||||||
jikan.Anime
|
jikan.Anime
|
||||||
|
RecommendationRationale []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Genre struct {
|
type Genre struct {
|
||||||
@@ -137,6 +138,10 @@ type AnimeCatalogService interface {
|
|||||||
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecommendationInvalidator interface {
|
||||||
|
InvalidateTopPicksForUser(userID string)
|
||||||
|
}
|
||||||
|
|
||||||
type AnimeSearchService interface {
|
type AnimeSearchService interface {
|
||||||
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
|
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
|
||||||
GetProducerNameByID(ctx context.Context, id int) (string, error)
|
GetProducerNameByID(ctx context.Context, id int) (string, error)
|
||||||
@@ -179,4 +184,5 @@ type AnimeRepository interface {
|
|||||||
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
|
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
|
||||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error)
|
GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error)
|
||||||
|
GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ package domain
|
|||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
type EpisodeAvailability struct {
|
type EpisodeAvailability struct {
|
||||||
Sub []int
|
Sub []int
|
||||||
Dub []int
|
Dub []int
|
||||||
|
Titles map[int]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodeProvider interface {
|
||||||
|
Name() string
|
||||||
|
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type EpisodeAvailabilityProvider interface {
|
type EpisodeAvailabilityProvider interface {
|
||||||
Name() string
|
EpisodeProvider
|
||||||
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
|
|
||||||
GetEpisodeAvailabilityByProviderID(ctx context.Context, providerID string) (EpisodeAvailability, error)
|
GetEpisodeAvailabilityByProviderID(ctx context.Context, providerID string) (EpisodeAvailability, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EpisodeTitleProvider interface {
|
||||||
|
EpisodeProvider
|
||||||
|
GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime Anime, episodeCount int) (map[int]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
type CanonicalEpisode struct {
|
type CanonicalEpisode struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -24,13 +34,28 @@ type CanonicalEpisode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CanonicalEpisodeList struct {
|
type CanonicalEpisodeList struct {
|
||||||
AnimeID int `json:"anime_id"`
|
AnimeID int `json:"anime_id"`
|
||||||
Episodes []CanonicalEpisode `json:"episodes"`
|
Episodes []CanonicalEpisode `json:"episodes"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
NextRefreshAt string `json:"next_refresh_at,omitempty"`
|
AvailabilityVersion int `json:"availability_version,omitempty"`
|
||||||
|
ClassificationChecked bool `json:"classification_checked,omitempty"`
|
||||||
|
ReleaseChecked bool `json:"release_checked,omitempty"`
|
||||||
|
NextRefreshAt string `json:"next_refresh_at,omitempty"`
|
||||||
|
RetryUntilAt string `json:"retry_until_at,omitempty"`
|
||||||
|
LastAttemptAt string `json:"last_attempt_at,omitempty"`
|
||||||
|
LastSuccessAt string `json:"last_success_at,omitempty"`
|
||||||
|
FailureCount int64 `json:"failure_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EpisodeService interface {
|
type EpisodeService interface {
|
||||||
GetCanonicalEpisodes(ctx context.Context, anime Anime, forceRefresh bool) (CanonicalEpisodeList, error)
|
GetCanonicalEpisodes(ctx context.Context, anime Anime, forceRefresh bool) (CanonicalEpisodeList, error)
|
||||||
RefreshTrackedDue(ctx context.Context, limit int) error
|
RefreshTrackedDue(ctx context.Context, limit int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EpisodeTitleService interface {
|
||||||
|
EnrichEpisodeTitles(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodeClassificationService interface {
|
||||||
|
EnrichEpisodeClassifications(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ import (
|
|||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type playbackSourceRefreshKey struct{}
|
||||||
|
type deferredPlaybackDataKey struct{}
|
||||||
|
|
||||||
|
func WithDeferredPlaybackData(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, deferredPlaybackDataKey{}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlaybackDataDeferred(ctx context.Context) bool {
|
||||||
|
deferred, _ := ctx.Value(deferredPlaybackDataKey{}).(bool)
|
||||||
|
return deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPlaybackSourceRefresh(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, playbackSourceRefreshKey{}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlaybackSourceRefreshRequested(ctx context.Context) bool {
|
||||||
|
refresh, _ := ctx.Value(playbackSourceRefreshKey{}).(bool)
|
||||||
|
return refresh
|
||||||
|
}
|
||||||
|
|
||||||
type PlaybackService interface {
|
type PlaybackService interface {
|
||||||
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
|
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
|
||||||
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
|
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
|
||||||
@@ -14,17 +35,26 @@ type PlaybackService interface {
|
|||||||
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
|
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlaybackEpisodeTitleService interface {
|
||||||
|
EnrichEpisodeTitles(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaybackEpisodeClassificationService interface {
|
||||||
|
EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
|
||||||
|
}
|
||||||
|
|
||||||
type WatchPageData struct {
|
type WatchPageData struct {
|
||||||
WatchData WatchData
|
WatchData WatchData
|
||||||
Anime Anime
|
Anime Anime
|
||||||
Episodes []CanonicalEpisode
|
Episodes []CanonicalEpisode
|
||||||
CurrentEpID string
|
CurrentEpID string
|
||||||
WatchlistStatus string
|
WatchlistStatus string
|
||||||
WatchlistIDs []int64
|
WatchlistIDs []int64
|
||||||
Seasons []SeasonEntry
|
Seasons []SeasonEntry
|
||||||
User *User
|
User *User
|
||||||
CurrentPath string
|
CurrentPath string
|
||||||
Error string
|
Error string
|
||||||
|
EpisodeAvailabilityWarning string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatchData struct {
|
type WatchData struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package episodes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/integrations/playback/allanime"
|
"mal/integrations/playback/allanime"
|
||||||
|
"mal/integrations/tvmaze"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
episodeService "mal/internal/episodes/service"
|
episodeService "mal/internal/episodes/service"
|
||||||
@@ -17,6 +18,7 @@ func episodeAvailabilityEnabled(cfg config.Config) bool {
|
|||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
episodeAvailabilityEnabled,
|
episodeAvailabilityEnabled,
|
||||||
|
tvmaze.NewClient,
|
||||||
fx.Annotate(
|
fx.Annotate(
|
||||||
episodeService.NewEpisodeService,
|
episodeService.NewEpisodeService,
|
||||||
),
|
),
|
||||||
@@ -24,5 +26,8 @@ var Module = fx.Options(
|
|||||||
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
||||||
return []domain.EpisodeAvailabilityProvider{p}
|
return []domain.EpisodeAvailabilityProvider{p}
|
||||||
}),
|
}),
|
||||||
|
fx.Provide(func(p *tvmaze.Client) domain.EpisodeTitleProvider {
|
||||||
|
return p
|
||||||
|
}),
|
||||||
fx.Invoke(RegisterWorker),
|
fx.Invoke(RegisterWorker),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,20 +6,26 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
|
const episodeAvailabilityPayloadVersion = 4
|
||||||
|
|
||||||
|
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, availability domain.EpisodeAvailability, source string, now time.Time) (domain.CanonicalEpisodeList, error) {
|
||||||
nextRefreshSQL := nextRefreshAt(anime, now)
|
nextRefreshSQL := nextRefreshAt(anime, now)
|
||||||
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
|
episodes := mergeEpisodes(nil, availability, 0)
|
||||||
payload := domain.CanonicalEpisodeList{
|
payload := domain.CanonicalEpisodeList{
|
||||||
AnimeID: anime.MalID,
|
AnimeID: anime.MalID,
|
||||||
Episodes: episodes,
|
Episodes: episodes,
|
||||||
Source: source,
|
Source: source,
|
||||||
|
AvailabilityVersion: episodeAvailabilityPayloadVersion,
|
||||||
|
ReleaseChecked: false,
|
||||||
|
LastAttemptAt: now.Format(time.RFC3339),
|
||||||
|
FailureCount: 0,
|
||||||
}
|
}
|
||||||
|
payload.LastSuccessAt = now.Format(time.RFC3339)
|
||||||
if nextRefreshSQL.Valid {
|
if nextRefreshSQL.Valid {
|
||||||
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
|
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
@@ -29,7 +35,7 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
|
|||||||
return domain.CanonicalEpisodeList{}, err
|
return domain.CanonicalEpisodeList{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, providerSuccess, nextRefreshSQL) {
|
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, true, nextRefreshSQL) {
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +152,7 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.CanonicalEpisodeList{}, false
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
|
payload = enrichCachedPayload(payload, row)
|
||||||
observability.Info(
|
observability.Info(
|
||||||
"episodes_cache_served",
|
"episodes_cache_served",
|
||||||
"episodes",
|
"episodes",
|
||||||
@@ -168,9 +175,27 @@ func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anim
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.CanonicalEpisodeList{}, false
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
|
payload = enrichCachedPayload(payload, row)
|
||||||
return payload, true
|
return payload, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enrichCachedPayload(payload domain.CanonicalEpisodeList, row db.EpisodeAvailabilityCache) domain.CanonicalEpisodeList {
|
||||||
|
if row.NextRefreshAt.Valid {
|
||||||
|
payload.NextRefreshAt = row.NextRefreshAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if row.RetryUntilAt.Valid {
|
||||||
|
payload.RetryUntilAt = row.RetryUntilAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if row.LastAttemptAt.Valid {
|
||||||
|
payload.LastAttemptAt = row.LastAttemptAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if row.LastSuccessAt.Valid {
|
||||||
|
payload.LastSuccessAt = row.LastSuccessAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
payload.FailureCount = row.FailureCount
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
|
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
|
||||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
||||||
observability.Info(
|
observability.Info(
|
||||||
@@ -213,6 +238,24 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
|
|||||||
)
|
)
|
||||||
return domain.CanonicalEpisodeList{}, false
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
|
if payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
|
||||||
|
return domain.CanonicalEpisodeList{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isStaleProviderEpisodePayload(payload) {
|
||||||
|
observability.Info(
|
||||||
|
"episodes_cached_payload_rejected_stale_version",
|
||||||
|
"episodes",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
"cached_episodes": len(payload.Episodes),
|
||||||
|
"source": payload.Source,
|
||||||
|
"availability_version": payload.AvailabilityVersion,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return domain.CanonicalEpisodeList{}, false
|
||||||
|
}
|
||||||
|
|
||||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||||
observability.Info(
|
observability.Info(
|
||||||
@@ -230,3 +273,7 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
|
|||||||
|
|
||||||
return payload, true
|
return payload, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isStaleProviderEpisodePayload(payload domain.CanonicalEpisodeList) bool {
|
||||||
|
return payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" && payload.AvailabilityVersion < episodeAvailabilityPayloadVersion
|
||||||
|
}
|
||||||
|
|||||||
81
internal/episodes/service/classifications.go
Normal file
81
internal/episodes/service/classifications.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *EpisodeService) EnrichEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||||
|
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability is not cached")
|
||||||
|
}
|
||||||
|
if payload.ClassificationChecked {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if s.jikan == nil {
|
||||||
|
return payload, errors.New("episode classifications: provider is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err, _ := s.classificationLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
|
||||||
|
return s.loadEpisodeClassifications(ctx, anime)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
return value.(domain.CanonicalEpisodeList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EpisodeService) loadEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||||
|
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability cache disappeared")
|
||||||
|
}
|
||||||
|
mergeEpisodeClassifications(payload.Episodes, episodes)
|
||||||
|
payload.ClassificationChecked = true
|
||||||
|
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.Info(
|
||||||
|
"episode_classifications_enriched",
|
||||||
|
"episodes",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
"episodes": len(episodes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEpisodeClassifications(canonical []domain.CanonicalEpisode, episodes []jikan.Episode) {
|
||||||
|
byNumber := make(map[int]jikan.Episode, len(episodes))
|
||||||
|
for i, episode := range episodes {
|
||||||
|
number, ok := jikanEpisodeNumber(episode, i)
|
||||||
|
if ok {
|
||||||
|
byNumber[number] = episode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range canonical {
|
||||||
|
classification, ok := byNumber[canonical[i].Number]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canonical[i].Filler = classification.Filler
|
||||||
|
canonical[i].Recap = classification.Recap
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/episodes/service/classifications_test.go
Normal file
32
internal/episodes/service/classifications_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeEpisodeClassificationsRestoresFillerAndRecap(t *testing.T) {
|
||||||
|
episodes := []domain.CanonicalEpisode{
|
||||||
|
{Number: 1, Title: "First", HasSub: true},
|
||||||
|
{Number: 2, Title: "Second", HasSub: true},
|
||||||
|
{Number: 3, Title: "Third", HasSub: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeEpisodeClassifications(episodes, []jikan.Episode{
|
||||||
|
{Episode: "1"},
|
||||||
|
{Episode: "2", Filler: true},
|
||||||
|
{Episode: "3", Recap: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if episodes[0].Filler || episodes[0].Recap {
|
||||||
|
t.Fatalf("episode 1 = %#v", episodes[0])
|
||||||
|
}
|
||||||
|
if !episodes[1].Filler || episodes[1].Recap {
|
||||||
|
t.Fatalf("episode 2 = %#v", episodes[1])
|
||||||
|
}
|
||||||
|
if episodes[2].Filler || !episodes[2].Recap {
|
||||||
|
t.Fatalf("episode 3 = %#v", episodes[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
@@ -35,6 +36,9 @@ func titleCandidates(anime domain.Anime) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
||||||
|
if payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" {
|
||||||
|
return providerBackedPayloadHasAvailability(payload)
|
||||||
|
}
|
||||||
if expectedCount <= 0 {
|
if expectedCount <= 0 {
|
||||||
return providerBackedPayloadHasAvailability(payload)
|
return providerBackedPayloadHasAvailability(payload)
|
||||||
}
|
}
|
||||||
@@ -62,15 +66,25 @@ func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) b
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
||||||
|
return mergeEpisodeData(jikanEpisodes, availability, expectedCount, time.Now(), false, "", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEpisodesForAnime(anime domain.Anime, jikanEpisodes []jikan.Episode, now time.Time, providerVerified bool) []domain.CanonicalEpisode {
|
||||||
|
return mergeEpisodeData(jikanEpisodes, domain.EpisodeAvailability{}, anime.Episodes, now, providerVerified, anime.Aired.From, anime.Airing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEpisodeData(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int, now time.Time, providerVerified bool, firstAired string, requireJikanAiredDates bool) []domain.CanonicalEpisode {
|
||||||
byNumber := map[int]episodePartial{}
|
byNumber := map[int]episodePartial{}
|
||||||
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
|
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
|
||||||
providerBacked := len(providerNumbers) > 0
|
providerBacked := providerVerified || len(providerNumbers) > 0
|
||||||
|
|
||||||
for number := range providerNumbers {
|
for number := range providerNumbers {
|
||||||
mergeEpisode(&byNumber, number, func(item *episodePartial) {})
|
mergeEpisode(&byNumber, number, func(item *episodePartial) {
|
||||||
|
item.title = availability.Titles[number]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount)
|
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount, now, firstAired, requireJikanAiredDates)
|
||||||
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
|
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
|
||||||
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
|
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
|
||||||
|
|
||||||
@@ -100,13 +114,26 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
|
|||||||
return episodes
|
return episodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int) {
|
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int, now time.Time, firstAired string, requireAiredDates bool) {
|
||||||
|
if shouldSkipJikanMerge(providerBacked, firstAired, now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for i, ep := range episodes {
|
for i, ep := range episodes {
|
||||||
if exceedsExpectedCount(i+1, expectedCount) {
|
if exceedsExpectedCount(i+1, expectedCount) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
number, ok := jikanEpisodeNumber(ep, i)
|
number, ok := jikanEpisodeNumber(ep, i)
|
||||||
if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) {
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exceedsExpectedCount(number, expectedCount) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if providerBacked && !providerNumbers[number] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !providerBacked && !hasEpisodeAired(ep, now, requireAiredDates) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mergeEpisode(byNumber, number, func(item *episodePartial) {
|
mergeEpisode(byNumber, number, func(item *episodePartial) {
|
||||||
@@ -117,6 +144,10 @@ func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldSkipJikanMerge(providerBacked bool, firstAired string, now time.Time) bool {
|
||||||
|
return !providerBacked && !hasStartedAiring(firstAired, now)
|
||||||
|
}
|
||||||
|
|
||||||
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
|
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
|
||||||
numbers := map[int]bool{}
|
numbers := map[int]bool{}
|
||||||
for _, number := range availability.Sub {
|
for _, number := range availability.Sub {
|
||||||
@@ -158,6 +189,28 @@ func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
|
|||||||
return index + 1, true
|
return index + 1, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasStartedAiring(firstAired string, now time.Time) bool {
|
||||||
|
if strings.TrimSpace(firstAired) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
startedAt, err := time.Parse(time.RFC3339, firstAired)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !now.Before(startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasEpisodeAired(ep jikan.Episode, now time.Time, requireAiredDate bool) bool {
|
||||||
|
if strings.TrimSpace(ep.Aired) == "" {
|
||||||
|
return !requireAiredDate
|
||||||
|
}
|
||||||
|
airedAt, err := time.Parse(time.RFC3339, ep.Aired)
|
||||||
|
if err != nil {
|
||||||
|
return !requireAiredDate
|
||||||
|
}
|
||||||
|
return !now.Before(airedAt)
|
||||||
|
}
|
||||||
|
|
||||||
func exceedsExpectedCount(number int, expectedCount int) bool {
|
func exceedsExpectedCount(number int, expectedCount int) bool {
|
||||||
return expectedCount > 0 && number > expectedCount
|
return expectedCount > 0 && number > expectedCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, titles []string) (string, error) {
|
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, titles []string) (string, error) {
|
||||||
providerID, found, err := s.cachedProviderID(ctx, anime, provider)
|
providerID, found, err := s.cachedProviderID(ctx, anime, provider)
|
||||||
if found || err != nil {
|
if found || err != nil {
|
||||||
return providerID, err
|
return providerID, err
|
||||||
@@ -39,7 +39,7 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
|
|||||||
return providerID, nil
|
return providerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider) (string, bool, error) {
|
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider) (string, bool, error) {
|
||||||
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
|
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
|
||||||
AnimeID: int64(anime.MalID),
|
AnimeID: int64(anime.MalID),
|
||||||
Provider: provider.Name(),
|
Provider: provider.Name(),
|
||||||
@@ -81,7 +81,7 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
|
|||||||
return row.ProviderShowID, true, nil
|
return row.ProviderShowID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) {
|
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, resolveErr error) {
|
||||||
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||||
AnimeID: int64(anime.MalID),
|
AnimeID: int64(anime.MalID),
|
||||||
Provider: provider.Name(),
|
Provider: provider.Name(),
|
||||||
@@ -105,7 +105,7 @@ func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domai
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) {
|
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, providerID string) {
|
||||||
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||||
AnimeID: int64(anime.MalID),
|
AnimeID: int64(anime.MalID),
|
||||||
Provider: provider.Name(),
|
Provider: provider.Name(),
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Clock interface {
|
type Clock interface {
|
||||||
@@ -21,32 +24,33 @@ type realClock struct{}
|
|||||||
func (realClock) Now() time.Time { return time.Now() }
|
func (realClock) Now() time.Time { return time.Now() }
|
||||||
|
|
||||||
type EpisodeService struct {
|
type EpisodeService struct {
|
||||||
queries *db.Queries
|
queries *db.Queries
|
||||||
jikan *jikan.Client
|
jikan *jikan.Client
|
||||||
providers []domain.EpisodeAvailabilityProvider
|
providers []domain.EpisodeAvailabilityProvider
|
||||||
clock Clock
|
titles domain.EpisodeTitleProvider
|
||||||
enabled bool
|
clock Clock
|
||||||
|
enabled bool
|
||||||
|
titleLoad singleflight.Group
|
||||||
|
classificationLoad singleflight.Group
|
||||||
|
cacheMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool) domain.EpisodeService {
|
||||||
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
|
return NewEpisodeServiceWithClock(queries, jikanClient, providers, titles, enabled, realClock{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
|
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool, clock Clock) *EpisodeService {
|
||||||
return &EpisodeService{
|
return &EpisodeService{
|
||||||
queries: queries,
|
queries: queries,
|
||||||
jikan: jikanClient,
|
jikan: jikanClient,
|
||||||
providers: providers,
|
providers: providers,
|
||||||
|
titles: titles,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
||||||
if !s.enabled {
|
|
||||||
return s.jikanOnly(ctx, anime, "legacy_disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !forceRefresh {
|
if !forceRefresh {
|
||||||
if cached, ok := s.getFreshCached(ctx, anime); ok {
|
if cached, ok := s.getFreshCached(ctx, anime); ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
@@ -125,19 +129,6 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
|
||||||
if jikanErr != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"episodes_jikan_metadata_failed",
|
|
||||||
"episodes",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"anime_id": anime.MalID,
|
|
||||||
},
|
|
||||||
jikanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
||||||
if providerErr != nil {
|
if providerErr != nil {
|
||||||
s.markFailure(ctx, anime, providerErr)
|
s.markFailure(ctx, anime, providerErr)
|
||||||
@@ -153,19 +144,10 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
|
|||||||
)
|
)
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
if jikanErr == nil {
|
|
||||||
storeCtx := ctx
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
storeCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
return s.store(storeCtx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
|
|
||||||
}
|
|
||||||
return domain.CanonicalEpisodeList{}, providerErr
|
return domain.CanonicalEpisodeList{}, providerErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.store(ctx, anime, jikanEpisodes, availability, source, now, true)
|
return s.store(ctx, anime, availability, source, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {
|
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {
|
||||||
@@ -215,15 +197,3 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
|
|||||||
}
|
}
|
||||||
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
|
|
||||||
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CanonicalEpisodeList{}, err
|
|
||||||
}
|
|
||||||
return domain.CanonicalEpisodeList{
|
|
||||||
AnimeID: anime.MalID,
|
|
||||||
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}, anime.Episodes),
|
|
||||||
Source: source,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +30,19 @@ func TestMergeEpisodesUsesProviderAvailabilityAsSourceOfTruth(t *testing.T) {
|
|||||||
assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false)
|
assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodesUsesProviderTitles(t *testing.T) {
|
||||||
|
episodes := mergeEpisodes(nil, domain.EpisodeAvailability{
|
||||||
|
Sub: []int{1, 2},
|
||||||
|
Titles: map[int]string{1: "The Apocalypse", 2: "The Battle of Loka"},
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
if len(episodes) != 2 {
|
||||||
|
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
|
||||||
|
}
|
||||||
|
assertEpisode(t, episodes[0], 1, "The Apocalypse", true, false, true, false)
|
||||||
|
assertEpisode(t, episodes[1], 2, "The Battle of Loka", true, false, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
|
func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
|
||||||
episodes := mergeEpisodes([]jikan.Episode{
|
episodes := mergeEpisodes([]jikan.Episode{
|
||||||
{MalID: 101, Episode: "1", Title: "Start"},
|
{MalID: 101, Episode: "1", Title: "Start"},
|
||||||
@@ -41,6 +57,74 @@ func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
|
|||||||
assertEpisode(t, episodes[1], 2, "Second", false, false, false, false)
|
assertEpisode(t, episodes[1], 2, "Second", false, false, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodesSkipsFutureJikanEpisodesWithoutProviderAvailability(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}
|
||||||
|
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
|
||||||
|
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
|
||||||
|
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
|
||||||
|
]`), now, false)
|
||||||
|
|
||||||
|
if len(episodes) != 0 {
|
||||||
|
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodesSkipsUndatedJikanEpisodesForAiringAnimeWithoutProviderAvailability(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}
|
||||||
|
episodes := mergeEpisodesForAnime(anime, []jikan.Episode{
|
||||||
|
{MalID: 1, Title: "Episode 1"},
|
||||||
|
{MalID: 2, Title: "Episode 2"},
|
||||||
|
}, now, false)
|
||||||
|
|
||||||
|
if len(episodes) != 0 {
|
||||||
|
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodesKeepsReleasedJikanEpisodesWithoutProviderAvailability(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}
|
||||||
|
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
|
||||||
|
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
|
||||||
|
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
|
||||||
|
]`), now, false)
|
||||||
|
|
||||||
|
if len(episodes) != 1 {
|
||||||
|
t.Fatalf("len(episodes) = %d, want 1", len(episodes))
|
||||||
|
}
|
||||||
|
assertEpisode(t, episodes[0], 1, "Episode 1", false, false, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodesTreatsEmptyProviderAvailabilityAsAuthoritative(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||||
|
}}
|
||||||
|
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
|
||||||
|
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"}
|
||||||
|
]`), now, true)
|
||||||
|
|
||||||
|
if len(episodes) != 0 {
|
||||||
|
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
|
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
|
||||||
episodes := mergeEpisodes([]jikan.Episode{
|
episodes := mergeEpisodes([]jikan.Episode{
|
||||||
{MalID: 201, Episode: "", Title: "Missing"},
|
{MalID: 201, Episode: "", Title: "Missing"},
|
||||||
@@ -113,6 +197,19 @@ func TestIsCanonicalEpisodePayloadValidRejectsProviderEpisodesWithoutAvailabilit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsCanonicalEpisodePayloadValidTrustsProviderCount(t *testing.T) {
|
||||||
|
payload := domain.CanonicalEpisodeList{
|
||||||
|
Source: "AllAnime",
|
||||||
|
Episodes: []domain.CanonicalEpisode{
|
||||||
|
{Number: 13, Title: "Episode 13", HasSub: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isCanonicalEpisodePayloadValid(payload, 12) {
|
||||||
|
t.Fatal("expected provider episode count to override Jikan metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) {
|
func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) {
|
||||||
payload := domain.CanonicalEpisodeList{
|
payload := domain.CanonicalEpisodeList{
|
||||||
Source: "jikan_fallback",
|
Source: "jikan_fallback",
|
||||||
@@ -127,6 +224,89 @@ func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeCachedPayloadRejectsUncheckedAiringJikanFallback(t *testing.T) {
|
||||||
|
svc := &EpisodeService{}
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
}}
|
||||||
|
raw := `{"anime_id":62076,"source":"jikan_fallback","episodes":[{"number":1,"title":"Episode 1"}]}`
|
||||||
|
|
||||||
|
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
|
||||||
|
t.Fatal("expected unchecked airing jikan fallback cache to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCachedPayloadRejectsOldReleaseCheckedAiringFallback(t *testing.T) {
|
||||||
|
svc := &EpisodeService{}
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
}}
|
||||||
|
raw := `{"anime_id":62076,"source":"jikan_fallback","release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
|
||||||
|
|
||||||
|
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
|
||||||
|
t.Fatal("expected old release-checked jikan fallback cache to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCachedPayloadRejectsOldProviderPayload(t *testing.T) {
|
||||||
|
svc := &EpisodeService{}
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
}}
|
||||||
|
raw := `{"anime_id":62076,"source":"AllAnime","availability_version":2,"episodes":[{"number":1,"title":"Episode 1","has_sub":true}]}`
|
||||||
|
|
||||||
|
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
|
||||||
|
t.Fatal("expected old airing provider cache to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCachedPayloadRejectsCurrentJikanFallback(t *testing.T) {
|
||||||
|
svc := &EpisodeService{}
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 62076,
|
||||||
|
Airing: true,
|
||||||
|
}}
|
||||||
|
raw := `{"anime_id":62076,"source":"jikan_fallback","availability_version":2,"release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
|
||||||
|
|
||||||
|
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
|
||||||
|
t.Fatal("expected Jikan fallback cache to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichCachedPayloadAddsRefreshMetadata(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC)
|
||||||
|
payload := enrichCachedPayload(domain.CanonicalEpisodeList{
|
||||||
|
AnimeID: 59970,
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1}},
|
||||||
|
Source: "AllAnime",
|
||||||
|
}, db.EpisodeAvailabilityCache{
|
||||||
|
NextRefreshAt: sql.NullTime{Time: now.Add(time.Hour), Valid: true},
|
||||||
|
RetryUntilAt: sql.NullTime{Time: now.Add(30 * time.Minute), Valid: true},
|
||||||
|
LastAttemptAt: sql.NullTime{Time: now.Add(-5 * time.Minute), Valid: true},
|
||||||
|
LastSuccessAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
|
||||||
|
FailureCount: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
if payload.NextRefreshAt != "2026-06-27T12:00:00Z" {
|
||||||
|
t.Fatalf("NextRefreshAt = %q, want RFC3339 timestamp", payload.NextRefreshAt)
|
||||||
|
}
|
||||||
|
if payload.RetryUntilAt != "2026-06-27T11:30:00Z" {
|
||||||
|
t.Fatalf("RetryUntilAt = %q, want RFC3339 timestamp", payload.RetryUntilAt)
|
||||||
|
}
|
||||||
|
if payload.LastAttemptAt != "2026-06-27T10:55:00Z" {
|
||||||
|
t.Fatalf("LastAttemptAt = %q, want RFC3339 timestamp", payload.LastAttemptAt)
|
||||||
|
}
|
||||||
|
if payload.LastSuccessAt != "2026-06-27T10:00:00Z" {
|
||||||
|
t.Fatalf("LastSuccessAt = %q, want RFC3339 timestamp", payload.LastSuccessAt)
|
||||||
|
}
|
||||||
|
if payload.FailureCount != 2 {
|
||||||
|
t.Fatalf("FailureCount = %d, want 2", payload.FailureCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
||||||
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
||||||
anime.Broadcast.Day = "Saturdays"
|
anime.Broadcast.Day = "Saturdays"
|
||||||
@@ -160,6 +340,16 @@ func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeJikanEpisodes(t *testing.T, raw string) []jikan.Episode {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var episodes []jikan.Episode
|
||||||
|
if err := json.Unmarshal([]byte(raw), &episodes); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal episodes: %v", err)
|
||||||
|
}
|
||||||
|
return episodes
|
||||||
|
}
|
||||||
|
|
||||||
func assertEpisode(t *testing.T, got domain.CanonicalEpisode, number int, title string, sub bool, dub bool, subOnly bool, filler bool) {
|
func assertEpisode(t *testing.T, got domain.CanonicalEpisode, number int, title string, sub bool, dub bool, subOnly bool, filler bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got.Number != number || got.Title != title || got.HasSub != sub || got.HasDub != dub || got.SubOnly != subOnly || got.Filler != filler || got.Recap {
|
if got.Number != number || got.Title != title || got.HasSub != sub || got.HasDub != dub || got.SubOnly != subOnly || got.Filler != filler || got.Recap {
|
||||||
|
|||||||
140
internal/episodes/service/titles.go
Normal file
140
internal/episodes/service/titles.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *EpisodeService) EnrichEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||||
|
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability is not cached")
|
||||||
|
}
|
||||||
|
if !hasPlaceholderTitles(payload.Episodes) {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if s.titles == nil {
|
||||||
|
return payload, errors.New("episode titles: provider is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err, _ := s.titleLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
|
||||||
|
return s.loadEpisodeTitles(ctx, anime)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
return value.(domain.CanonicalEpisodeList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EpisodeService) loadEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||||
|
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
|
||||||
|
}
|
||||||
|
|
||||||
|
providerID, err := s.providerID(ctx, anime, s.titles, titleCandidates(anime))
|
||||||
|
if err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
titles, err := s.titles.GetEpisodeTitlesByProviderID(ctx, providerID, anime, len(payload.Episodes))
|
||||||
|
if err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := mergeMissingTitles(payload.Episodes, titles)
|
||||||
|
if !changed {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.Info(
|
||||||
|
"episode_titles_enriched",
|
||||||
|
"episodes",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
"provider": s.titles.Name(),
|
||||||
|
"titles": len(titles),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EpisodeService) cachedEpisodePayload(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, db.EpisodeAvailabilityCache, bool) {
|
||||||
|
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||||
|
if err != nil {
|
||||||
|
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
|
||||||
|
}
|
||||||
|
payload, ok := s.decodeCachedPayload(anime, row.Data)
|
||||||
|
if !ok {
|
||||||
|
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
|
||||||
|
}
|
||||||
|
return enrichCachedPayload(payload, row), row, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EpisodeService) storeEnrichedPayload(ctx context.Context, row db.EpisodeAvailabilityCache, payload domain.CanonicalEpisodeList) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("episode metadata: encode cache: %w", err)
|
||||||
|
}
|
||||||
|
err = s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{
|
||||||
|
AnimeID: row.AnimeID,
|
||||||
|
Data: string(body),
|
||||||
|
NextRefreshAt: row.NextRefreshAt,
|
||||||
|
RetryUntilAt: row.RetryUntilAt,
|
||||||
|
LastAttemptAt: row.LastAttemptAt,
|
||||||
|
LastSuccessAt: row.LastSuccessAt,
|
||||||
|
FailureCount: row.FailureCount,
|
||||||
|
LastError: row.LastError,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("episode metadata: update cache: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPlaceholderTitles(episodes []domain.CanonicalEpisode) bool {
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if isPlaceholderTitle(episode.Number, episode.Title) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMissingTitles(episodes []domain.CanonicalEpisode, titles map[int]string) bool {
|
||||||
|
changed := false
|
||||||
|
for i := range episodes {
|
||||||
|
episode := &episodes[i]
|
||||||
|
title := strings.TrimSpace(titles[episode.Number])
|
||||||
|
if title == "" || !isPlaceholderTitle(episode.Number, episode.Title) || title == episode.Title {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
episode.Title = title
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlaceholderTitle(number int, title string) bool {
|
||||||
|
return strings.TrimSpace(title) == fmt.Sprintf("Episode %d", number)
|
||||||
|
}
|
||||||
145
internal/episodes/service/titles_test.go
Normal file
145
internal/episodes/service/titles_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type titleProviderStub struct {
|
||||||
|
loads int
|
||||||
|
resolves int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *titleProviderStub) Name() string { return "TVmaze" }
|
||||||
|
|
||||||
|
func (p *titleProviderStub) ResolveEpisodeProviderID(context.Context, int, []string) (string, error) {
|
||||||
|
p.resolves++
|
||||||
|
return "80712", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *titleProviderStub) GetEpisodeTitlesByProviderID(context.Context, string, domain.Anime, int) (map[int]string, error) {
|
||||||
|
p.loads++
|
||||||
|
return map[int]string{1: "TVmaze title one", 2: "TVmaze title two"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeMissingTitlesPreservesAllAnimeTitles(t *testing.T) {
|
||||||
|
episodes := []domain.CanonicalEpisode{
|
||||||
|
{Number: 1, Title: "AllAnime title"},
|
||||||
|
{Number: 2, Title: "Episode 2"},
|
||||||
|
{Number: 3, Title: "Episode 3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := mergeMissingTitles(episodes, map[int]string{
|
||||||
|
1: "TVmaze title one",
|
||||||
|
2: "TVmaze title two",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected missing title to be enriched")
|
||||||
|
}
|
||||||
|
if episodes[0].Title != "AllAnime title" {
|
||||||
|
t.Fatalf("AllAnime title was overwritten: %q", episodes[0].Title)
|
||||||
|
}
|
||||||
|
if episodes[1].Title != "TVmaze title two" {
|
||||||
|
t.Fatalf("episode 2 title = %q", episodes[1].Title)
|
||||||
|
}
|
||||||
|
if episodes[2].Title != "Episode 3" {
|
||||||
|
t.Fatalf("episode 3 title = %q", episodes[2].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichEpisodeTitlesCachesTVmazeTitles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
sqlDB := newTitleTestDB(ctx, t)
|
||||||
|
provider := &titleProviderStub{}
|
||||||
|
svc := &EpisodeService{queries: db.New(sqlDB), titles: provider, clock: realClock{}}
|
||||||
|
anime := domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 59846,
|
||||||
|
Title: "Saigo ni Hitotsu dake Onegai shitemo Yoroshii deshou ka",
|
||||||
|
TitleEnglish: "May I Ask for One Final Thing?",
|
||||||
|
}}
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
assertEnrichedTitleList(t, svc, anime)
|
||||||
|
}
|
||||||
|
if provider.resolves != 1 || provider.loads != 1 {
|
||||||
|
t.Fatalf("provider calls = resolves:%d loads:%d, want 1 each", provider.resolves, provider.loads)
|
||||||
|
}
|
||||||
|
assertCachedTitles(ctx, t, sqlDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTitleTestDB(ctx context.Context, t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
for _, statement := range []string{
|
||||||
|
`CREATE TABLE episode_availability_cache (
|
||||||
|
anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME,
|
||||||
|
retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE episode_provider_mapping (
|
||||||
|
anime_id INTEGER NOT NULL, provider TEXT NOT NULL, provider_show_id TEXT NOT NULL,
|
||||||
|
failed_until DATETIME, last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (anime_id, provider)
|
||||||
|
)`,
|
||||||
|
`INSERT INTO episode_availability_cache (
|
||||||
|
anime_id, data, next_refresh_at, failure_count, last_error
|
||||||
|
) VALUES (
|
||||||
|
59846,
|
||||||
|
'{"anime_id":59846,"source":"AllAnime","availability_version":4,"episodes":[{"number":1,"title":"AllAnime title","has_sub":true},{"number":2,"title":"Episode 2","has_sub":true}]}',
|
||||||
|
'2999-01-01T00:00:00Z', 2, 'preserve me'
|
||||||
|
)`,
|
||||||
|
} {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEnrichedTitleList(t *testing.T, svc *EpisodeService, anime domain.Anime) {
|
||||||
|
t.Helper()
|
||||||
|
list, err := svc.EnrichEpisodeTitles(context.Background(), anime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list.Episodes[0].Title != "AllAnime title" || list.Episodes[1].Title != "TVmaze title two" {
|
||||||
|
t.Fatalf("episodes = %#v", list.Episodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCachedTitles(ctx context.Context, t *testing.T, sqlDB *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
var raw, lastError string
|
||||||
|
var failureCount int
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `SELECT data, failure_count, last_error FROM episode_availability_cache WHERE anime_id = 59846`).Scan(&raw, &failureCount, &lastError); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cached domain.CanonicalEpisodeList
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cached.Episodes[1].Title != "TVmaze title two" || failureCount != 2 || lastError != "preserve me" {
|
||||||
|
t.Fatalf("cached = %#v failure_count=%d last_error=%q", cached.Episodes, failureCount, lastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/observability/bytes_test.go
Normal file
29
internal/observability/bytes_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatBytesBelow1KB(t *testing.T) {
|
||||||
|
if got := formatBytes(512.0); got != "512B" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "512B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBytesInKB(t *testing.T) {
|
||||||
|
if got := formatBytes(2048.0); got != "2.0KB" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "2.0KB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBytesInMB(t *testing.T) {
|
||||||
|
if got := formatBytes(5_242_880.0); got != "5.0MB" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "5.0MB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBytesReturnsStringAsIs(t *testing.T) {
|
||||||
|
if got := formatBytes("unknown"); got != "unknown" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/observability/clone_test.go
Normal file
29
internal/observability/clone_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCloneFieldsReturnsNilForNil(t *testing.T) {
|
||||||
|
if got := cloneFields(nil); got != nil {
|
||||||
|
t.Fatalf("expected nil, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneFieldsReturnsNilForEmpty(t *testing.T) {
|
||||||
|
if got := cloneFields(map[string]any{}); got != nil {
|
||||||
|
t.Fatalf("expected nil, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneFieldsCopiesAllEntries(t *testing.T) {
|
||||||
|
original := map[string]any{"a": 1, "b": "two"}
|
||||||
|
cp := cloneFields(original)
|
||||||
|
if cp["a"] != 1 || cp["b"] != "two" {
|
||||||
|
t.Fatalf("clone = %#v, want %#v", cp, original)
|
||||||
|
}
|
||||||
|
cp["c"] = 3
|
||||||
|
if _, exists := original["c"]; exists {
|
||||||
|
t.Fatal("clone modified original")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/observability/duration_test.go
Normal file
23
internal/observability/duration_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatDurationMillisFormatsFloat(t *testing.T) {
|
||||||
|
if got := formatDurationMillis(123.456); got != "123.456ms" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "123.456ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDurationMillisFormatsInt(t *testing.T) {
|
||||||
|
if got := formatDurationMillis(42); got != "42ms" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "42ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDurationMillisReturnsStringAsIs(t *testing.T) {
|
||||||
|
if got := formatDurationMillis("fast"); got != "fast" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "fast")
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/observability/field_test.go
Normal file
52
internal/observability/field_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPopFieldReturnsEmptyForNil(t *testing.T) {
|
||||||
|
if got := popField(nil, "key"); got != "" {
|
||||||
|
t.Fatalf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopFieldReturnsEmptyForMissingKey(t *testing.T) {
|
||||||
|
if got := popField(map[string]any{"a": 1}, "b"); got != "" {
|
||||||
|
t.Fatalf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopFieldReturnsAndDeletes(t *testing.T) {
|
||||||
|
m := map[string]any{"status": 200, "other": "x"}
|
||||||
|
got := popField(m, "status")
|
||||||
|
if got != "200" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "200")
|
||||||
|
}
|
||||||
|
if _, exists := m["status"]; exists {
|
||||||
|
t.Fatal("popField did not delete key")
|
||||||
|
}
|
||||||
|
if m["other"] != "x" {
|
||||||
|
t.Fatal("popField deleted other key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopFieldFormatsDuration(t *testing.T) {
|
||||||
|
got := popField(map[string]any{"duration_ms": 123.4}, "duration_ms")
|
||||||
|
if got != "123.4ms" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "123.4ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopFieldFormatsBytes(t *testing.T) {
|
||||||
|
got := popField(map[string]any{"bytes": 2048.0}, "bytes")
|
||||||
|
if got != "2.0KB" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "2.0KB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopFieldPassthroughString(t *testing.T) {
|
||||||
|
got := popField(map[string]any{"method": "GET"}, "method")
|
||||||
|
if got != "GET" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "GET")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,28 @@ func (fxLogger) LogEvent(event fxevent.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func describeFXEventError(event fxevent.Event) (string, map[string]any, error) {
|
func describeFXEventError(event fxevent.Event) (string, map[string]any, error) {
|
||||||
|
if eventName, fields, ok, err := describeFXBuildEventError(event); ok {
|
||||||
|
return eventName, fields, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return describeFXLifecycleEventError(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeFXBuildEventError(event fxevent.Event) (string, map[string]any, bool, error) {
|
||||||
switch e := event.(type) {
|
switch e := event.(type) {
|
||||||
case *fxevent.Provided:
|
case *fxevent.Provided:
|
||||||
return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, e.Err
|
return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, true, e.Err
|
||||||
case *fxevent.Invoked:
|
case *fxevent.Invoked:
|
||||||
return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, e.Err
|
return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, true, e.Err
|
||||||
case *fxevent.Run:
|
case *fxevent.Run:
|
||||||
return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, e.Err
|
return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, true, e.Err
|
||||||
|
default:
|
||||||
|
return "", nil, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeFXLifecycleEventError(event fxevent.Event) (string, map[string]any, error) {
|
||||||
|
switch e := event.(type) {
|
||||||
case *fxevent.OnStartExecuted:
|
case *fxevent.OnStartExecuted:
|
||||||
return "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
|
return "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
|
||||||
case *fxevent.OnStopExecuted:
|
case *fxevent.OnStopExecuted:
|
||||||
|
|||||||
119
internal/observability/fx_test.go
Normal file
119
internal/observability/fx_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxevent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDescribeFXEventErrorDescribesFailedBuildEvents(t *testing.T) {
|
||||||
|
err := errors.New("fx failed")
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
event fxevent.Event
|
||||||
|
wantName string
|
||||||
|
wantFields map[string]any
|
||||||
|
}{
|
||||||
|
"provided": {
|
||||||
|
event: &fxevent.Provided{ConstructorName: "newServer", Err: err},
|
||||||
|
wantName: "fx_provide_failed",
|
||||||
|
wantFields: map[string]any{"constructor": "newServer"},
|
||||||
|
},
|
||||||
|
"invoked": {
|
||||||
|
event: &fxevent.Invoked{FunctionName: "startServer", Err: err},
|
||||||
|
wantName: "fx_invoke_failed",
|
||||||
|
wantFields: map[string]any{"function": "startServer"},
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
event: &fxevent.Run{Name: "newRepo", Kind: "provide", Err: err},
|
||||||
|
wantName: "fx_run_failed",
|
||||||
|
wantFields: map[string]any{"function": "newRepo", "kind": "provide"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeFXEventErrorDescribesFailedLifecycleEvents(t *testing.T) {
|
||||||
|
err := errors.New("fx failed")
|
||||||
|
runtime := 12 * time.Millisecond
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
event fxevent.Event
|
||||||
|
wantName string
|
||||||
|
wantFields map[string]any
|
||||||
|
}{
|
||||||
|
"on start executed": {
|
||||||
|
event: &fxevent.OnStartExecuted{CallerName: "server", FunctionName: "listen", Runtime: runtime, Err: err},
|
||||||
|
wantName: "fx_on_start_failed",
|
||||||
|
wantFields: map[string]any{"caller": "server", "function": "listen", "runtime": runtime},
|
||||||
|
},
|
||||||
|
"on stop executed": {
|
||||||
|
event: &fxevent.OnStopExecuted{CallerName: "server", FunctionName: "close", Runtime: runtime, Err: err},
|
||||||
|
wantName: "fx_on_stop_failed",
|
||||||
|
wantFields: map[string]any{"caller": "server", "function": "close", "runtime": runtime},
|
||||||
|
},
|
||||||
|
"started": {
|
||||||
|
event: &fxevent.Started{Err: err},
|
||||||
|
wantName: "fx_start_failed",
|
||||||
|
},
|
||||||
|
"stopped": {
|
||||||
|
event: &fxevent.Stopped{Err: err},
|
||||||
|
wantName: "fx_stop_failed",
|
||||||
|
},
|
||||||
|
"rolling back": {
|
||||||
|
event: &fxevent.RollingBack{StartErr: err},
|
||||||
|
wantName: "fx_rollback_start",
|
||||||
|
},
|
||||||
|
"rolled back": {
|
||||||
|
event: &fxevent.RolledBack{Err: err},
|
||||||
|
wantName: "fx_rollback_failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertFXEventError(t *testing.T, event fxevent.Event, wantName string, wantFields map[string]any, wantErr error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
gotName, gotFields, gotErr := describeFXEventError(event)
|
||||||
|
|
||||||
|
if gotName != wantName {
|
||||||
|
t.Fatalf("event name = %q, want %q", gotName, wantName)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotFields, wantFields) {
|
||||||
|
t.Fatalf("fields = %#v, want %#v", gotFields, wantFields)
|
||||||
|
}
|
||||||
|
if !errors.Is(gotErr, wantErr) {
|
||||||
|
t.Fatalf("error = %v, want %v", gotErr, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeFXEventErrorIgnoresEventsWithoutErrors(t *testing.T) {
|
||||||
|
tests := map[string]fxevent.Event{
|
||||||
|
"provided": &fxevent.Provided{ConstructorName: "newServer"},
|
||||||
|
"unknown": &fxevent.Supplied{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, event := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, _, gotErr := describeFXEventError(event)
|
||||||
|
|
||||||
|
if gotErr != nil {
|
||||||
|
t.Fatalf("error = %v, want nil", gotErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/observability/localip_test.go
Normal file
27
internal/observability/localip_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsLocalClientIPReturnsTrueForLoopback(t *testing.T) {
|
||||||
|
for _, ip := range []string{"127.0.0.1", "::1"} {
|
||||||
|
if !isLocalClientIP(ip) {
|
||||||
|
t.Fatalf("isLocalClientIP(%q) = false, want true", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalClientIPReturnsFalseForExternal(t *testing.T) {
|
||||||
|
for _, ip := range []string{"8.8.8.8", "192.168.1.1", ""} {
|
||||||
|
if isLocalClientIP(ip) {
|
||||||
|
t.Fatalf("isLocalClientIP(%q) = true, want false", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalClientIPReturnsFalseForInvalid(t *testing.T) {
|
||||||
|
if isLocalClientIP("not-an-ip") {
|
||||||
|
t.Fatal("isLocalClientIP('not-an-ip') = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/observability/quote_test.go
Normal file
38
internal/observability/quote_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuoteIfNeededReturnsEmptyQuoted(t *testing.T) {
|
||||||
|
if got := quoteIfNeeded(""); got != `""` {
|
||||||
|
t.Fatalf("got %q, want %q", got, `""`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteIfNeededReturnsPlainString(t *testing.T) {
|
||||||
|
if got := quoteIfNeeded("hello"); got != "hello" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteIfNeededQuotesStringWithEquals(t *testing.T) {
|
||||||
|
got := quoteIfNeeded("key=value")
|
||||||
|
if got != `"key=value"` {
|
||||||
|
t.Fatalf("got %q, want %q", got, `"key=value"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteIfNeededQuotesStringWithSpace(t *testing.T) {
|
||||||
|
got := quoteIfNeeded("hello world")
|
||||||
|
if got != `"hello world"` {
|
||||||
|
t.Fatalf("got %q, want %q", got, `"hello world"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteIfNeededQuotesStringWithNewline(t *testing.T) {
|
||||||
|
got := quoteIfNeeded("line1\nline2")
|
||||||
|
if got != `"line1\nline2"` {
|
||||||
|
t.Fatalf("got %q, want %q", got, `"line1\nline2"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/observability/status_test.go
Normal file
19
internal/observability/status_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatHTTPStatusReturnsPlainWhenNoColor(t *testing.T) {
|
||||||
|
colorLogs = false
|
||||||
|
if got := formatHTTPStatus(200); got != "200" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatHTTPStatusReturnsFallback(t *testing.T) {
|
||||||
|
colorLogs = false
|
||||||
|
if got := formatHTTPStatus("ok"); got != "ok" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/playback/handler/episode_metadata_test.go
Normal file
52
internal/playback/handler/episode_metadata_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingSkipSegmentPlaybackService struct {
|
||||||
|
domain.PlaybackService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s failingSkipSegmentPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
|
||||||
|
return errors.New("database constraint skip_segments_user_id_fkey failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertSkipSegmentRouteHidesServiceError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("User", &domain.User{User: db.User{ID: "user-1"}})
|
||||||
|
})
|
||||||
|
h := NewPlaybackHandler(failingSkipSegmentPlaybackService{}, nil)
|
||||||
|
h.Register(router)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"mal_id":123,"episode":1,"skip_type":"op","start_time":10,"end_time":90}`)
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watch/segments", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
responseBody := rec.Body.String()
|
||||||
|
if !strings.Contains(responseBody, `"error":"invalid skip segment"`) {
|
||||||
|
t.Fatalf("expected stable public error, got %s", responseBody)
|
||||||
|
}
|
||||||
|
if strings.Contains(responseBody, "database constraint") {
|
||||||
|
t.Fatalf("expected private service error to stay out of response, got %s", responseBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/internal/playback/proxytarget"
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
netutil "mal/pkg/net"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -21,15 +21,17 @@ type PlaybackHandler struct {
|
|||||||
proxyClient *http.Client
|
proxyClient *http.Client
|
||||||
streamingClient *http.Client
|
streamingClient *http.Client
|
||||||
subtitleCache *subtitleCache
|
subtitleCache *subtitleCache
|
||||||
|
manifestCache *manifestCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
|
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
|
||||||
return &PlaybackHandler{
|
return &PlaybackHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
animeSvc: animeSvc,
|
animeSvc: animeSvc,
|
||||||
proxyClient: netutil.NewClient(),
|
proxyClient: proxytarget.NewClient(60 * time.Second),
|
||||||
streamingClient: netutil.NewStreamingClient(),
|
streamingClient: proxytarget.NewStreamingClient(),
|
||||||
subtitleCache: newSubtitleCache(10*time.Minute, 256),
|
subtitleCache: newSubtitleCache(10*time.Minute, 256),
|
||||||
|
manifestCache: newManifestCache(manifestCacheMaxEntries),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +39,84 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/anime/:id/watch", h.HandleWatchPage)
|
r.GET("/anime/:id/watch", h.HandleWatchPage)
|
||||||
r.POST("/api/watch-progress", h.HandleSaveProgress)
|
r.POST("/api/watch-progress", h.HandleSaveProgress)
|
||||||
r.POST("/api/watch-complete", h.HandleWatchComplete)
|
r.POST("/api/watch-complete", h.HandleWatchComplete)
|
||||||
|
r.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
|
||||||
|
r.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
|
||||||
r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
|
r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
|
||||||
r.POST("/api/watch/segments", h.HandleUpsertSkipSegment)
|
r.POST("/api/watch/segments", h.HandleUpsertSkipSegment)
|
||||||
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
|
|
||||||
r.GET("/watch/proxy/stream", h.HandleProxyStream)
|
r.GET("/watch/proxy/stream", h.HandleProxyStream)
|
||||||
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
|
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) HandleEpisodeClassifications(c *gin.Context) {
|
||||||
|
animeID, err := strconv.Atoi(c.Param("animeId"))
|
||||||
|
if err != nil || animeID <= 0 {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enricher, ok := h.svc.(domain.PlaybackEpisodeClassificationService)
|
||||||
|
if !ok {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode classifications are unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodes, err := enricher.EnrichEpisodeClassifications(c.Request.Context(), animeID)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
"watch_episode_classifications_failed",
|
||||||
|
"playback",
|
||||||
|
"episode classifications are unavailable",
|
||||||
|
map[string]any{"anime_id": animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classifications := make([]gin.H, 0, len(episodes))
|
||||||
|
for _, episode := range episodes {
|
||||||
|
classifications = append(classifications, gin.H{
|
||||||
|
"number": episode.Number,
|
||||||
|
"filler": episode.Filler,
|
||||||
|
"recap": episode.Recap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, classifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) HandleEpisodeTitles(c *gin.Context) {
|
||||||
|
animeID, err := strconv.Atoi(c.Param("animeId"))
|
||||||
|
if err != nil || animeID <= 0 {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enricher, ok := h.svc.(domain.PlaybackEpisodeTitleService)
|
||||||
|
if !ok {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode titles are unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodes, err := enricher.EnrichEpisodeTitles(c.Request.Context(), animeID)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
"watch_episode_titles_failed",
|
||||||
|
"playback",
|
||||||
|
"episode titles are unavailable",
|
||||||
|
map[string]any{"anime_id": animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
titles := make([]gin.H, 0, len(episodes))
|
||||||
|
for _, episode := range episodes {
|
||||||
|
titles = append(titles, gin.H{"number": episode.Number, "title": episode.Title})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, titles)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
@@ -56,12 +129,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
|||||||
user := server.CurrentUser(c)
|
user := server.CurrentUser(c)
|
||||||
userID := server.CurrentUserID(c)
|
userID := server.CurrentUserID(c)
|
||||||
|
|
||||||
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
|
ctx := domain.WithDeferredPlaybackData(c.Request.Context())
|
||||||
|
data, err := h.svc.BuildWatchData(ctx, id, []string{}, ep, mode, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if data.Anime.MalID == 0 && data.WatchData.MalID == 0 && len(data.Episodes) == 0 {
|
if data.Anime.MalID == 0 && data.WatchData.MalID == 0 && len(data.Episodes) == 0 {
|
||||||
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
fmt.Printf("error fetching anime for error page: %v\n", fetchErr)
|
observability.Warn("error_page_anime_fetch_failed", "playback", "", map[string]any{"anime_id": id}, fetchErr)
|
||||||
}
|
}
|
||||||
data = domain.WatchPageData{
|
data = domain.WatchPageData{
|
||||||
Anime: anime,
|
Anime: anime,
|
||||||
@@ -79,7 +153,17 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.Error = err.Error()
|
observability.LogContext(
|
||||||
|
c.Request.Context(),
|
||||||
|
observability.LogLevelError,
|
||||||
|
"watch_page_build_failed",
|
||||||
|
"playback",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": id, "episode": ep, "mode": mode, "user_id": userID, "request_path": c.Request.URL.Path},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
data.Error = "failed to load playback data"
|
||||||
data.User = user
|
data.User = user
|
||||||
data.CurrentPath = c.Request.URL.Path
|
data.CurrentPath = c.Request.URL.Path
|
||||||
|
|
||||||
@@ -109,10 +193,14 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mode := c.DefaultQuery("mode", "sub")
|
mode := c.DefaultQuery("mode", "sub")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if c.Query("refresh") == "1" {
|
||||||
|
ctx = domain.WithPlaybackSourceRefresh(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
userID := server.CurrentUserID(c)
|
userID := server.CurrentUserID(c)
|
||||||
|
|
||||||
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
data, err := h.svc.BuildWatchData(ctx, animeID, []string{}, episode, mode, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
server.RespondError(
|
server.RespondError(
|
||||||
c,
|
c,
|
||||||
@@ -243,61 +331,17 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
|
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"skip_segment_upsert_failed",
|
||||||
|
"playback",
|
||||||
|
"invalid skip segment",
|
||||||
|
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "skip_type": req.SkipType, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("animeId"))
|
|
||||||
if err != nil {
|
|
||||||
c.Status(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
|
||||||
if fetchErr != nil {
|
|
||||||
fmt.Printf("error fetching anime for thumbnail fill: %v\n", fetchErr)
|
|
||||||
}
|
|
||||||
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
|
|
||||||
epMap := make(map[int]domain.EpisodeData)
|
|
||||||
for _, ep := range allEpisodes {
|
|
||||||
epMap[ep.MalID] = ep
|
|
||||||
}
|
|
||||||
var filled []domain.EpisodeData
|
|
||||||
for i := 1; i <= anime.Episodes; i++ {
|
|
||||||
if ep, ok := epMap[i]; ok {
|
|
||||||
filled = append(filled, ep)
|
|
||||||
} else {
|
|
||||||
filled = append(filled, domain.EpisodeData{
|
|
||||||
MalID: i,
|
|
||||||
Title: fmt.Sprintf("Episode %d", i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allEpisodes = filled
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
MalID int `json:"mal_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]Result, len(allEpisodes))
|
|
||||||
for i, ep := range allEpisodes {
|
|
||||||
results[i] = Result{
|
|
||||||
MalID: ep.MalID,
|
|
||||||
Title: ep.Title,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, results)
|
|
||||||
}
|
|
||||||
|
|||||||
109
internal/playback/handler/manifest_cache.go
Normal file
109
internal/playback/handler/manifest_cache.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
manifestCacheVODTTL = 45 * time.Second
|
||||||
|
manifestCacheLiveTTL = 3 * time.Second
|
||||||
|
manifestCacheMaxEntries = 256
|
||||||
|
manifestCacheMaxBodySize = 2 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifestCacheEntry struct {
|
||||||
|
key string
|
||||||
|
body []byte
|
||||||
|
headers http.Header
|
||||||
|
status int
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
maxEntries int
|
||||||
|
entries map[string]*list.Element
|
||||||
|
lru *list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManifestCache(maxEntries int) *manifestCache {
|
||||||
|
if maxEntries <= 0 {
|
||||||
|
maxEntries = manifestCacheMaxEntries
|
||||||
|
}
|
||||||
|
return &manifestCache{
|
||||||
|
maxEntries: maxEntries,
|
||||||
|
entries: make(map[string]*list.Element, maxEntries),
|
||||||
|
lru: list.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestCacheKey(targetURL string, referer string) string {
|
||||||
|
return targetURL + "\x00" + referer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *manifestCache) get(key string, now time.Time) (manifestCacheEntry, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
el := c.entries[key]
|
||||||
|
if el == nil {
|
||||||
|
return manifestCacheEntry{}, false
|
||||||
|
}
|
||||||
|
entry, ok := el.Value.(manifestCacheEntry)
|
||||||
|
if !ok || !now.Before(entry.expiresAt) {
|
||||||
|
c.remove(el)
|
||||||
|
return manifestCacheEntry{}, false
|
||||||
|
}
|
||||||
|
c.lru.MoveToFront(el)
|
||||||
|
entry.body = append([]byte(nil), entry.body...)
|
||||||
|
entry.headers = entry.headers.Clone()
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *manifestCache) set(key string, status int, headers http.Header, body []byte, now time.Time) bool {
|
||||||
|
if status < http.StatusOK || status >= http.StatusMultipleChoices || len(body) > manifestCacheMaxBodySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := manifestCacheVODTTL
|
||||||
|
bodyText := string(body)
|
||||||
|
if strings.Contains(bodyText, "#EXTINF") && !strings.Contains(bodyText, "#EXT-X-ENDLIST") {
|
||||||
|
ttl = manifestCacheLiveTTL
|
||||||
|
}
|
||||||
|
entry := manifestCacheEntry{
|
||||||
|
key: key,
|
||||||
|
body: append([]byte(nil), body...),
|
||||||
|
headers: headers.Clone(),
|
||||||
|
status: status,
|
||||||
|
expiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if el := c.entries[key]; el != nil {
|
||||||
|
el.Value = entry
|
||||||
|
c.lru.MoveToFront(el)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c.entries[key] = c.lru.PushFront(entry)
|
||||||
|
for len(c.entries) > c.maxEntries {
|
||||||
|
back := c.lru.Back()
|
||||||
|
if back == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.remove(back)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *manifestCache) remove(el *list.Element) {
|
||||||
|
entry, ok := el.Value.(manifestCacheEntry)
|
||||||
|
if ok {
|
||||||
|
delete(c.entries, entry.key)
|
||||||
|
}
|
||||||
|
c.lru.Remove(el)
|
||||||
|
}
|
||||||
122
internal/playback/handler/manifest_cache_test.go
Normal file
122
internal/playback/handler/manifest_cache_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifestRoundTripper struct {
|
||||||
|
calls int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *manifestRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
rt.calls++
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Type": {"application/vnd.apple.mpegurl"},
|
||||||
|
"Content-Length": {"48"},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(strings.NewReader(rt.body)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestCacheTTLSizeAndLRU(t *testing.T) {
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
cache := newManifestCache(2)
|
||||||
|
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}}
|
||||||
|
vod := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
|
||||||
|
live := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n")
|
||||||
|
|
||||||
|
if !cache.set("vod", http.StatusOK, headers, vod, now) {
|
||||||
|
t.Fatal("VOD manifest was not cached")
|
||||||
|
}
|
||||||
|
if !cache.set("live", http.StatusOK, headers, live, now) {
|
||||||
|
t.Fatal("live manifest was not cached")
|
||||||
|
}
|
||||||
|
cache.get("vod", now)
|
||||||
|
cache.set("new", http.StatusOK, headers, vod, now)
|
||||||
|
|
||||||
|
if _, ok := cache.get("live", now); ok {
|
||||||
|
t.Fatal("least recently used manifest was not evicted")
|
||||||
|
}
|
||||||
|
if _, ok := cache.get("vod", now.Add(manifestCacheLiveTTL+time.Second)); !ok {
|
||||||
|
t.Fatal("VOD manifest expired at the live TTL")
|
||||||
|
}
|
||||||
|
if cache.set("large", http.StatusOK, headers, make([]byte, manifestCacheMaxBodySize+1), now) {
|
||||||
|
t.Fatal("oversized manifest was cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBoundedPlaylistRejectsOversizedBody(t *testing.T) {
|
||||||
|
_, err := readBoundedPlaylist(bytes.NewReader(make([]byte, manifestCacheMaxBodySize+1)))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("readBoundedPlaylist accepted an oversized body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedRawPlaylistIsRewrittenWithResponseTokens(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
svc := &rewritePlaybackService{}
|
||||||
|
h := &PlaybackHandler{svc: svc}
|
||||||
|
body := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
|
||||||
|
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}, "Content-Length": {"48"}}
|
||||||
|
|
||||||
|
responses := make([]string, 0, 2)
|
||||||
|
for range 2 {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
h.writeProxyPlaylist(ctx, http.StatusOK, headers, body, "https://cdn.example.test/master.m3u8", "")
|
||||||
|
responses = append(responses, recorder.Body.String())
|
||||||
|
if recorder.Header().Get("Content-Length") == "48" {
|
||||||
|
t.Fatal("rewritten playlist retained upstream Content-Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if responses[0] == responses[1] {
|
||||||
|
t.Fatalf("rewritten responses reused tokenized output: %q", responses[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(responses[0], "token=token-1") || !strings.Contains(responses[1], "token=token-2") {
|
||||||
|
t.Fatalf("responses did not contain separately issued tokens: %#v", responses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleProxyStreamServesCachedRawManifest(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
svc := &rewritePlaybackService{targetURL: "https://203.0.113.10/master.m3u8"}
|
||||||
|
rt := &manifestRoundTripper{body: "#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n"}
|
||||||
|
h := &PlaybackHandler{
|
||||||
|
svc: svc,
|
||||||
|
streamingClient: &http.Client{Transport: rt},
|
||||||
|
manifestCache: newManifestCache(2),
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/watch/proxy/stream", h.HandleProxyStream)
|
||||||
|
|
||||||
|
responses := make([]string, 0, 2)
|
||||||
|
for range 2 {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=opaque", nil)
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
responses = append(responses, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if rt.calls != 1 {
|
||||||
|
t.Fatalf("upstream calls = %d, want 1", rt.calls)
|
||||||
|
}
|
||||||
|
if responses[0] == responses[1] {
|
||||||
|
t.Fatalf("cached raw manifest reused rewritten tokens: %#v", responses)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type rewritePlaybackService struct {
|
type rewritePlaybackService struct {
|
||||||
targets []string
|
targets []string
|
||||||
|
targetURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
|
func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
|
||||||
@@ -31,7 +32,7 @@ func (s *rewritePlaybackService) SignProxyToken(targetURL, _ string, _ string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) {
|
func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) {
|
||||||
return "", "", nil
|
return s.targetURL, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
|
func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
|
"mal/internal/playback/proxytarget"
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -11,9 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
|
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
|
||||||
|
if err := proxytarget.Validate(targetURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate proxy target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build proxy request for %q: %w", targetURL, err)
|
return nil, fmt.Errorf("build proxy request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if referer != "" {
|
if referer != "" {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
errlog "mal/pkg"
|
errlog "mal/pkg"
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -17,68 +18,111 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
|
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusBadGateway)
|
c.Status(http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
cacheKey := manifestCacheKey(targetURL, referer)
|
||||||
req.Header.Set("Range", rangeHeader)
|
if h.writeCachedPlaylist(c, cacheKey, targetURL, referer) {
|
||||||
}
|
return
|
||||||
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
|
|
||||||
req.Header.Set("If-Range", ifRangeHeader)
|
|
||||||
}
|
}
|
||||||
|
copyProxyRangeHeaders(req, c)
|
||||||
|
|
||||||
resp, err := h.streamingClient.Do(req)
|
resp, err := h.streamingClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.Canceled) {
|
h.handleStreamRequestError(c, err)
|
||||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
|
||||||
recordPrivateGinError(c, err)
|
|
||||||
}
|
|
||||||
c.Status(http.StatusBadGateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
errlog.Log("failed to close proxy stream response body", resp.Body.Close())
|
errlog.Log("failed to close proxy stream response body", resp.Body.Close())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
h.writeStreamResponse(c, resp, cacheKey, targetURL, referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) writeCachedPlaylist(c *gin.Context, cacheKey string, targetURL string, referer string) bool {
|
||||||
|
if c.GetHeader("Range") != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cached, found := h.manifestCache.get(cacheKey, time.Now())
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
observability.Info("playback_manifest_cache_hit", "playback", "", nil)
|
||||||
|
h.writeProxyPlaylist(c, cached.status, cached.headers, cached.body, targetURL, referer)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyProxyRangeHeaders(req *http.Request, c *gin.Context) {
|
||||||
|
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
||||||
|
req.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
|
||||||
|
req.Header.Set("If-Range", ifRangeHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) handleStreamRequestError(c *gin.Context, err error) {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
safeErr := errors.New("stream upstream request failed")
|
||||||
|
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", nil, safeErr)
|
||||||
|
recordPrivateGinError(c, safeErr)
|
||||||
|
}
|
||||||
|
c.Status(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) writeStreamResponse(c *gin.Context, resp *http.Response, cacheKey string, targetURL string, referer string) {
|
||||||
if isHLSPlaylistResponse(targetURL, resp.Header) {
|
if isHLSPlaylistResponse(targetURL, resp.Header) {
|
||||||
h.writeProxyPlaylist(c, resp, targetURL, referer)
|
observability.Info("playback_manifest_cache_miss", "playback", "", nil)
|
||||||
|
body, readErr := readBoundedPlaylist(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
safeErr := errors.New("stream playlist read failed")
|
||||||
|
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", nil, safeErr)
|
||||||
|
recordPrivateGinError(c, safeErr)
|
||||||
|
c.Status(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.manifestCache.set(cacheKey, resp.StatusCode, resp.Header, body, time.Now())
|
||||||
|
h.writeProxyPlaylist(c, resp.StatusCode, resp.Header, body, targetURL, referer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
copyProxyHeaders(c.Writer.Header(), resp.Header)
|
copyProxyHeaders(c.Writer.Header(), resp.Header)
|
||||||
c.Status(resp.StatusCode)
|
c.Status(resp.StatusCode)
|
||||||
copyProxyResponseBody(c, resp.Body, targetURL)
|
copyProxyResponseBody(c, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyProxyResponseBody(c *gin.Context, body io.Reader, targetURL string) {
|
func copyProxyResponseBody(c *gin.Context, body io.Reader) {
|
||||||
n, err := io.Copy(c.Writer, body)
|
n, err := io.Copy(c.Writer, body)
|
||||||
if err == nil || errors.Is(err, context.Canceled) || c.Request.Context().Err() != nil {
|
if err == nil || errors.Is(err, context.Canceled) || c.Request.Context().Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"target_url": targetURL, "bytes_copied": n}, err)
|
observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"bytes_copied": n}, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, resp *http.Response, targetURL string, referer string) {
|
func readBoundedPlaylist(body io.Reader) ([]byte, error) {
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
data, err := io.ReadAll(io.LimitReader(body, netutil.MiB2+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
return nil, err
|
||||||
recordPrivateGinError(c, err)
|
|
||||||
c.Status(http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if len(data) > manifestCacheMaxBodySize {
|
||||||
|
return nil, errors.New("upstream playlist exceeds size limit")
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, status int, headers http.Header, body []byte, targetURL string, referer string) {
|
||||||
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
|
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
safeErr := errors.New("stream playlist rewrite failed")
|
||||||
recordPrivateGinError(c, err)
|
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", nil, safeErr)
|
||||||
|
recordPrivateGinError(c, safeErr)
|
||||||
c.Status(http.StatusBadGateway)
|
c.Status(http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
copyProxyHeaders(c.Writer.Header(), resp.Header)
|
copyProxyHeaders(c.Writer.Header(), headers)
|
||||||
c.Writer.Header().Del("Content-Length")
|
c.Writer.Header().Del("Content-Length")
|
||||||
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewritten))
|
c.Data(status, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|||||||
resp, err := h.proxyClient.Do(req)
|
resp, err := h.proxyClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
safeErr := errors.New("subtitle upstream request failed")
|
||||||
recordPrivateGinError(c, err)
|
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", nil, safeErr)
|
||||||
|
recordPrivateGinError(c, safeErr)
|
||||||
}
|
}
|
||||||
c.Status(http.StatusBadGateway)
|
c.Status(http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
@@ -46,8 +47,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
safeErr := errors.New("subtitle response read failed")
|
||||||
recordPrivateGinError(c, err)
|
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", nil, safeErr)
|
||||||
|
recordPrivateGinError(c, safeErr)
|
||||||
c.Status(http.StatusBadGateway)
|
c.Status(http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
78
internal/playback/handler/proxy_target_test.go
Normal file
78
internal/playback/handler/proxy_target_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type proxyTargetPlaybackService struct {
|
||||||
|
targetURL string
|
||||||
|
referer string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
|
||||||
|
return domain.WatchPageData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) SaveProgress(context.Context, string, int64, int, float64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) CompleteAnime(context.Context, string, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) SignProxyToken(string, string, string) (string, error) {
|
||||||
|
return "token", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) ResolveProxyToken(string, string) (string, string, error) {
|
||||||
|
return s.targetURL, s.referer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s proxyTargetPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingRoundTripper struct {
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *recordingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
rt.called = true
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("WEBVTT\n")),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleProxySubtitleRejectsUnsafeTargetBeforeFetch(t *testing.T) {
|
||||||
|
rt := &recordingRoundTripper{}
|
||||||
|
h := &PlaybackHandler{
|
||||||
|
svc: proxyTargetPlaybackService{targetURL: "http://127.0.0.1/subtitle.vtt"},
|
||||||
|
proxyClient: &http.Client{Transport: rt},
|
||||||
|
subtitleCache: newSubtitleCache(0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/subtitle?token=token", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
if rt.called {
|
||||||
|
t.Fatal("proxy client was called for unsafe target")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,14 +17,96 @@ import (
|
|||||||
type watchPagePlaybackService struct {
|
type watchPagePlaybackService struct {
|
||||||
domain.PlaybackService
|
domain.PlaybackService
|
||||||
|
|
||||||
data domain.WatchPageData
|
data domain.WatchPageData
|
||||||
err error
|
deferred bool
|
||||||
|
err error
|
||||||
|
refreshRequested bool
|
||||||
|
titles []domain.CanonicalEpisode
|
||||||
|
classifications []domain.CanonicalEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *watchPagePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
|
func (s *watchPagePlaybackService) BuildWatchData(ctx context.Context, _ int, _ []string, _ string, _ string, _ string) (domain.WatchPageData, error) {
|
||||||
|
s.deferred = domain.PlaybackDataDeferred(ctx)
|
||||||
|
s.refreshRequested = domain.PlaybackSourceRefreshRequested(ctx)
|
||||||
return s.data, s.err
|
return s.data, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *watchPagePlaybackService) EnrichEpisodeTitles(context.Context, int) ([]domain.CanonicalEpisode, error) {
|
||||||
|
return s.titles, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *watchPagePlaybackService) EnrichEpisodeClassifications(context.Context, int) ([]domain.CanonicalEpisode, error) {
|
||||||
|
return s.classifications, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEpisodeDataRequestsForcedSourceRefresh(t *testing.T) {
|
||||||
|
data := baseWatchPageData()
|
||||||
|
data.WatchData.ModeSources = map[string]domain.ModeSource{
|
||||||
|
"sub": {Token: "opaque", Type: "m3u8"},
|
||||||
|
}
|
||||||
|
svc := &watchPagePlaybackService{data: data}
|
||||||
|
h := &PlaybackHandler{svc: svc}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episode/123/1?mode=sub&refresh=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !svc.refreshRequested {
|
||||||
|
t.Fatal("episode handler did not request a forced source refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEpisodeTitlesReturnsMinimalEnrichedList(t *testing.T) {
|
||||||
|
svc := &watchPagePlaybackService{titles: []domain.CanonicalEpisode{
|
||||||
|
{Number: 1, Title: "The First Title", HasSub: true},
|
||||||
|
{Number: 2, Title: "The Second Title", HasDub: true},
|
||||||
|
}}
|
||||||
|
h := &PlaybackHandler{svc: svc}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/titles", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec.Body.String()); got != `[{"number":1,"title":"The First Title"},{"number":2,"title":"The Second Title"}]` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEpisodeClassificationsReturnsMinimalList(t *testing.T) {
|
||||||
|
svc := &watchPagePlaybackService{classifications: []domain.CanonicalEpisode{
|
||||||
|
{Number: 1},
|
||||||
|
{Number: 2, Filler: true},
|
||||||
|
{Number: 3, Recap: true},
|
||||||
|
}}
|
||||||
|
h := &PlaybackHandler{svc: svc}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/classifications", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec.Body.String()); got != `[{"filler":false,"number":1,"recap":false},{"filler":true,"number":2,"recap":false},{"filler":false,"number":3,"recap":true}]` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type watchPageAnimeService struct {
|
type watchPageAnimeService struct {
|
||||||
domain.AnimePlaybackService
|
domain.AnimePlaybackService
|
||||||
t *testing.T
|
t *testing.T
|
||||||
@@ -96,8 +178,11 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
|
|||||||
if !strings.Contains(body, `data-episode-id="1"`) {
|
if !strings.Contains(body, `data-episode-id="1"`) {
|
||||||
t.Fatalf("expected episode list in body, got:\n%s", body)
|
t.Fatalf("expected episode list in body, got:\n%s", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `data-playback-error="no streams found"`) {
|
if !strings.Contains(body, `data-playback-error="failed to load playback data"`) {
|
||||||
t.Fatalf("expected playback error data attribute in body, got:\n%s", body)
|
t.Fatalf("expected stable playback error data attribute in body, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "no streams found") {
|
||||||
|
t.Fatalf("expected private playback error to stay out of body, got:\n%s", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `/anime/123/watch?ep=2`) {
|
if !strings.Contains(body, `/anime/123/watch?ep=2`) {
|
||||||
t.Fatalf("expected episode links to keep the anime id, got:\n%s", body)
|
t.Fatalf("expected episode links to keep the anime id, got:\n%s", body)
|
||||||
@@ -106,3 +191,49 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
|
|||||||
t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body)
|
t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleWatchPageDefersPlaybackData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := &watchPagePlaybackService{data: baseWatchPageData()}
|
||||||
|
router := newWatchPageRouter(t, &PlaybackHandler{svc: svc})
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/123/watch?ep=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !svc.deferred {
|
||||||
|
t.Fatal("watch page did not defer playback data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWatchPageRendersEpisodeAvailabilityWarningModal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := baseWatchPageData()
|
||||||
|
data.EpisodeAvailabilityWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain."
|
||||||
|
router := newWatchPageRouter(t, &PlaybackHandler{
|
||||||
|
svc: &watchPagePlaybackService{data: data},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/123/watch?ep=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-episode-availability-warning`,
|
||||||
|
`Episode availability is uncertain`,
|
||||||
|
`Continue anyway`,
|
||||||
|
data.EpisodeAvailabilityWarning,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("watch page missing %q in body:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,33 @@ func TestPlaybackServiceProxyTokenDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlaybackServiceSignProxyTokenRejectsUnsafeTargets(t *testing.T) {
|
||||||
|
svc := &playbackService{proxyTokenKey: "secret", proxyTokens: newProxyTokenStore()}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
targetURL string
|
||||||
|
}{
|
||||||
|
{name: "loopback ipv4", targetURL: "http://127.0.0.1/video.m3u8"},
|
||||||
|
{name: "private ipv4", targetURL: "https://10.0.0.4/segment.ts"},
|
||||||
|
{name: "loopback ipv6", targetURL: "http://[::1]/subtitle.vtt"},
|
||||||
|
{name: "unsupported scheme", targetURL: "file:///tmp/video.m3u8"},
|
||||||
|
{name: "missing host", targetURL: "https:///segment.ts"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
token, err := svc.SignProxyToken(tt.targetURL, "", "stream")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("SignProxyToken(%q) error = nil, token = %q", tt.targetURL, token)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
t.Fatalf("SignProxyToken(%q) token = %q, want empty", tt.targetURL, token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakePlaybackRepository struct {
|
type fakePlaybackRepository struct {
|
||||||
inTxCalled bool
|
inTxCalled bool
|
||||||
getAnimeErr error
|
getAnimeErr error
|
||||||
|
|||||||
125
internal/playback/proxytarget/target.go
Normal file
125
internal/playback/proxytarget/target.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package proxytarget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ipResolver interface {
|
||||||
|
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validate(rawURL string) error {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse proxy target: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("proxy target scheme must be http or https")
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("proxy target host is required")
|
||||||
|
}
|
||||||
|
if isLocalhostName(host) {
|
||||||
|
return fmt.Errorf("proxy target host is local")
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(host); err == nil && !isAllowedAddr(addr) {
|
||||||
|
return fmt.Errorf("proxy target address is not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: newTransport(10*time.Second, 10*time.Second, 30*time.Second),
|
||||||
|
Timeout: timeout,
|
||||||
|
CheckRedirect: checkRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStreamingClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: newTransport(10*time.Second, 10*time.Second, 15*time.Second),
|
||||||
|
CheckRedirect: checkRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkRedirect(req *http.Request, _ []*http.Request) error {
|
||||||
|
return Validate(req.URL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTransport(dialTimeout, tlsTimeout, headerTimeout time.Duration) *http.Transport {
|
||||||
|
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||||
|
return &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: tlsTimeout,
|
||||||
|
ResponseHeaderTimeout: headerTimeout,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
DialContext: validatingDialContext(dialer, net.DefaultResolver),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatingDialContext(dialer *net.Dialer, resolver ipResolver) func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse proxy target address: %w", err)
|
||||||
|
}
|
||||||
|
if isLocalhostName(host) {
|
||||||
|
return nil, fmt.Errorf("proxy target host is local")
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr, err := netip.ParseAddr(host); err == nil {
|
||||||
|
if !isAllowedAddr(addr) {
|
||||||
|
return nil, fmt.Errorf("proxy target address is not allowed")
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := resolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve proxy target host: %w", err)
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
addr, ok := addrFromIP(ip.IP)
|
||||||
|
if !ok || !isAllowedAddr(addr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("proxy target resolved to no allowed addresses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalhostName(host string) bool {
|
||||||
|
lower := strings.TrimSuffix(strings.ToLower(host), ".")
|
||||||
|
return lower == "localhost" || strings.HasSuffix(lower, ".localhost")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addrFromIP(ip net.IP) (netip.Addr, bool) {
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
return netip.AddrFrom4([4]byte{v4[0], v4[1], v4[2], v4[3]}), true
|
||||||
|
}
|
||||||
|
if v6 := ip.To16(); v6 != nil {
|
||||||
|
return netip.AddrFrom16([16]byte{v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7], v6[8], v6[9], v6[10], v6[11], v6[12], v6[13], v6[14], v6[15]}), true
|
||||||
|
}
|
||||||
|
return netip.Addr{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedAddr(addr netip.Addr) bool {
|
||||||
|
return addr.IsValid() &&
|
||||||
|
addr.IsGlobalUnicast() &&
|
||||||
|
!addr.IsLoopback() &&
|
||||||
|
!addr.IsPrivate() &&
|
||||||
|
!addr.IsLinkLocalUnicast() &&
|
||||||
|
!addr.IsUnspecified()
|
||||||
|
}
|
||||||
68
internal/playback/proxytarget/target_test.go
Normal file
68
internal/playback/proxytarget/target_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package proxytarget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateAllowsHTTPSTargetWithHostname(t *testing.T) {
|
||||||
|
if err := Validate("https://cdn.example.test/video/segment.ts"); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsUnsafeTargets(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"ftp://cdn.example.test/video.ts",
|
||||||
|
"https:///video.ts",
|
||||||
|
"http://localhost/video.ts",
|
||||||
|
"http://127.0.0.1/video.ts",
|
||||||
|
"http://[::ffff:127.0.0.1]/video.ts",
|
||||||
|
"http://169.254.169.254/video.ts",
|
||||||
|
"https://[::1]/subtitle.vtt",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, targetURL := range tests {
|
||||||
|
t.Run(targetURL, func(t *testing.T) {
|
||||||
|
if err := Validate(targetURL); err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRedirectRejectsUnsafeLocation(t *testing.T) {
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1", Path: "/video.ts"}}
|
||||||
|
|
||||||
|
if err := checkRedirect(req, nil); err == nil {
|
||||||
|
t.Fatal("checkRedirect() error = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatingDialContextRejectsPrivateResolvedAddress(t *testing.T) {
|
||||||
|
dial := validatingDialContext(&net.Dialer{Timeout: time.Millisecond}, fakeResolver{
|
||||||
|
ips: []net.IPAddr{{IP: net.ParseIP("192.168.1.20")}},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := dial(context.Background(), "tcp", "cdn.example.test:443")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("dial error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no allowed addresses") {
|
||||||
|
t.Fatalf("dial error = %v, want no allowed addresses", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeResolver struct {
|
||||||
|
ips []net.IPAddr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r fakeResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) {
|
||||||
|
return r.ips, r.err
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
package playback
|
package playback
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
"mal/internal/playback/proxytarget"
|
||||||
errlog "mal/pkg"
|
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
type playbackService struct {
|
type playbackService struct {
|
||||||
@@ -21,6 +21,8 @@ type playbackService struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
proxyTokenKey string
|
proxyTokenKey string
|
||||||
proxyTokens *proxyTokenStore
|
proxyTokens *proxyTokenStore
|
||||||
|
sourceCache *sourceCache
|
||||||
|
sourceFlight singleflight.Group
|
||||||
auditSvc domain.AuditService
|
auditSvc domain.AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provi
|
|||||||
httpClient: netutil.NewClient(),
|
httpClient: netutil.NewClient(),
|
||||||
proxyTokenKey: string(proxyTokenKey),
|
proxyTokenKey: string(proxyTokenKey),
|
||||||
proxyTokens: newProxyTokenStore(),
|
proxyTokens: newProxyTokenStore(),
|
||||||
|
sourceCache: newSourceCache(defaultSourceCacheTTL, defaultSourceCacheStaleTTL, defaultSourceCacheMaxEntries),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,9 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
|
|||||||
if s.proxyTokenKey == "" {
|
if s.proxyTokenKey == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
if err := proxytarget.Validate(targetURL); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now())
|
return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,27 +65,3 @@ func (s *playbackService) ResolveProxyToken(token string, scope string) (string,
|
|||||||
}
|
}
|
||||||
return target.targetURL, target.referer, nil
|
return target.targetURL, target.referer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) warmStreamURL(targetURL, referer string) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if referer != "" {
|
|
||||||
req.Header.Set("Referer", referer)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", netutil.Firefox121)
|
|
||||||
|
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if resp != nil {
|
|
||||||
errlog.Close(resp.Body, "failed to close warm stream error response body")
|
|
||||||
}
|
|
||||||
observability.LogJSON(observability.LogLevelWarn, "warm_stream_failed", "playback", err.Error(), map[string]any{"url": targetURL}, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errlog.Log("failed to close warm stream response body", resp.Body.Close())
|
|
||||||
}
|
|
||||||
|
|||||||
152
internal/playback/source_cache.go
Normal file
152
internal/playback/source_cache.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSourceCacheTTL = 5 * time.Minute
|
||||||
|
defaultSourceCacheStaleTTL = 10 * time.Minute
|
||||||
|
defaultSourceCacheMaxEntries = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceCacheKey struct {
|
||||||
|
animeID int
|
||||||
|
episode string
|
||||||
|
mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSourceCacheKey(animeID int, episode string, mode string) sourceCacheKey {
|
||||||
|
return sourceCacheKey{
|
||||||
|
animeID: animeID,
|
||||||
|
episode: strings.TrimSpace(episode),
|
||||||
|
mode: normalizeSourceMode(mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k sourceCacheKey) flightKey() string {
|
||||||
|
return fmt.Sprintf("%d|%q|%q", k.animeID, k.episode, k.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSourceMode(mode string) string {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||||
|
if mode == "" {
|
||||||
|
return "sub"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceCacheEntry struct {
|
||||||
|
key sourceCacheKey
|
||||||
|
result *domain.StreamResult
|
||||||
|
freshUntil time.Time
|
||||||
|
staleUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceCacheState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceCacheMiss sourceCacheState = iota
|
||||||
|
sourceCacheFresh
|
||||||
|
sourceCacheStale
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
freshTTL time.Duration
|
||||||
|
staleTTL time.Duration
|
||||||
|
maxEntries int
|
||||||
|
entries map[sourceCacheKey]*list.Element
|
||||||
|
lru *list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSourceCache(freshTTL, staleTTL time.Duration, maxEntries int) *sourceCache {
|
||||||
|
if freshTTL <= 0 {
|
||||||
|
freshTTL = defaultSourceCacheTTL
|
||||||
|
}
|
||||||
|
if staleTTL < 0 {
|
||||||
|
staleTTL = 0
|
||||||
|
}
|
||||||
|
if maxEntries <= 0 {
|
||||||
|
maxEntries = defaultSourceCacheMaxEntries
|
||||||
|
}
|
||||||
|
return &sourceCache{
|
||||||
|
freshTTL: freshTTL,
|
||||||
|
staleTTL: staleTTL,
|
||||||
|
maxEntries: maxEntries,
|
||||||
|
entries: make(map[sourceCacheKey]*list.Element, maxEntries),
|
||||||
|
lru: list.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sourceCache) get(key sourceCacheKey, now time.Time) (*domain.StreamResult, sourceCacheState) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
el := c.entries[key]
|
||||||
|
if el == nil {
|
||||||
|
return nil, sourceCacheMiss
|
||||||
|
}
|
||||||
|
entry, ok := el.Value.(sourceCacheEntry)
|
||||||
|
if !ok || !now.Before(entry.staleUntil) {
|
||||||
|
c.remove(el)
|
||||||
|
return nil, sourceCacheMiss
|
||||||
|
}
|
||||||
|
c.lru.MoveToFront(el)
|
||||||
|
if now.Before(entry.freshUntil) {
|
||||||
|
return cloneStreamResult(entry.result), sourceCacheFresh
|
||||||
|
}
|
||||||
|
return cloneStreamResult(entry.result), sourceCacheStale
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sourceCache) set(key sourceCacheKey, result *domain.StreamResult, now time.Time) (evicted bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry := sourceCacheEntry{
|
||||||
|
key: key,
|
||||||
|
result: cloneStreamResult(result),
|
||||||
|
freshUntil: now.Add(c.freshTTL),
|
||||||
|
staleUntil: now.Add(c.freshTTL + c.staleTTL),
|
||||||
|
}
|
||||||
|
if el := c.entries[key]; el != nil {
|
||||||
|
el.Value = entry
|
||||||
|
c.lru.MoveToFront(el)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
c.entries[key] = c.lru.PushFront(entry)
|
||||||
|
for len(c.entries) > c.maxEntries {
|
||||||
|
back := c.lru.Back()
|
||||||
|
if back == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.remove(back)
|
||||||
|
evicted = true
|
||||||
|
}
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sourceCache) remove(el *list.Element) {
|
||||||
|
entry, ok := el.Value.(sourceCacheEntry)
|
||||||
|
if ok {
|
||||||
|
delete(c.entries, entry.key)
|
||||||
|
}
|
||||||
|
c.lru.Remove(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStreamResult(result *domain.StreamResult) *domain.StreamResult {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *result
|
||||||
|
cloned.Subtitles = append([]domain.Subtitle(nil), result.Subtitles...)
|
||||||
|
cloned.Qualities = append([]domain.StreamSource(nil), result.Qualities...)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
147
internal/playback/source_cache_test.go
Normal file
147
internal/playback/source_cache_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceCacheProvider struct {
|
||||||
|
calls atomic.Int32
|
||||||
|
get func(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *sourceCacheProvider) Name() string { return "test" }
|
||||||
|
|
||||||
|
func (p *sourceCacheProvider) GetStreams(ctx context.Context, animeID int, titles []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||||
|
p.calls.Add(1)
|
||||||
|
return p.get(ctx, animeID, titles, episode, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSourceCacheService(provider domain.Provider) *playbackService {
|
||||||
|
return &playbackService{
|
||||||
|
providers: []domain.Provider{provider},
|
||||||
|
sourceCache: newSourceCache(time.Minute, time.Minute, 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamResultCachesClonedProviderResult(t *testing.T) {
|
||||||
|
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
|
||||||
|
return &domain.StreamResult{
|
||||||
|
URL: "https://cdn.example.test/episode.m3u8",
|
||||||
|
Subtitles: []domain.Subtitle{{URL: "https://cdn.example.test/sub.vtt", Label: "English"}},
|
||||||
|
}, nil
|
||||||
|
}}
|
||||||
|
svc := newSourceCacheService(provider)
|
||||||
|
|
||||||
|
first := svc.resolveStreamResult(context.Background(), 42, []string{"Title"}, "3", "SUB", false, false)
|
||||||
|
first.Subtitles[0].Label = "changed"
|
||||||
|
second := svc.resolveStreamResult(context.Background(), 42, []string{"Different title"}, "3", "sub", false, false)
|
||||||
|
|
||||||
|
if provider.calls.Load() != 1 {
|
||||||
|
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
|
||||||
|
}
|
||||||
|
if second == nil || second.Subtitles[0].Label != "English" {
|
||||||
|
t.Fatalf("cached result was not cloned: %#v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamResultCollapsesConcurrentMisses(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
default:
|
||||||
|
close(started)
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
return &domain.StreamResult{URL: "https://cdn.example.test/episode.m3u8"}, nil
|
||||||
|
}}
|
||||||
|
svc := newSourceCacheService(provider)
|
||||||
|
|
||||||
|
const requests = 8
|
||||||
|
results := make(chan *domain.StreamResult, requests)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range requests {
|
||||||
|
wg.Go(func() {
|
||||||
|
results <- svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
for result := range results {
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("concurrent lookup returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provider.calls.Load() != 1 {
|
||||||
|
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamResultSeparatesKeysAndForcesRefresh(t *testing.T) {
|
||||||
|
provider := &sourceCacheProvider{get: func(_ context.Context, _ int, _ []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||||
|
return &domain.StreamResult{URL: "https://cdn.example.test/" + episode + "/" + mode}, nil
|
||||||
|
}}
|
||||||
|
svc := newSourceCacheService(provider)
|
||||||
|
|
||||||
|
svc.resolveStreamResult(context.Background(), 42, nil, "1", "sub", false, false)
|
||||||
|
svc.resolveStreamResult(context.Background(), 42, nil, "2", "sub", false, false)
|
||||||
|
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, false)
|
||||||
|
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, true)
|
||||||
|
|
||||||
|
if provider.calls.Load() != 4 {
|
||||||
|
t.Fatalf("provider calls = %d, want 4", provider.calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamResultUsesStaleCompletedMediaOnProviderError(t *testing.T) {
|
||||||
|
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
|
||||||
|
return nil, errors.New("provider unavailable")
|
||||||
|
}}
|
||||||
|
svc := newSourceCacheService(provider)
|
||||||
|
svc.sourceCache.set(
|
||||||
|
newSourceCacheKey(42, "3", "sub"),
|
||||||
|
&domain.StreamResult{URL: "https://cdn.example.test/stale.m3u8"},
|
||||||
|
time.Now().Add(-90*time.Second),
|
||||||
|
)
|
||||||
|
|
||||||
|
result := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", true, false)
|
||||||
|
if result == nil || result.URL != "https://cdn.example.test/stale.m3u8" {
|
||||||
|
t.Fatalf("result = %#v, want stale source", result)
|
||||||
|
}
|
||||||
|
if airing := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false); airing != nil {
|
||||||
|
t.Fatalf("airing result = %#v, want nil after provider failure", airing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceCacheExpiresAndEvictsLeastRecentlyUsed(t *testing.T) {
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
cache := newSourceCache(time.Minute, time.Minute, 2)
|
||||||
|
keyA := newSourceCacheKey(1, "1", "sub")
|
||||||
|
keyB := newSourceCacheKey(1, "2", "sub")
|
||||||
|
keyC := newSourceCacheKey(1, "3", "sub")
|
||||||
|
cache.set(keyA, &domain.StreamResult{URL: "a"}, now)
|
||||||
|
cache.set(keyB, &domain.StreamResult{URL: "b"}, now)
|
||||||
|
cache.get(keyA, now)
|
||||||
|
cache.set(keyC, &domain.StreamResult{URL: "c"}, now)
|
||||||
|
|
||||||
|
if _, state := cache.get(keyB, now); state != sourceCacheMiss {
|
||||||
|
t.Fatalf("evicted cache state = %d, want miss", state)
|
||||||
|
}
|
||||||
|
if _, state := cache.get(keyA, now.Add(90*time.Second)); state != sourceCacheStale {
|
||||||
|
t.Fatalf("stale cache state = %d, want stale", state)
|
||||||
|
}
|
||||||
|
if _, state := cache.get(keyA, now.Add(2*time.Minute)); state != sourceCacheMiss {
|
||||||
|
t.Fatalf("expired cache state = %d, want miss", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,29 +2,43 @@ package playback
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sourceResolutionTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
type sourceResolutionResult struct {
|
||||||
|
result *domain.StreamResult
|
||||||
|
err error
|
||||||
|
duration time.Duration
|
||||||
|
shared bool
|
||||||
|
completed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeAvailabilityUncertainWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain."
|
||||||
|
|
||||||
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
|
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
|
||||||
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
|
animeData, err := s.watchAnime(ctx, animeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
|
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
animeData := domain.Anime{Anime: anime}
|
|
||||||
if err := s.ensureAnimeRow(ctx, animeData); err != nil {
|
if err := s.ensureAnimeRow(ctx, animeData); err != nil {
|
||||||
observability.Warn("upsert_anime_failed", "playback", "",
|
observability.Warn("upsert_anime_failed", "playback", "",
|
||||||
map[string]any{"anime_id": animeID},
|
map[string]any{"anime_id": animeID},
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
anime := animeData.Anime
|
||||||
searchTitles := buildSearchTitles(animeData, titleCandidates)
|
searchTitles := buildSearchTitles(animeData, titleCandidates)
|
||||||
eps, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false)
|
eps, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,24 +47,18 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
|||||||
|
|
||||||
// mode fallback
|
// mode fallback
|
||||||
mode, from := resolveMode(episode, mode, eps.Episodes)
|
mode, from := resolveMode(episode, mode, eps.Episodes)
|
||||||
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode)
|
deferred := domain.PlaybackDataDeferred(ctx)
|
||||||
if resolvedMode != "" {
|
modeSources, result, mode, from := s.watchModeSources(
|
||||||
mode = resolvedMode
|
ctx, animeID, searchTitles, episode, mode, from, !anime.Airing, deferred,
|
||||||
}
|
)
|
||||||
if switchedFrom != "" {
|
|
||||||
from = switchedFrom
|
|
||||||
}
|
|
||||||
startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode)
|
startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode)
|
||||||
seasons := s.loadSeasons(ctx, animeID)
|
segments := s.watchSegments(ctx, userID, animeID, episode, deferred)
|
||||||
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn("fetch_skip_segments_failed", "playback", "",
|
|
||||||
map[string]any{"anime_id": animeID, "episode": episode},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments)
|
watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments)
|
||||||
pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData)
|
pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, nil, watchData)
|
||||||
|
pageData.EpisodeAvailabilityWarning = episodeAvailabilityWarning(eps, time.Now())
|
||||||
|
if deferred {
|
||||||
|
return pageData, nil
|
||||||
|
}
|
||||||
if len(modeSources) == 0 {
|
if len(modeSources) == 0 {
|
||||||
return pageData, fmt.Errorf("no streams found")
|
return pageData, fmt.Errorf("no streams found")
|
||||||
}
|
}
|
||||||
@@ -58,10 +66,99 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
|||||||
return pageData, fmt.Errorf("no streams found for mode %s", mode)
|
return pageData, fmt.Errorf("no streams found for mode %s", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
go s.warmStreamURL(result.URL, result.Referer)
|
|
||||||
return pageData, nil
|
return pageData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) EnrichEpisodeTitles(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
|
||||||
|
anime, err := s.watchAnime(ctx, animeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch anime for episode titles: %w", err)
|
||||||
|
}
|
||||||
|
enricher, ok := s.episodes.(domain.EpisodeTitleService)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("episode title enrichment is unavailable")
|
||||||
|
}
|
||||||
|
episodes, err := enricher.EnrichEpisodeTitles(ctx, anime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return episodes.Episodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
|
||||||
|
anime, err := s.watchAnime(ctx, animeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch anime for episode classifications: %w", err)
|
||||||
|
}
|
||||||
|
enricher, ok := s.episodes.(domain.EpisodeClassificationService)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("episode classification enrichment is unavailable")
|
||||||
|
}
|
||||||
|
episodes, err := enricher.EnrichEpisodeClassifications(ctx, anime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return episodes.Episodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) watchAnime(ctx context.Context, animeID int) (domain.Anime, error) {
|
||||||
|
row, err := s.repo.GetAnime(ctx, int64(animeID))
|
||||||
|
if err == nil && row.ID > 0 && strings.TrimSpace(row.TitleOriginal) != "" {
|
||||||
|
anime := jikan.Anime{
|
||||||
|
MalID: int(row.ID),
|
||||||
|
Title: row.TitleOriginal,
|
||||||
|
TitleEnglish: row.TitleEnglish.String,
|
||||||
|
TitleJapanese: row.TitleJapanese.String,
|
||||||
|
Airing: row.Airing.Valid && row.Airing.Bool,
|
||||||
|
Status: row.Status.String,
|
||||||
|
}
|
||||||
|
return domain.Anime{Anime: anime}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Anime{}, err
|
||||||
|
}
|
||||||
|
return domain.Anime{Anime: anime}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) watchModeSources(ctx context.Context, animeID int, searchTitles []string, episode, mode, from string, allowStale, deferred bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
|
||||||
|
if deferred {
|
||||||
|
return map[string]domain.ModeSource{}, nil, mode, from
|
||||||
|
}
|
||||||
|
|
||||||
|
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(
|
||||||
|
ctx,
|
||||||
|
animeID,
|
||||||
|
searchTitles,
|
||||||
|
episode,
|
||||||
|
mode,
|
||||||
|
allowStale,
|
||||||
|
domain.PlaybackSourceRefreshRequested(ctx),
|
||||||
|
)
|
||||||
|
if resolvedMode != "" {
|
||||||
|
mode = resolvedMode
|
||||||
|
}
|
||||||
|
if switchedFrom != "" {
|
||||||
|
from = switchedFrom
|
||||||
|
}
|
||||||
|
return modeSources, result, mode, from
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) watchSegments(ctx context.Context, userID string, animeID int, episode string, deferred bool) []domain.SkipSegment {
|
||||||
|
if deferred {
|
||||||
|
return []domain.SkipSegment{}
|
||||||
|
}
|
||||||
|
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn("fetch_skip_segments_failed", "playback", "",
|
||||||
|
map[string]any{"anime_id": animeID, "episode": episode},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData {
|
func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData {
|
||||||
return domain.WatchData{
|
return domain.WatchData{
|
||||||
MalID: animeID,
|
MalID: animeID,
|
||||||
@@ -96,6 +193,23 @@ func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func episodeAvailabilityWarning(episodeList domain.CanonicalEpisodeList, now time.Time) string {
|
||||||
|
if episodeList.FailureCount > 0 {
|
||||||
|
return episodeAvailabilityUncertainWarning
|
||||||
|
}
|
||||||
|
if episodeList.NextRefreshAt == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
nextRefresh, err := time.Parse(time.RFC3339, episodeList.NextRefreshAt)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if nextRefresh.After(now) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return episodeAvailabilityUncertainWarning
|
||||||
|
}
|
||||||
|
|
||||||
func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string {
|
func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates))
|
out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates))
|
||||||
@@ -144,15 +258,16 @@ func resolveMode(episode string, requestedMode string, episodes []domain.Canonic
|
|||||||
return requestedMode, ""
|
return requestedMode, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
|
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string, allowStale bool, forceRefresh bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
|
||||||
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode); res != nil {
|
requestedMode = normalizeSourceMode(requestedMode)
|
||||||
|
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode, allowStale, forceRefresh); res != nil {
|
||||||
return map[string]domain.ModeSource{
|
return map[string]domain.ModeSource{
|
||||||
requestedMode: s.buildModeSource(res),
|
requestedMode: s.buildModeSource(res),
|
||||||
}, res, requestedMode, ""
|
}, res, requestedMode, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fallbackMode := range fallbackModes(requestedMode) {
|
for _, fallbackMode := range fallbackModes(requestedMode) {
|
||||||
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode)
|
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode, allowStale, forceRefresh)
|
||||||
if res == nil {
|
if res == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -164,23 +279,85 @@ func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, s
|
|||||||
return map[string]domain.ModeSource{}, nil, requestedMode, ""
|
return map[string]domain.ModeSource{}, nil, requestedMode, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult {
|
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string, allowStale bool, forceRefresh bool) *domain.StreamResult {
|
||||||
for _, p := range s.providers {
|
key := newSourceCacheKey(animeID, episode, mode)
|
||||||
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode)
|
stale, state := s.sourceCache.get(key, time.Now())
|
||||||
if err == nil && res != nil {
|
if !forceRefresh && state == sourceCacheFresh {
|
||||||
return res
|
observability.Info("playback_source_cache_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode})
|
||||||
}
|
return stale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
observability.Info("playback_source_cache_miss", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode, "forced": forceRefresh})
|
||||||
|
resolved := s.waitForSourceResult(ctx, key, animeID, searchTitles, forceRefresh)
|
||||||
|
if !resolved.completed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
observability.Info("playback_source_resolution", "playback", "", map[string]any{
|
||||||
|
"anime_id": animeID, "episode": episode, "mode": key.mode,
|
||||||
|
"duration_ms": resolved.duration.Milliseconds(), "shared": resolved.shared,
|
||||||
|
})
|
||||||
|
if resolved.err == nil {
|
||||||
|
return cloneStreamResult(resolved.result)
|
||||||
|
}
|
||||||
|
if allowStale && state == sourceCacheStale && stale != nil {
|
||||||
|
observability.Warn("playback_source_cache_stale_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, errors.New("provider source refresh failed"))
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
observability.Warn("playback_source_resolution_failed", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, resolved.err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) waitForSourceResult(ctx context.Context, key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) sourceResolutionResult {
|
||||||
|
startedAt := time.Now()
|
||||||
|
resultCh := s.sourceFlight.DoChan(key.flightKey(), func() (any, error) {
|
||||||
|
return s.resolveSource(key, animeID, searchTitles, forceRefresh)
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return sourceResolutionResult{err: ctx.Err(), duration: time.Since(startedAt)}
|
||||||
|
case resolved := <-resultCh:
|
||||||
|
result, _ := resolved.Val.(*domain.StreamResult)
|
||||||
|
return sourceResolutionResult{
|
||||||
|
result: result, err: resolved.Err, shared: resolved.Shared,
|
||||||
|
duration: time.Since(startedAt), completed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playbackService) resolveSource(key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) (*domain.StreamResult, error) {
|
||||||
|
if !forceRefresh {
|
||||||
|
if cached, state := s.sourceCache.get(key, time.Now()); state == sourceCacheFresh {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveCtx, cancel := context.WithTimeout(context.Background(), sourceResolutionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, provider := range s.providers {
|
||||||
|
result, err := provider.GetStreams(resolveCtx, animeID, searchTitles, key.episode, key.mode)
|
||||||
|
if err == nil && result != nil {
|
||||||
|
if s.sourceCache.set(key, result, time.Now()) {
|
||||||
|
observability.Info("playback_source_cache_eviction", "playback", "", nil)
|
||||||
|
}
|
||||||
|
return cloneStreamResult(result), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("no provider returned a stream")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource {
|
func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource {
|
||||||
subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles))
|
subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles))
|
||||||
for _, sub := range res.Subtitles {
|
for _, sub := range res.Subtitles {
|
||||||
token, err := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
|
token, err := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.LogJSON(observability.LogLevelWarn, "sign_subtitle_token_failed", "playback", err.Error(), map[string]any{"url": sub.URL}, nil)
|
observability.Warn("sign_subtitle_token_failed", "playback", "", nil, err)
|
||||||
}
|
}
|
||||||
subtitles = append(subtitles, domain.SubtitleItem{
|
subtitles = append(subtitles, domain.SubtitleItem{
|
||||||
Lang: sub.Label,
|
Lang: sub.Label,
|
||||||
@@ -190,7 +367,7 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
|
|||||||
|
|
||||||
streamToken, err := s.SignProxyToken(res.URL, res.Referer, "stream")
|
streamToken, err := s.SignProxyToken(res.URL, res.Referer, "stream")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.LogJSON(observability.LogLevelWarn, "sign_stream_token_failed", "playback", err.Error(), map[string]any{"url": res.URL}, nil)
|
observability.Warn("sign_stream_token_failed", "playback", "", nil, err)
|
||||||
}
|
}
|
||||||
return domain.ModeSource{
|
return domain.ModeSource{
|
||||||
Token: streamToken,
|
Token: streamToken,
|
||||||
@@ -199,40 +376,6 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry {
|
|
||||||
relations, err := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn("fetch_relations_failed", "playback", "",
|
|
||||||
map[string]any{"anime_id": animeID},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
seasons := make([]domain.SeasonEntry, 0, len(relations))
|
|
||||||
tvCounter := 1
|
|
||||||
|
|
||||||
for _, rel := range relations {
|
|
||||||
animeType := strings.ToLower(rel.Anime.Type)
|
|
||||||
if animeType != "tv" && animeType != "movie" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
season := domain.SeasonEntry{
|
|
||||||
MalID: rel.Anime.MalID,
|
|
||||||
Title: rel.Anime.DisplayTitle(),
|
|
||||||
Prefix: rel.Relation,
|
|
||||||
IsCurrent: rel.IsCurrent,
|
|
||||||
}
|
|
||||||
if rel.Relation == "TV" {
|
|
||||||
season.Prefix = fmt.Sprintf("S%d", tvCounter)
|
|
||||||
tvCounter++
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons = append(seasons, season)
|
|
||||||
}
|
|
||||||
|
|
||||||
return seasons
|
|
||||||
}
|
|
||||||
|
|
||||||
func availableModes(modeSources map[string]domain.ModeSource) []string {
|
func availableModes(modeSources map[string]domain.ModeSource) []string {
|
||||||
modes := make([]string, 0, len(modeSources))
|
modes := make([]string, 0, len(modeSources))
|
||||||
for mode := range modeSources {
|
for mode := range modeSources {
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
package playback
|
package playback
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/internal/domain"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestWatchModeSourcesDefersProviderResolution(t *testing.T) {
|
||||||
|
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
|
||||||
|
t.Fatal("deferred watch page resolved a provider source")
|
||||||
|
return nil, nil
|
||||||
|
}}
|
||||||
|
svc := newSourceCacheService(provider)
|
||||||
|
|
||||||
|
sources, result, mode, from := svc.watchModeSources(
|
||||||
|
context.Background(), 42, nil, "1", "sub", "", false, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(sources) != 0 || result != nil || mode != "sub" || from != "" {
|
||||||
|
t.Fatalf("deferred result = (%v, %v, %q, %q)", sources, result, mode, from)
|
||||||
|
}
|
||||||
|
if provider.calls.Load() != 0 {
|
||||||
|
t.Fatalf("provider calls = %d, want 0", provider.calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchAnimeUsesLocalRow(t *testing.T) {
|
||||||
|
svc := &playbackService{repo: &fakePlaybackRepository{}}
|
||||||
|
|
||||||
|
anime, err := svc.watchAnime(context.Background(), 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("watchAnime() error = %v", err)
|
||||||
|
}
|
||||||
|
if anime.MalID != 12 || anime.Title != "Anime 12" {
|
||||||
|
t.Fatalf("watchAnime() = %+v", anime.Anime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFallbackModes(t *testing.T) {
|
func TestFallbackModes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -33,3 +67,57 @@ func TestFallbackModes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEpisodeAvailabilityWarning(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
list domain.CanonicalEpisodeList
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fresh availability does not warn",
|
||||||
|
list: domain.CanonicalEpisodeList{
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
|
||||||
|
LastSuccessAt: "2026-06-27T10:00:00Z",
|
||||||
|
NextRefreshAt: "2026-06-27T12:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stale availability warns",
|
||||||
|
list: domain.CanonicalEpisodeList{
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
|
||||||
|
LastSuccessAt: "2026-06-27T09:00:00Z",
|
||||||
|
NextRefreshAt: "2026-06-27T10:00:00Z",
|
||||||
|
},
|
||||||
|
want: episodeAvailabilityUncertainWarning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retrying availability warns",
|
||||||
|
list: domain.CanonicalEpisodeList{
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
|
||||||
|
NextRefreshAt: "2026-06-27T11:05:00Z",
|
||||||
|
RetryUntilAt: "2026-06-27T11:30:00Z",
|
||||||
|
FailureCount: 1,
|
||||||
|
},
|
||||||
|
want: episodeAvailabilityUncertainWarning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed availability warns",
|
||||||
|
list: domain.CanonicalEpisodeList{
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
|
||||||
|
FailureCount: 3,
|
||||||
|
},
|
||||||
|
want: episodeAvailabilityUncertainWarning,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := episodeAvailabilityWarning(tt.list, now)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("episodeAvailabilityWarning() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user