345 Commits

Author SHA1 Message Date
renovate[bot]
b92d16c5ab chore(deps): update postgres docker tag to v0.13.4 2025-12-17 20:04:55 +00:00
82ede32aa7 Websocket api for watch progress (#1220) 2025-12-17 11:41:32 +01:00
ab5da0d5c6 Add back entry_pk in history 2025-12-17 11:39:15 +01:00
333dc46ebf Remove entry fk in history 2025-12-17 11:39:15 +01:00
3b6234de46 Fix new history queries 2025-12-17 11:39:15 +01:00
a855004fd2 Implement watch websocket api 2025-12-17 11:39:15 +01:00
fd29c6f682 Rework history to prevent duplicates in the last day 2025-12-17 11:39:15 +01:00
86f4ce2bd8 wip 2025-12-17 11:39:15 +01:00
renovate[bot]
16058b92b3 fix(deps): update module github.com/exaring/otelpgx to v0.9.4 (#1225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 11:27:10 +01:00
acelinkio
29ac70420e fix(deps): update module github.com/go-playground/validator/v10 to v10.29.0 (#1224) 2025-12-14 19:44:24 -08:00
renovate[bot]
c136ece0c1 fix(deps): update module github.com/go-playground/validator/v10 to v10.29.0 2025-12-15 03:42:40 +00:00
acelinkio
1aa15ed569 fix(deps): update opentelemetry-go-contrib monorepo (#1223) 2025-12-14 19:40:55 -08:00
acelinkio
918a0eeab5 fix(deps): update aws-sdk-go-v2 monorepo (#1222) 2025-12-14 19:40:41 -08:00
renovate[bot]
31d4befb3d fix(deps): update opentelemetry-go-contrib monorepo 2025-12-15 01:38:21 +00:00
renovate[bot]
6563e15123 fix(deps): update aws-sdk-go-v2 monorepo 2025-12-15 01:37:58 +00:00
acelinkio
8ba3417631 fix(deps): update opentelemetry-go monorepo (#1218) 2025-12-13 10:52:28 -08:00
renovate[bot]
17fd84c62c fix(deps): update opentelemetry-go monorepo 2025-12-13 18:37:58 +00:00
acelinkio
a44ef47420 fix(deps): update module golang.org/x/text to v0.32.0 (#1217) 2025-12-13 10:36:30 -08:00
acelinkio
9b57437fa1 fix(deps): update module github.com/lestrrat-go/httprc/v3 to v3.0.2 (#1214) 2025-12-13 10:35:04 -08:00
renovate[bot]
c859ed219f fix(deps): update module golang.org/x/text to v0.32.0 2025-12-13 18:17:00 +00:00
renovate[bot]
94f22218a3 fix(deps): update module github.com/lestrrat-go/httprc/v3 to v3.0.2 2025-12-13 18:15:53 +00:00
00466108a3 Proper api shutdown & image downlaoding multi run (#1213) 2025-12-09 08:30:19 +00:00
acelinkio
f9af1a9947 transcoder: slog, otel, & logging improvements (#1212) 2025-12-08 06:38:34 -08:00
acelinkio
50241b23b5 auth: slog, otel, & logging improvements (#1204) 2025-12-08 14:27:50 +00:00
Arlan Lloyd
5f220d4b51 fix path 2025-12-08 14:24:24 +00:00
Arlan Lloyd
f7149a4f19 reverting change, slog not used in src 2025-12-08 05:59:20 +00:00
Arlan Lloyd
e716fd49f5 slog, otel, & logging improvements 2025-12-08 05:46:44 +00:00
acelinkio
13d1b721b2 Update module github.com/golang-migrate/migrate/v4 to v4.19.1 (#1211) 2025-12-07 17:50:09 -08:00
acelinkio
6e36f85c3a Update dependency go to v1.25.5 (#1210) 2025-12-07 17:49:54 -08:00
acelinkio
6f16fd1592 Update dependency @biomejs/biome to v2.3.8 (#1209) 2025-12-07 17:49:41 -08:00
acelinkio
3465279a26 Update aws-sdk-go-v2 monorepo (#1208) 2025-12-07 17:49:29 -08:00
acelinkio
4cef5f5ca3 Update actions/checkout action to v6.0.1 (#1207) 2025-12-07 17:48:50 -08:00
renovate[bot]
81531e45ad Update module github.com/golang-migrate/migrate/v4 to v4.19.1 2025-12-08 00:44:24 +00:00
renovate[bot]
e602b75db3 Update dependency go to v1.25.5 2025-12-08 00:44:06 +00:00
renovate[bot]
4866354546 Update dependency @biomejs/biome to v2.3.8 2025-12-08 00:43:49 +00:00
renovate[bot]
1cc6a932a1 Update aws-sdk-go-v2 monorepo 2025-12-08 00:43:39 +00:00
renovate[bot]
acdf5cf66a Update actions/checkout action to v6.0.1 2025-12-08 00:43:12 +00:00
3a06e3c43c Fix sharp in bun compile in 1.3.4 (#1206) 2025-12-07 16:35:09 +01:00
fc7deb8e09 Fix pg pool starvation (#1205) 2025-12-07 13:40:44 +00:00
58603c5180 Run multiple image download queue in parallel (#1203) 2025-12-06 01:51:36 +01:00
a429b0ace9 Run multiple image download queue in parallel 2025-12-06 01:49:16 +01:00
0f62854128 Fix images recycling keys 2025-12-06 01:49:16 +01:00
2deeaaf97e Enable docker cross compile for front & auth 2025-12-06 01:49:16 +01:00
6f07e51a07 Handle duplicated studios (#1202) 2025-12-06 00:51:27 +01:00
c839fc826e Fix front type for original 2025-12-06 00:48:19 +01:00
10ac7e1ec6 Handle duplicated studios 2025-12-06 00:48:19 +01:00
79075e497d Lots of api fixes + error api for scanner (#1201) 2025-12-06 00:06:25 +01:00
8109b7ada6 Format stuff 2025-12-05 23:42:52 +01:00
30f26b2f6a Allow insert without original translation 2025-12-05 23:38:18 +01:00
a1b975cc5d Delete timedout running requests 2025-12-04 17:58:32 +01:00
4f2b2d2cd2 Handle seasons with holes in episode numbers 2025-12-04 17:58:32 +01:00
d3ccd14fe0 Fix sqlarr 2025-12-04 17:58:32 +01:00
7f5bc2f57c Fix logout on deleted accounts 2025-12-04 17:58:32 +01:00
c2c9bbe555 Prevent duplicated staff members 2025-12-04 17:58:32 +01:00
20e6fbbc33 Remove well-known from otel 2025-12-04 17:58:31 +01:00
5f9064ec37 Prevent all scanner slave to process requests 2025-12-04 17:58:31 +01:00
433b90a3fb Add requests errors in db and api 2025-12-04 17:58:31 +01:00
81c6f68509 Fix shell.nix for sharp 2025-12-04 17:58:31 +01:00
96ac331903 Fix downloaded images volume on docker 2025-12-04 17:58:31 +01:00
renovate[bot]
f1c2724a7b chore(deps): update traefik docker tag to v3.6 (#1196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 12:12:42 +00:00
acelinkio
12fe7c157f .github remove autosync references + fix whitespace (#1198) 2025-12-02 09:26:04 +01:00
c29ad99ca0 Fix pg admin password (#1186) 2025-12-02 09:22:49 +01:00
acelinkio
a99f29074c scanner: adding the probes back (#1197) 2025-12-01 18:30:23 -08:00
Arlan Lloyd
f449a0878a adding the probes back 2025-12-02 02:26:34 +00:00
acelinkio
097985ab6d scanner: refactor otel integration (#1194) 2025-12-01 23:50:28 +01:00
11c300ecf7 Add status api to get scanner's status (#1195) 2025-12-01 20:18:21 +01:00
1e975ce238 Set null not distinct in scanner request constraint 2025-12-01 20:15:57 +01:00
b39fa4262d Add status api to get scanner's status 2025-12-01 20:04:16 +01:00
d7699389bc Fix missing kid in apikeys jwt 2025-12-01 20:04:16 +01:00
renovate[bot]
1036e9f3f3 chore(deps): update dependency @biomejs/biome to v2.3.7 (#1189)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 10:36:11 +01:00
renovate[bot]
b4749f3ed3 fix(deps): update aws-sdk-go-v2 monorepo (#1191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 10:34:25 +01:00
renovate[bot]
a20c61206f fix(deps): update module github.com/labstack/echo-jwt/v4 to v4.4.0 (#1192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 10:34:14 +01:00
renovate[bot]
0644a43cb1 chore(deps): update actions/checkout action to v6 (#1193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 10:34:05 +01:00
af4742ae0b Fix sqlarr of api (#1188) 2025-11-30 19:29:46 +00:00
acelinkio
e401ca98c0 downgrade cloudpirates postgres 0.12.0 (#1187) 2025-11-28 13:15:55 -08:00
Arlan Lloyd
a756c875fd 0.12.1 has a bug with rendering nested values 2025-11-28 21:13:10 +00:00
acelinkio
2ef26e5d02 add devspace (#1173) 2025-11-28 20:52:10 +01:00
acelinkio
e7d9002156 kyoo_api logs redact password & other sensitive fields (#1182) 2025-11-28 16:42:27 +00:00
acelinkio
28d2e193aa kyoo_api extension install specify schema (#1183) 2025-11-28 16:39:47 +00:00
ce5bee11c0 Use unnest in insertion methods (#1185) 2025-11-28 17:28:11 +01:00
60d59d7f7b Wrap every insert with a trace 2025-11-28 17:25:29 +01:00
464d720ef9 Fix unnest issues 2025-11-28 17:11:43 +01:00
8fc279d2ed Use unnest everywhere 2025-11-28 17:11:43 +01:00
a45e992339 Properly type unnestValues return 2025-11-28 17:11:43 +01:00
5f8ddd435a Use unnest for entries 2025-11-28 17:11:43 +01:00
d822463fe0 Add a trace for api migrations 2025-11-28 17:11:43 +01:00
acelinkio
3a0cbf786d fix(deps): update aws-sdk-go-v2 monorepo (#1159) 2025-11-24 13:12:25 -08:00
renovate[bot]
dfb4777a5d fix(deps): update aws-sdk-go-v2 monorepo 2025-11-24 20:29:44 +00:00
acelinkio
eea32c47e9 chore(deps): update postgres docker tag to v0.12.1 (#1181) 2025-11-24 09:49:58 -08:00
renovate[bot]
6bcd03b18e chore(deps): update postgres docker tag to v0.12.1 2025-11-24 13:46:46 +00:00
acelinkio
87a3df6897 chore(deps): update dependency @biomejs/biome to v2.3.6 (#1179) 2025-11-23 18:38:41 -08:00
acelinkio
7f7a16e9b5 chore(deps): update postgres docker tag to v0.12.0 (#1180) 2025-11-23 18:38:17 -08:00
renovate[bot]
b95dd9056b chore(deps): update postgres docker tag to v0.12.0 2025-11-24 02:22:25 +00:00
renovate[bot]
5044f941b1 chore(deps): update dependency @biomejs/biome to v2.3.6 2025-11-24 02:08:06 +00:00
c56f9ea791 Remove identify traces (#1178) 2025-11-23 23:55:23 +01:00
eb56dd70d6 Batch images task insertion and add priority (#1177) 2025-11-23 23:22:01 +01:00
a4f5ef33ff Fix deadlock on image downloading 2025-11-23 23:20:40 +01:00
20ab1dae6c Force tests to run on kyoo_test database 2025-11-23 23:16:58 +01:00
7ebc0fe504 Fix type issues 2025-11-23 22:44:59 +01:00
019aceb8d9 Batch images task insertion and add priority 2025-11-23 22:44:59 +01:00
f59cb5d671 Properly handle spans of image downloading (#1176) 2025-11-23 19:00:54 +01:00
d4deafe1dc Forward X-Forward-Host & proto headers in traefik 2025-11-23 18:58:19 +01:00
7b2f1c7a82 Fix image test in github 2025-11-23 18:06:55 +01:00
c5fa3ecb01 Cleanup scanner processing span 2025-11-23 17:49:23 +01:00
3602905e86 Properly handle spans of image downloading 2025-11-23 17:49:23 +01:00
1f7844b8a5 Fix api images path (#1175) 2025-11-23 14:57:07 +01:00
Jory Irving
3b76fb2647 Allow manual helm chart publishing for specific tags (#1174) 2025-11-23 13:24:14 +01:00
acelinkio
9a00d5036f Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /transcoder (#1171) 2025-11-21 09:34:30 -08:00
dependabot[bot]
7c315602cd Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /transcoder
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 17:03:04 +00:00
acelinkio
19e0e402da Bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /auth (#1168) 2025-11-21 09:02:35 -08:00
acelinkio
ef38468178 chore(deps): update dependency @biomejs/biome to v2.3.5 (#1157) 2025-11-21 09:02:17 -08:00
2cbbb450c2 Lock scanner processing to a single runner (#1170) 2025-11-20 12:13:34 +01:00
9f466ff702 Update elysia otel plugin 2025-11-20 12:12:01 +01:00
05f7fabb3c Lock scanner processing to a single runner 2025-11-20 12:12:01 +01:00
5bc6a06b91 Chunk identify scans (#1169) 2025-11-20 09:46:49 +01:00
f7e801e574 Chunk identify scans 2025-11-20 09:44:00 +01:00
dependabot[bot]
c663189df1 Bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /auth
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:45:53 +00:00
37ec32b52d Name migrate span of scanner 2025-11-20 00:03:24 +01:00
188ce3f67d Log to stdout & otel for scanner 2025-11-19 23:59:08 +01:00
18b2ae2c5f Remove name prefix in apikeys (#1167) 2025-11-19 23:29:31 +01:00
a115c83cba Make scanner ready check a noop (#1166) 2025-11-19 20:49:42 +01:00
27d25f4829 Fix transcoder service name in otel (#1165) 2025-11-19 19:27:50 +00:00
acelinkio
d1d6fa9556 docs: change logo to use google source (#1164) 2025-11-19 10:09:03 -08:00
Arlan Lloyd
7878673d8d change logo to use google source 2025-11-19 18:07:09 +00:00
34761b43ae Fix need rendering compute on videos slug (#1163) 2025-11-19 16:28:47 +00:00
acelinkio
536b03b1ef chart: update docs (#1162) 2025-11-18 14:22:53 -08:00
acelinkio
6f37c128ef Merge branch 'master' into chart/update_notes 2025-11-18 14:21:51 -08:00
Arlan Lloyd
8e3a582f75 update docs 2025-11-18 22:20:32 +00:00
acelinkio
b5720dca06 update diagrams for v5 (#1156) 2025-11-18 10:11:51 -08:00
Arlan Lloyd
df26dbca63 update SSR notes + api downloads media images 2025-11-18 18:10:13 +00:00
acelinkio
851ae9eb9d Merge branch 'master' into update_diagrams 2025-11-18 09:37:45 -08:00
Arlan Lloyd
8aaf786400 update metadata storage + scanner docs 2025-11-18 16:04:18 +00:00
3e40842c84 Setup otel & export traces for all services (#1161) 2025-11-17 23:38:30 +01:00
8ab4146241 Fix format 2025-11-17 23:20:09 +01:00
0fa25eaeca Use my work of drizzle-otel to properly instrument db errors 2025-11-17 23:07:59 +01:00
2194831d86 Manually instrument scanner 2025-11-17 23:07:59 +01:00
7124a3d3c6 Instrument scanner 2025-11-17 23:07:59 +01:00
55a22f1c9e Instrument pgx 2025-11-17 23:07:54 +01:00
6d58164a6d Instrument auth's echo 2025-11-17 23:03:49 +01:00
fea9c16515 Instrument drizzle 2025-11-17 23:03:49 +01:00
01883d08cc Instrument elyzia 2025-11-17 23:03:49 +01:00
acelinkio
03792487c3 chore(deps): update postgres docker tag to v0.11.6 (#1158) 2025-11-17 07:50:27 -08:00
renovate[bot]
87a0fa39f7 chore(deps): update postgres docker tag to v0.11.6 2025-11-17 15:03:19 +00:00
renovate[bot]
64dae6ddce chore(deps): update dependency @biomejs/biome to v2.3.5 2025-11-17 01:48:59 +00:00
Arlan Lloyd
efec489c96 update diagrams for v5 2025-11-15 20:11:24 +00:00
Arlan Lloyd
5837b9875d update project structure 2025-11-14 16:46:51 +00:00
1e1a6a1159 chart: v5 update (#884) 2025-11-14 09:41:09 +01:00
5e63b57440 Fix pagination URLs when behind SSL-terminating reverse proxy 2025-11-13 14:04:05 +01:00
Arlan Lloyd
befc0fc84f update sslmode default for kyoo_api 2025-11-12 23:41:40 +00:00
Arlan Lloyd
9bbdb3d7f0 add way to specify shared database 2025-11-12 06:50:52 +00:00
Arlan Lloyd
58690eb428 copy default claims from .env 2025-11-11 15:29:32 +00:00
Arlan Lloyd
7d47b7642c add sslmode to api/scanner & fix default for transcoder 2025-11-10 19:11:33 +00:00
Arlan Lloyd
2f3682c226 remove unneeded comment 2025-11-10 18:49:32 +00:00
Arlan Lloyd
d952444919 fix scanner claims 2025-11-10 17:13:45 +00:00
Arlan Lloyd
86cb391425 add interpolation to handle apikey concatination 2025-11-10 16:01:08 +00:00
Arlan Lloyd
ed6623293b fix probe locations 2025-11-10 15:27:58 +00:00
Arlan Lloyd
7a756dd67c remove test manifest 2025-11-10 14:28:16 +00:00
Arlan Lloyd
e71640a636 remove test values 2025-11-10 14:26:47 +00:00
renovate[bot]
f4b1ab5fa0 Update dependency @biomejs/biome to v2.3.4 (#1152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 11:11:34 +01:00
renovate[bot]
90475e47b1 Update aws-sdk-go-v2 monorepo (#1137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 09:49:56 +00:00
renovate[bot]
c32b58e974 Update dependency @biomejs/biome to v2.3.3 (#1148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:38:15 +01:00
renovate[bot]
211f75f71a Update dependency go to v1.25.4 (#1149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:38:07 +01:00
renovate[bot]
f0b9f3afdc Update module github.com/asticode/go-astisub to v0.38.0 (#1124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:37:51 +01:00
renovate[bot]
6bc041723e Update module golang.org/x/sync to v0.18.0 (#1151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:37:39 +01:00
Arlan Lloyd
82ea4fbe0b move oidc vars 2025-11-10 04:44:19 +00:00
Arlan Lloyd
1749dc814b add postgres extension + config 2025-11-10 04:35:50 +00:00
Arlan Lloyd
dfc886127a update schema configs 2025-11-10 04:30:12 +00:00
Arlan Lloyd
4943c94182 remove swagger middleware 2025-11-10 04:21:09 +00:00
Arlan Lloyd
238702f81c add probes 2025-11-10 03:59:53 +00:00
Antoine Labarussias
c60abb26f9 fix(chart): send cookie header to middleware 2025-11-09 20:13:20 +01:00
Antoine Labarussias
2fb393bb45 fix(chart): update transcoder pg env vars 2025-11-09 20:13:20 +01:00
Antoine Labarussias
6bab124331 fix(chart): set existingSecret to null 2025-11-09 20:13:20 +01:00
Antoine Labarussias
9c57e01426 feat(chart): add auth private key 2025-11-09 20:13:20 +01:00
Arlan Lloyd
66dedaee29 update apikey format to $name-$key 2025-11-09 20:13:20 +01:00
Arlan Lloyd
eb31c0d8e6 add in scanner & extra apikey support 2025-11-09 20:13:20 +01:00
Arlan Lloyd
cd65632527 allow for specifying middleware proxy root url 2025-11-09 20:13:20 +01:00
Arlan Lloyd
a563d8f8ba update docs 2025-11-09 20:13:20 +01:00
Arlan Lloyd
60082ee799 remove apikey reference 2025-11-09 20:13:20 +01:00
Arlan Lloyd
6065b73d13 fix port metadata 2025-11-09 20:13:20 +01:00
Arlan Lloyd
32a9dfc11c fix middleware 2025-11-09 20:13:20 +01:00
Arlan Lloyd
57c135c86b set traefik args via extraArgs 2025-11-09 20:13:20 +01:00
Arlan Lloyd
5064111a93 update traefik configs 2025-11-09 20:13:20 +01:00
Arlan Lloyd
de718b6a46 update scanner settings 2025-11-09 20:13:20 +01:00
Arlan Lloyd
d7748eb83e remove specifying auth schema and rsa 2025-11-09 20:13:20 +01:00
Arlan Lloyd
d730b5f3f4 specify traefik repo/version 2025-11-09 20:13:20 +01:00
Arlan Lloyd
5d17f8f0f9 fix subchart settings/notes 2025-11-09 20:13:20 +01:00
Arlan Lloyd
864ee3efa2 add explicit namespace 2025-11-09 20:13:20 +01:00
Arlan Lloyd
a14a0145a9 update transcoder env 2025-11-09 20:13:20 +01:00
Arlan Lloyd
a86c361825 fix api port 2025-11-09 20:13:20 +01:00
Arlan Lloyd
e17e969bfe fix scanner envs 2025-11-09 20:13:20 +01:00
Arlan Lloyd
4708186f5c update api env vars 2025-11-09 20:13:20 +01:00
Arlan Lloyd
6238c8d9a0 fix default address 2025-11-09 20:13:20 +01:00
Arlan Lloyd
7a34bbedae fix readme 2025-11-09 20:13:20 +01:00
Arlan Lloyd
e59477eddd finish renaming kyoo_back to kyoo_api 2025-11-09 20:13:20 +01:00
Arlan Lloyd
7368af7266 rename back to api 2025-11-09 20:13:20 +01:00
Arlan Lloyd
9c03f99524 move to cloudpirates postgres 2025-11-09 20:13:19 +01:00
Arlan Lloyd
54d4965a9a purge old infra var 2025-11-09 20:13:18 +01:00
Arlan Lloyd
dc7d6919da purge kyoo_migrations, no longer needed 2025-11-09 20:13:18 +01:00
Arlan Lloyd
aa180bfcea purge autosync! 2025-11-09 20:13:18 +01:00
Arlan Lloyd
5ab28622d9 purge rabbitmq 2025-11-09 20:13:17 +01:00
Arlan Lloyd
7f97ea6e90 purge meilisearch from chart 2025-11-09 20:13:14 +01:00
Arlan Lloyd
869b0206b8 purge matcher specific settings 2025-11-09 20:13:14 +01:00
Arlan Lloyd
159a4cc77a prepare ingress to traefikproxy 2025-11-09 20:13:14 +01:00
Arlan Lloyd
571590a40d update variables 2025-11-09 20:13:13 +01:00
Arlan Lloyd
0943401d03 initial add of auth 2025-11-09 20:13:13 +01:00
Arlan Lloyd
ababb67b1a update traefik configs 2025-11-09 20:13:13 +01:00
Arlan Lloyd
5bf4d70623 traefik update services configs 2025-11-09 20:13:13 +01:00
Arlan Lloyd
c495589927 use short names 2025-11-09 20:13:13 +01:00
Arlan Lloyd
5e20257202 fix middleware name 2025-11-09 20:13:13 +01:00
Arlan Lloyd
e8154c31ce update ForwardAuth docs 2025-11-09 20:13:13 +01:00
Arlan Lloyd
b1d8d00a9b update 2025-11-09 20:13:13 +01:00
Arlan Lloyd
c5c0de5493 configure defaultConfigmap 2025-11-09 20:13:13 +01:00
Arlan Lloyd
cfafe12c82 add conditional enable 2025-11-09 20:13:13 +01:00
Arlan Lloyd
594e0233ca fix template 2025-11-09 20:13:13 +01:00
Arlan Lloyd
63c5b40123 initial add Traefik
Signed-off-by: Arlan Lloyd <arlanlloyd@gmail.com>
2025-11-09 20:13:13 +01:00
1caff13adc Add /health & /ready for every service (#1147) 2025-11-09 20:12:36 +01:00
b4c85f3f28 Add /health and /ready for scanner 2025-11-09 19:58:41 +01:00
a95bbcb6eb Fix last_seen update on auth 2025-11-09 19:58:41 +01:00
61b38d5b03 Add /ready for api 2025-11-09 19:25:08 +01:00
563ae85db1 Add /health and /ready for transcoder 2025-11-09 19:21:29 +01:00
8f0fb42b47 Add /ready api to auth 2025-11-09 19:21:29 +01:00
40c13e7ddf Cleanup casing in api extension setup 2025-11-09 19:21:29 +01:00
0a862c3782 Remove phantom token middleware for swagger 2025-11-09 19:21:29 +01:00
b1723c2f2c Transcoder misc fixes (#1144)
Co-authored-by: Fred Heinecke <fred.heinecke@yahoo.com>
2025-11-09 18:07:20 +00:00
Weblate (bot)
84fcbbbb42 Translations update from Hosted Weblate (#1146) 2025-11-09 18:53:50 +01:00
Weblate (bot)
93b5b50ba1 Translations update from Hosted Weblate (#1145) 2025-11-09 18:52:22 +01:00
4f9d340ef4 Add settings page (#1143) 2025-11-09 18:49:42 +01:00
5142e2bc25 Fix expo build 2025-11-09 18:27:03 +01:00
8f7f388403 Fix claim edit always editing permissions 2025-11-09 18:27:03 +01:00
5a37327e63 Fix change password api 2025-11-09 18:27:03 +01:00
d42062679a Handle logout and account deletion 2025-11-09 16:38:10 +01:00
6bb905b388 Rework settings page 2025-11-09 16:38:10 +01:00
39cfd501ac Add automatic language detection and language setting 2025-11-09 16:38:10 +01:00
079cc6b4f9 Fix translations keys 2025-11-09 16:38:10 +01:00
951ae955ed Use a postinstall script to generate translation list 2025-11-09 16:38:10 +01:00
61708857af Rework useMutation optimistic updates & start settings page 2025-11-09 16:38:10 +01:00
d3c69876d4 Add back search logic (#1142) 2025-11-08 15:03:14 +01:00
ebaf6d2177 Delete old front routing 2025-11-08 14:40:45 +01:00
9bc30ab62d Add back search logic 2025-11-08 14:40:45 +01:00
Antoine
f71a65d134 feat(auth): update forward auth endpoint (#1141) 2025-11-06 01:25:33 +01:00
renovate[bot]
ca0722b55c chore(deps): update helm release postgresql to v18.1.3 (#1118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:42:27 +01:00
renovate[bot]
22cf24fd8c fix(deps): update module github.com/lestrrat-go/jwx/v3 to v3.0.12 (#1088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:42:15 +01:00
Antoine
19e3619a42 docs(transcoder): update env examples (#1140) 2025-11-05 09:26:20 +01:00
1db4dea56f Handle cookies in keibi + fix database/env stuff (#1135) 2025-11-04 10:09:39 +01:00
03bb51661a Format stuff 2025-11-04 09:48:21 +01:00
01177c2489 Proprely remove cookies on logout 2025-11-04 09:48:21 +01:00
a86cd969a3 Properly handle rate limits in the scanner 2025-11-04 09:48:21 +01:00
bc6c93c9c7 Fix bearer cookie being base64ed 2025-11-04 09:48:21 +01:00
572ddc69ad Fix video controller permissions 2025-11-04 09:48:21 +01:00
c1b243df9c Fix image downloading error handling 2025-11-04 09:48:21 +01:00
31500dc3c5 Use an api key for the scanner 2025-11-04 09:48:21 +01:00
509e7c08cd Switch transcoder to pgx 2025-11-04 09:48:21 +01:00
165d9e8f31 Update .env.example 2025-11-04 09:48:21 +01:00
04171af3e3 Require core.play to play videos in gocoder 2025-11-04 09:48:21 +01:00
f1ddc7e7b9 Hardcode gocoder db schema 2025-11-04 09:48:21 +01:00
5827cc32e8 Hard code keibi postgres schema 2025-11-04 09:48:21 +01:00
4dc34641ec Handle cookies in keibi 2025-11-04 09:48:21 +01:00
Antoine
14c8f25499 fix(front): small fixes (#1134) 2025-11-03 18:37:18 +00:00
Antoine
d98ac3b452 fix(api): don't crash if not superuser (#1133) 2025-11-03 12:34:30 +01:00
3e7b27342c chore(deps): update actions/upload-artifact action to v5 (#1138) 2025-11-03 09:32:57 +01:00
dd9e611bef chore(deps): update dependency node to v24 (#1139) 2025-11-03 09:32:48 +01:00
renovate[bot]
0905e4424e chore(deps): update dependency node to v24 2025-11-03 01:54:30 +00:00
renovate[bot]
64c43a7833 chore(deps): update actions/upload-artifact action to v5 2025-11-03 01:54:26 +00:00
648a03e3ea Translations update from Hosted Weblate (#1136) 2025-11-02 22:40:29 +01:00
Максим Горпиніч
eb6887d189 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (211 of 211 strings)

Translation: Kyoo/Kyoo
Translate-URL: https://hosted.weblate.org/projects/kyoo/kyoo/uk/
2025-11-02 18:51:11 +00:00
acelinkio
c6133d85ff fix: auth's publish location (#1131) 2025-11-02 12:22:07 +01:00
Antoine
8a1b90f035 fix(front): update initial state api url (#1132) 2025-11-02 12:21:44 +01:00
renovate[bot]
8348e185fc chore(deps): update dependency @biomejs/biome to v2.3.2 (#1128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 14:06:12 +01:00
renovate[bot]
1d78b26a37 fix(deps): update aws-sdk-go-v2 monorepo (#1129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 09:20:39 +01:00
a8b2e575d5 Ignore front in renovate (#1127) 2025-10-27 10:57:03 +01:00
c132afe163 Delete v4 backend + docker fixes (#1116) 2025-10-26 22:55:56 +01:00
ebad8f32a4 Fix details page query 2025-10-26 22:32:40 +01:00
fb74ed30f8 Fix dockerfile of api 2025-10-26 22:31:40 +01:00
a0be0555e0 Update api packages 2025-10-26 22:31:40 +01:00
002b9e4b35 Add front dockerfile 2025-10-26 22:31:40 +01:00
996ad52159 Update docker compose 2025-10-26 11:20:51 +01:00
3b8c21c20f Misc cleanups 2025-10-26 11:20:51 +01:00
b3b58f7a1e Delete old back 2025-10-26 11:20:51 +01:00
7739908a19 Add back info page (#1115) 2025-10-26 09:48:38 +00:00
a3f29c73ec Player rewrite (#1020) 2025-10-24 16:33:49 +02:00
5fdc96db64 Format stuff 2025-10-24 16:18:35 +02:00
9206a30182 Fix player on mobile 2025-10-24 12:33:31 +02:00
1d81981c3f Fix expo install with bun 1.3 2025-10-22 13:41:37 +02:00
a15f28e541 Update bun-types in api 2025-10-22 09:18:44 +02:00
23832929e9 Add libass support 2025-10-22 09:17:56 +02:00
3590963206 Add subtitle conversion (from srt to vtt for example) 2025-10-22 09:17:54 +02:00
5ca1ae938f wip: Add subtitles handling 2025-10-22 09:16:45 +02:00
dfdeca35f3 Add keyboard bindings for player 2025-10-22 09:16:45 +02:00
70ff2285d5 Add video & quality selector 2025-10-22 09:16:45 +02:00
c5f237771c Add audio track selector 2025-10-22 09:16:45 +02:00
8fea8b1fe7 Add player error handling and hls fallback 2025-10-22 09:16:45 +02:00
816ee8de14 Web player fixes & fullscreen 2025-10-22 09:16:45 +02:00
a7d5f94dfb Allow clientId to be specified in query params 2025-10-22 09:16:45 +02:00
b7c6ba1e85 Fix subtitle extraction race condition 2025-10-22 09:16:45 +02:00
e348464261 Use my build of rnv to fix events 2025-10-22 09:16:45 +02:00
5031cc7163 Fix package versions 2025-10-22 09:16:45 +02:00
ebfb486363 Fix select for web 2025-10-22 09:16:44 +02:00
6cb1ae5fa1 Use on-failure instead of unless-stopped in dev docker compose 2025-10-22 09:16:44 +02:00
ad41c09055 Fix some typescript issues 2025-10-22 09:16:44 +02:00
5b320974df Update packages 2025-10-22 09:16:44 +02:00
e01c67b1ef Fix player controls style 2025-10-22 09:16:44 +02:00
da5823deb2 Fix /videos/:id?with=show date formatting 2025-10-22 09:16:44 +02:00
7085a68733 fixup! Add mime_codec to subtitles in the transcoder 2025-10-22 09:16:44 +02:00
84a855602e Implement middle controls 2025-10-22 09:16:44 +02:00
57779f02b1 Keep controls open in menues or hover 2025-10-22 09:16:44 +02:00
becc550add Implement bottom controls 2025-10-22 09:16:44 +02:00
9343bb524c Remake touch controls 2025-10-22 09:16:44 +02:00
fc9695a2dc Rewrite player's controls compenents 2025-10-22 09:16:44 +02:00
a310ceaed5 Implement player's enEnd & loading indicator 2025-10-22 09:16:44 +02:00
4d8806fc7f Make history's time non nullable (it was never null anyways) 2025-10-22 09:16:44 +02:00
d70b45d1fd Add external subtitles handling 2025-10-22 09:16:44 +02:00
5eb067639b Add mime_codec to subtitles in the transcoder 2025-10-22 09:16:44 +02:00
c364d3a67e Fix rnv startup 2025-10-22 09:16:44 +02:00
666ad9279f Fix dockerignore of auth & transcoder 2025-10-22 09:16:44 +02:00
02c77d2f32 Use rnv v7 for the rework 2025-10-22 09:16:44 +02:00
ebbed77650 Add /videos/:id/direct & /videos/:id/master.m3u8 routes 2025-10-22 09:16:44 +02:00
578dc4bbc9 Delete old models package 2025-10-22 09:16:44 +02:00
e3ae961b68 Add progress to /videos/:id 2025-10-22 09:16:44 +02:00
c33ba01e54 Add with=show to /videos/:id 2025-10-22 09:16:44 +02:00
787bfc1151 Type video in the front 2025-10-22 09:16:44 +02:00
f12718d67f Cleanup video info type 2025-10-22 09:16:43 +02:00
df3e0d1ed7 Remove some transcoder's deprecated fields 2025-10-22 09:16:43 +02:00
8ffed25580 Add /api/videos/:id/info route to redirect to the transcoder 2025-10-22 09:16:43 +02:00
a0739e57f2 Init player rework 2025-10-22 09:16:43 +02:00
renovate[bot]
da79d235be fix(deps): update aws-sdk-go-v2 monorepo (#1105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 20:36:43 +00:00
renovate[bot]
ae20682c92 chore(deps): update aws-sdk-net monorepo (#1110)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 09:42:34 +02:00
renovate[bot]
99d4a21c12 chore(deps): update dependency system.componentmodel.composition to 9.0.10 (#1111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 09:42:28 +02:00
renovate[bot]
17040e9775 chore(deps): update dotnet monorepo to 8.0.21 (#1112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 09:42:22 +02:00
renovate[bot]
f92f93f960 chore(deps): update helm release postgresql to v18.0.15 (#1113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 09:42:15 +02:00
renovate[bot]
1eb832f00d chore(deps): update actions/setup-node action to v6 (#1114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 09:42:09 +02:00
renovate[bot]
b14ada8f48 chore(deps): update dependency @biomejs/biome to v2.2.6 (#1103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-19 17:04:13 +02:00
renovate[bot]
f5f7a187c4 chore(deps): update dependency go to v1.25.3 (#1104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 11:32:20 +02:00
renovate[bot]
938f7ca047 fix(deps): update module golang.org/x/text to v0.30.0 (#1109)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 22:53:09 +02:00
renovate[bot]
b0c1df2827 fix(deps): update module github.com/go-playground/validator/v10 to v10.28.0 (#1106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 09:29:16 +02:00
renovate[bot]
be0b5d1523 chore(deps): update helm release postgresql to v18 (#1107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 09:29:09 +02:00
renovate[bot]
fbee956876 chore(deps): update aws-sdk-net monorepo (#1102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 09:30:43 +02:00
renovate[bot]
db1c1a5e92 chore(deps): update aws-sdk-net monorepo (#1099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 09:32:48 +02:00
renovate[bot]
b4f9552048 chore(deps): update dependency nswag.aspnetcore to 14.6.1 (#1100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 09:32:42 +02:00
renovate[bot]
1a5b93e5aa fix(deps): update aws-sdk-go-v2 monorepo (#1101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 09:32:34 +02:00
acelinkio
216a58af24 Add runtime class in transcoder chart (#1093) 2025-09-30 07:24:41 -07:00
renovate[bot]
eff7a32b09 chore(deps): update aws-sdk-net monorepo (#1094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 10:03:15 +02:00
renovate[bot]
bd041c1a4e chore(deps): update dependency meilisearch to 0.17.1 (#1095)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:37:47 +02:00
renovate[bot]
48876494f7 chore(deps): update skiasharp monorepo to 3.119.1 (#1096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:37:36 +02:00
renovate[bot]
e4d92ff364 fix(deps): update aws-sdk-go-v2 monorepo (#1097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:37:28 +02:00
renovate[bot]
d35ab8c894 chore(deps): update dependency nswag.aspnetcore to 14.6.0 (#1098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:37:21 +02:00
596 changed files with 14638 additions and 52249 deletions

View File

@@ -10,6 +10,8 @@ LIBRARY_ROOT=./video
# You should set this to a path where kyoo can write large amount of data, this is used as a cache by the transcoder. # You should set this to a path where kyoo can write large amount of data, this is used as a cache by the transcoder.
# It will automatically be cleaned up on kyoo's startup/shutdown/runtime. # It will automatically be cleaned up on kyoo's startup/shutdown/runtime.
CACHE_ROOT=/tmp/kyoo_cache CACHE_ROOT=/tmp/kyoo_cache
# Where to store downloaded images of the shows
IMAGES_PATH="./images";
# A pattern (regex) to ignore files. # A pattern (regex) to ignore files.
LIBRARY_IGNORE_PATTERN=".*/[dD]ownloads?/.*" LIBRARY_IGNORE_PATTERN=".*/[dD]ownloads?/.*"
@@ -21,8 +23,8 @@ GOCODER_PRESET=fast
# Keep those empty to use kyoo's default api key. You can also specify a custom API key if you want. # Keep those empty to use kyoo's default api key. You can also specify a custom API key if you want.
# go to https://www.themoviedb.org/settings/api and copy the api key (not the read access token, the api key) # go to https://www.themoviedb.org/settings/api and copy the read access token (not the api key)
THEMOVIEDB_APIKEY= THEMOVIEDB_API_ACCESS_TOKEN=""
# go to https://thetvdb.com/api-information/signup and copy the api key # go to https://thetvdb.com/api-information/signup and copy the api key
TVDB_APIKEY= TVDB_APIKEY=
# you can also input your subscriber's pin to support TVDB # you can also input your subscriber's pin to support TVDB
@@ -32,40 +34,48 @@ TVDB_PIN=
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
PUBLIC_URL=http://localhost:8901 PUBLIC_URL=http://localhost:8901
# Use a builtin oidc service (google, discord, trakt, or simkl): # Default permissions of new users. They are able to browse & play videos.
# When you create a client_id, secret combo you may be asked for a redirect url. You need to specify https://YOUR-PUBLIC-URL/api/auth/logged/YOUR-SERVICE-NAME # Set `verified` to true if you don't wanna manually verify users.
OIDC_DISCORD_CLIENTID= EXTRA_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": false}'
OIDC_DISCORD_SECRET= # This is the permissions of the first user (aka the first user is admin)
# Or add your custom one: FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "apikeys.read", "apikeys.write", "core.read", "core.write", "core.play", "scanner.trigger"], "verified": true}'
OIDC_SERVICE_NAME=YourPrettyName
OIDC_SERVICE_LOGO=https://url-of-your-logo.com # Guest (meaning unlogged in users) can be:
OIDC_SERVICE_CLIENTID= # unauthorized (they need to connect before doing anything)
OIDC_SERVICE_SECRET= # GUEST_CLAIMS=""
OIDC_SERVICE_AUTHORIZATION=https://url-of-the-authorization-endpoint-of-the-oidc-service.com/auth # able to browse & see what you have but not able to play
OIDC_SERVICE_TOKEN=https://url-of-the-token-endpoint-of-the-oidc-service.com/token GUEST_CLAIMS='{"permissions": ["core.read"], "verified": true}'
OIDC_SERVICE_PROFILE=https://url-of-the-profile-endpoint-of-the-oidc-service.com/userinfo # or have browse & play permissions
OIDC_SERVICE_SCOPE="the list of scopes space separeted like email identity" GUEST_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": true}'
# Token authentication method as seen in https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
# Supported values: ClientSecretBasic (default) or ClientSecretPost # DO NOT change this.
# If in doubt, leave this empty. PROTECTED_CLAIMS="permissions,verified"
OIDC_SERVICE_AUTHMETHOD=ClientSecretBasic
# on the previous list, service is the internal name of your service, you can add as many as you want.
# Following options are optional and only useful for debugging. # You can create apikeys at runtime via POST /keys but you can also have some defined in the env.
# Replace $YOURNAME with the name of the key you want (only alpha are valid)
# The value will be the apikey (max 128 bytes)
KEIBI_APIKEY_SCANNER=EJqUB8robwKwLNt37SuHqdcsNGrtwpfYxeExfiAbokpxZVd4WctWr7gnSZ
KEIBI_APIKEY_SCANNER_CLAIMS='{"permissions": ["core.read", "core.write"]}'
# To debug the front end, you can set the following to an external backend # To debug the front end, you can set the following to an external backend
KYOO_URL= KYOO_URL=
# Database things OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
# It is recommended to use the below PG environment variables when possible.
# POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key
# The behavior of the below variables match what is documented here:
# https://www.postgresql.org/docs/current/libpq-envars.html
PGUSER=kyoo PGUSER=kyoo
PGPASSWORD=password PGPASSWORD=password
PGDATABASE=kyoo PGDATABASE=kyoo
PGHOST=postgres PGHOST=postgres
PGPORT=5432 PGPORT=5432
# PGOPTIONS=-c search_path=kyoo,public
# v5 stuff, does absolutely nothing on master (aka: you can delete this) # PGPASSFILE=/my/password # Takes precedence over PGPASSWORD. New line characters are not trimmed.
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' # PGSSLMODE=verify-full
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "scanner.trigger"], "verified": true}' # PGSSLROOTCERT=/my/serving.crt
GUEST_CLAIMS='{"permissions": ["core.read"]}' # PGSSLCERT=/my/client.crt
PROTECTED_CLAIMS="permissions,verified" # PGSSLKEY=/my/client.key=password

View File

@@ -1,3 +0,0 @@
7e6e56a366babe17e7891a5897180efbf93c00c5
a5638203a6ecb9f372a5a61e1c8fd443bf3a17fe
18e301f26acd7f2e97eac26c5f48377fa13956f5

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
*.Designer.cs linguist-generated=true

View File

@@ -19,13 +19,14 @@ jobs:
env: env:
POSTGRES_USER: kyoo POSTGRES_USER: kyoo
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: password
POSTGRES_DB: kyoo_test
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
@@ -37,3 +38,4 @@ jobs:
run: bun test run: bun test
env: env:
PGHOST: localhost PGHOST: localhost
IMAGES_PATH: ./images

View File

@@ -25,7 +25,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: gacts/install-hurl@v1 - uses: gacts/install-hurl@v1

View File

@@ -2,20 +2,6 @@ name: Coding Style
on: [pull_request, workflow_dispatch] on: [pull_request, workflow_dispatch]
jobs: jobs:
back:
name: "Lint Back"
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./back
steps:
- uses: actions/checkout@v5
- name: Check coding style
run: |
dotnet tool restore
dotnet csharpier . --check
api: api:
name: "Lint api" name: "Lint api"
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,7 +9,7 @@ jobs:
run: run:
working-directory: ./api working-directory: ./api
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Setup Biome - name: Setup Biome
uses: biomejs/setup-biome@v2 uses: biomejs/setup-biome@v2
@@ -40,7 +26,7 @@ jobs:
run: run:
working-directory: ./front working-directory: ./front
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Setup Biome - name: Setup Biome
uses: biomejs/setup-biome@v2 uses: biomejs/setup-biome@v2
@@ -51,10 +37,10 @@ jobs:
run: biome ci . run: biome ci .
scanner: scanner:
name: "Lint scanner/autosync" name: "Lint scanner"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: chartboost/ruff-action@v1 - uses: chartboost/ruff-action@v1
with: with:
@@ -67,7 +53,7 @@ jobs:
run: run:
working-directory: ./transcoder working-directory: ./transcoder
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Run go fmt - name: Run go fmt
run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi
@@ -79,7 +65,7 @@ jobs:
run: run:
working-directory: ./auth working-directory: ./auth
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Run go fmt - name: Run go fmt
run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi

View File

@@ -19,16 +19,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- context: ./back
dockerfile: Dockerfile
label: back
image: ${{ github.repository_owner }}/kyoo_back
- context: ./back
dockerfile: Dockerfile.migrations
label: migrations
image: ${{ github.repository_owner }}/kyoo_migrations
- context: ./api - context: ./api
dockerfile: Dockerfile dockerfile: Dockerfile
label: api label: api
@@ -44,11 +34,6 @@ jobs:
label: scanner label: scanner
image: ${{ github.repository_owner }}/kyoo_scanner image: ${{ github.repository_owner }}/kyoo_scanner
- context: ./autosync
dockerfile: Dockerfile
label: autosync
image: ${{ github.repository_owner }}/kyoo_autosync
- context: ./transcoder - context: ./transcoder
dockerfile: Dockerfile dockerfile: Dockerfile
label: transcoder label: transcoder
@@ -57,12 +42,12 @@ jobs:
- context: ./auth - context: ./auth
dockerfile: Dockerfile dockerfile: Dockerfile
label: auth label: auth
image: ${{ github.repository_owner }}/keibi image: ${{ github.repository_owner }}/kyoo_auth
env: env:
DOCKERHUB_ENABLED: ${{ secrets.DOCKER_USERNAME && secrets.DOCKER_PASSWORD && 'true' || 'false' }} DOCKERHUB_ENABLED: ${{ secrets.DOCKER_USERNAME && secrets.DOCKER_PASSWORD && 'true' || 'false' }}
name: Build ${{matrix.label}} name: Build ${{matrix.label}}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
id: filter id: filter

View File

@@ -4,6 +4,11 @@ on:
tags: tags:
- v* - v*
workflow_dispatch: workflow_dispatch:
inputs:
channel:
description: 'Release channel (master, edge, or leave blank for tag-based)'
required: false
default: 'master'
jobs: jobs:
release: release:
@@ -11,26 +16,34 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Helm - name: Set up Helm
uses: azure/setup-helm@v4 uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Update Helm Dependencies - name: Update Helm Dependencies
run: helm dependency update ./chart
- name: Determine Chart Version
id: version
run: | run: |
helm dependency update ./chart if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.channel }}"
else
TAG=$(echo ${GITHUB_REF#refs/tags/} | sed 's/^v//')
fi
echo "TAG=$TAG" >> "${GITHUB_ENV}"
echo "Using chart version: $TAG"
- name: Package Helm Chart - name: Package Helm Chart
run: | run: helm package ./chart --version $TAG --app-version $TAG
export tag=$(echo ${GITHUB_REF#refs/tags/} | sed 's/^v//')
helm package ./chart --version $tag --app-version $tag
- name: Build Helm-safe repo name - name: Build Helm-safe repo name
run: | run: |
@@ -38,5 +51,4 @@ jobs:
echo "REPO_NAME=${REPO_NAME}" >> "${GITHUB_ENV}" echo "REPO_NAME=${REPO_NAME}" >> "${GITHUB_ENV}"
- name: Push Helm Chart to GHCR - name: Push Helm Chart to GHCR
run: | run: helm push kyoo-*.tgz "${REPO_NAME}"
helm push kyoo-*.tgz "${REPO_NAME}"

View File

@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Set up Helm - name: Set up Helm
uses: azure/setup-helm@v4 uses: azure/setup-helm@v4

View File

@@ -13,7 +13,7 @@ jobs:
working-directory: ./front working-directory: ./front
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
# This is required because GHA doesn't support secrets in the `if` condition # This is required because GHA doesn't support secrets in the `if` condition
- name: Check if Expo build is enabled - name: Check if Expo build is enabled
@@ -27,10 +27,10 @@ jobs:
echo "Expo build is disabled for forks. To enable it, add an EXPO_TOKEN secret to this repository. See https://docs.expo.dev/eas-update/github-actions/ for more information." echo "Expo build is disabled for forks. To enable it, add an EXPO_TOKEN secret to this repository. See https://docs.expo.dev/eas-update/github-actions/ for more information."
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v5 uses: actions/setup-node@v6
if: env.IS_EXPO_ENABLED == 'true' if: env.IS_EXPO_ENABLED == 'true'
with: with:
node-version: 22.x node-version: 24.x
cache: yarn cache: yarn
cache-dependency-path: front/yarn.lock cache-dependency-path: front/yarn.lock
@@ -62,7 +62,7 @@ jobs:
if: env.IS_EXPO_ENABLED == 'true' if: env.IS_EXPO_ENABLED == 'true'
run: wget -O kyoo.apk ${{ steps.url.outputs.assetUrl }} run: wget -O kyoo.apk ${{ steps.url.outputs.assetUrl }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v5
if: env.IS_EXPO_ENABLED == 'true' if: env.IS_EXPO_ENABLED == 'true'
with: with:
name: kyoo.apk name: kyoo.apk

View File

@@ -13,7 +13,7 @@ jobs:
working-directory: ./front working-directory: ./front
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
# This is required because GHA doesn't support secrets in the `if` condition # This is required because GHA doesn't support secrets in the `if` condition
- name: Check if Expo build is enabled - name: Check if Expo build is enabled
@@ -27,10 +27,10 @@ jobs:
echo "Expo build is disabled for forks. To enable it, add an EXPO_TOKEN secret to this repository. See https://docs.expo.dev/eas-update/github-actions/ for more information." echo "Expo build is disabled for forks. To enable it, add an EXPO_TOKEN secret to this repository. See https://docs.expo.dev/eas-update/github-actions/ for more information."
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v5 uses: actions/setup-node@v6
if: env.IS_EXPO_ENABLED == 'true' if: env.IS_EXPO_ENABLED == 'true'
with: with:
node-version: 22.x node-version: 24.x
cache: yarn cache: yarn
cache-dependency-path: front/yarn.lock cache-dependency-path: front/yarn.lock

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Set correct versions - name: Set correct versions
run: | run: |

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/video /video
.devspace/
.env .env
.venv .venv
.idea .idea
@@ -6,6 +7,4 @@
log.html log.html
output.xml output.xml
report.html report.html
chart/charts
chart/Chart.lock
tmp tmp

View File

@@ -1,4 +0,0 @@
# Authors
Ordered by the date of the first commit.
* Zoe Roux ([@zoriya](http://github.com/zoriya))

View File

@@ -5,27 +5,26 @@ These diagrams are created with Mermaid and rendered locally. For the best expe
Kyoo is a monorepo that consists of several projects each in their own directory. Diagram below shows an outline of kyoo, projects, and artifacts. Kyoo is a monorepo that consists of several projects each in their own directory. Diagram below shows an outline of kyoo, projects, and artifacts.
```mermaid ```mermaid
block-beta block
columns 1 columns 1
block:proj1:1 block:proj1:1
proj_name["Kyoo"]:1 proj_name["Kyoo"]:1
end end
block:proj2:1 block:proj2:1
dir_1["autosync/"] dir_1["api/"]
dir_2["back/"] dir_2["auth/"]
dir_3["front/"] dir_3["front/"]
dir_4["transcoder/"] dir_4["transcoder/"]
dir_5["scanner/"] dir_5["scanner/"]
end end
block:proj3:1 block:proj3:1
%% columns auto (default) %% columns auto (default)
block:autosync_b:1 block:api_b:1
autosync_i1("kyoo_autosync") autosync_i1("kyoo_api")
end end
block:back_b:1 block:auth_b:1
columns 1 columns 1
back_i1("kyoo_back") back_i1("kyoo_auth")
back_i2("kyoo_migrations")
end end
block:front_b:1 block:front_b:1
front_i1("kyoo_front") front_i1("kyoo_front")
@@ -36,7 +35,6 @@ block-beta
block:scanner_b:1 block:scanner_b:1
columns 1 columns 1
scanner_i1("kyoo_scanner") scanner_i1("kyoo_scanner")
scanner_i2("kyoo_scanner*")
end end
end end
@@ -51,19 +49,17 @@ block-beta
style dir_4 fill:#438dd5,stroke-width:0px style dir_4 fill:#438dd5,stroke-width:0px
style dir_5 fill:#438dd5,stroke-width:0px style dir_5 fill:#438dd5,stroke-width:0px
style autosync_b fill:#438dd5,stroke-width:0px style api_b fill:#438dd5,stroke-width:0px
style back_b fill:#438dd5,stroke-width:0px style auth_b fill:#438dd5,stroke-width:0px
style front_b fill:#438dd5,stroke-width:0px style front_b fill:#438dd5,stroke-width:0px
style transcoder_b fill:#438dd5,stroke-width:0px style transcoder_b fill:#438dd5,stroke-width:0px
style scanner_b fill:#438dd5,stroke-width:0px style scanner_b fill:#438dd5,stroke-width:0px
style autosync_i1 fill:#85bbf0,stroke-width:0px style autosync_i1 fill:#85bbf0,stroke-width:0px
style back_i1 fill:#85bbf0,stroke-width:0px style back_i1 fill:#85bbf0,stroke-width:0px
style back_i2 fill:#85bbf0,stroke-width:0px
style front_i1 fill:#85bbf0,stroke-width:0px style front_i1 fill:#85bbf0,stroke-width:0px
style transcoder_i1 fill:#85bbf0,stroke-width:0px style transcoder_i1 fill:#85bbf0,stroke-width:0px
style scanner_i1 fill:#85bbf0,stroke-width:0px style scanner_i1 fill:#85bbf0,stroke-width:0px
style scanner_i2 fill:#85bbf0,stroke-width:0px
``` ```
# C4 Diagrams # C4 Diagrams
@@ -89,212 +85,165 @@ C4Context
``` ```
## Container ## Container
Messaging is middleware. EnterpriseMessageBus is for any messaging handled between different projects. Kyoo leverages the [API Gateway](https://learn.microsoft.com/en-us/azure/architecture/microservices/design/gateway) approach to microservices and [offloads](https://learn.microsoft.com/en-us/azure/architecture/patterns/gateway-offloading) authentication at the gateway.
```mermaid ```mermaid
C4Container C4Container
UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="3") UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")
title Container diagram for Kyoo System title Container diagram for Kyoo System
Person(user, "User") Person(user, "User")
System_Boundary(internal, "Kyoo") { Container(apigateway, "API Gateway")
Container(frontend, "front/") Container(auth, "auth")
Container(backend, "back/") Container(transcoder, "transcoder")
ContainerQueue(emb, "emb", "", "EnterpriseMessageBus") Container(scanner, "scanner")
Container(transcoder, "transcoder/") Container(frontend, "front")
Container(scanner, "scanner/")
Container(autosync, "autosync/")
}
System_Boundary(external, "") {
System_Ext(content, "ContentDatabase", "")
}
System_Boundary(external2, "") {
System_Ext(tracker, "ActivityTracker", "")
}
System_Boundary(external3, "") {
System_Ext(media, "MediaLibrary", "") System_Ext(media, "MediaLibrary", "")
} System_Ext(content, "ContentDatabase", "")
Container(api, "api")
System_Ext(tracker, "ActivityTracker", "")
Rel(user, frontend, "")
Rel(user, backend, "") Rel(user, apigateway, "")
Rel(frontend, backend, "") Rel(apigateway, frontend, "")
Rel(backend, emb, "") Rel(apigateway, scanner, "")
Rel(backend, transcoder, "") Rel(apigateway, transcoder, "")
Rel_Back(autosync, emb, "") Rel(apigateway, api, "")
Rel(autosync, tracker, "") Rel(apigateway, auth, "")
Rel_Back(scanner, emb, "") Rel(frontend, api, "")
Rel(scanner, backend, "") Rel(api, tracker, "")
Rel(api, content, "")
Rel(scanner, api, "")
Rel(scanner, media, "") Rel(scanner, media, "")
Rel(scanner, content, "") Rel(scanner, content, "")
Rel(transcoder, media, "") Rel(transcoder, media, "")
``` ```
## Component ## Component
### Autosync #### Auth
```mermaid Auth microservice is implicitly used by each other microservice for both end user authentication and microservice to microservice communications. Auth microservice will not be directly represented in the other component diagrams. Instead in their relationsihp will specify "auth via middleware".
C4Component
UpdateLayoutConfig($c4ShapeInRow="4", $c4BoundaryInRow="2")
title Component Diagram for Autosync
Container_Boundary(autosync, "autosync") {
Component(autosync_c1, "kyoo_autosync", "python, python3.12", "")
}
Container_Boundary(emb, "emb") {
ComponentQueue(emb_q1, "autosync", "RabbitMQ, Queue", "")
ComponentQueue(emb_e1, "events.watched", "RabbitMQ, Exchange", "")
}
Container_Boundary(tracker, "ActivityTracker") {
Component_Ext(tracker_c1, "TrackerProvider", "API", "simkl")
}
Container_Boundary(backend, "back") {
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
}
Rel(emb_e1, emb_q1, "bound")
Rel_Back(autosync_c1, emb_q1, "consumes")
Rel(backend_c2, emb_e1, "produces")
Rel(autosync_c1, tracker_c1, "updates")
```
### Back
```mermaid ```mermaid
C4Component C4Component
UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="2") UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="2")
title Component Diagram for Back title Auth Component Diagram
Person(user, "User") Container_Boundary(auth, "auth") {
Component(auth_c1, "kyoo_auth", "Go", "")
Container_Boundary(frontend, "front") { ComponentDb(auth_db1, "kelbi", "Postgres", "")
Component(frontend_c1, "kyoo_front", "typescript, node.js", "Static Content")
} }
Container_Boundary(backend, "back") { Rel(auth_c1, auth_db1, "")
ComponentDb(backend_db2, "search", "Meilisearch", "search resource")
Component(backend_c3, "BackendMetadata", "Volume", "Persistent. Distributed Metadata")
ComponentDb(backend_db1, "backend", "Postgres", "user data and session state")
Component(backend_c1, "kyoo_migrations", "C#, .NET 8.0", "Postgres Migration")
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
}
Container_Boundary(emb, "emb") {
ComponentQueue(emb_e1, "events.watched", "RabbitMQ, Exchange", "")
ComponentQueue(emb_q1, "autosync", "RabbitMQ, Queue", "")
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
ComponentQueue(emb_e2, "events.resource", "RabbitMQ, Exchange", "unused")
}
Container_Boundary(scanner, "scanner") {
Component(scanner_c2, "kyoo_scanner", "python, python3.12", "matcher")
Component(scanner_c1, "kyoo_scanner", "python, python3.12", "scanner")
}
Container_Boundary(autosync, "autosync") {
Component(autosync_c1, "kyoo_autosync", "python, python3.12", "")
}
Container_Boundary(transcoder, "transcoder") {
Component(transcoder_c1, "kyoo_transcoder", "go, go", "Video Transcoder")
}
Rel(user, backend_c2, "")
Rel(backend_c1, backend_db1, "")
Rel(backend_c2, backend_db1, "")
Rel(backend_c2, backend_db2, "")
Rel(backend_c2, transcoder_c1, "")
Rel(backend_c2, backend_c3, "")
Rel(backend_c2, emb_q2, "produces")
Rel(backend_c2, emb_e1, "produces")
Rel(backend_c2, emb_e2, "produces")
Rel(emb_e1, emb_q1, "bound")
Rel_Back(autosync_c1, emb_q1, "consumes")
Rel_Back(scanner_c1, emb_q2, "consumes")
Rel(scanner_c1, backend_c2, "")
Rel(scanner_c2, backend_c2, "")
Rel(frontend_c1, backend_c2, "")
``` ```
### Front #### Api
```mermaid
C4Component
UpdateLayoutConfig($c4ShapeInRow="4", $c4BoundaryInRow="2")
title Component Diagram for Front
Person(user, "User")
Container_Boundary(frontend, "front") {
Component(frontend_c1, "kyoo_front", "typescript, node.js", "Static Content")
}
Container_Boundary(backend, "back") {
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
}
Rel(frontend_c1, backend_c2, "ssr")
Rel(user, frontend_c1, "")
```
### Scanner
```mermaid
C4Component
UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="3")
title Component Diagram for Scanner
Container_Boundary(media, "MediaLibrary") {
Component_Ext(media_c1, "MediaShare", "Volume", "Read Only")
}
Container_Boundary(content, "ContentDatabase") {
Component_Ext(content_c1, "ContentProvider", "API", "tmdb or tvdb")
}
Container_Boundary(scanner, "scanner") {
Component(scanner_c2, "kyoo_scanner", "python, python3.12", "matcher")
ComponentQueue(scanner_q1, "scanner", "RabbitMQ, Queue", "")
Component(scanner_c1, "kyoo_scanner", "python, python3.12", "scanner")
}
Container_Boundary(backend, "back") {
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
}
Container_Boundary(emb, "emb") {
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
}
Rel(scanner_c1, scanner_q1, "produces")
Rel(scanner_c1, media_c1, "watches")
Rel(scanner_c1, backend_c2, "Fetch existing scans")
Rel(scanner_c2, content_c1, "Fetch media data")
Rel(scanner_c2, backend_c2, "Pushes media data")
Rel_Back(scanner_c2, scanner_q1, "consumes")
Rel_Back(scanner_c1, emb_q2, "consumes")
Rel(backend_c2, emb_q2, "produces")
```
### Transcoder
```mermaid ```mermaid
C4Component C4Component
UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="2") UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="2")
title Component Diagram for Transcoder title Api Component Diagram
Person(user, "User")
Container_Boundary(api, "api") {
ComponentDb(api_db1, "kyoo", "Postgres", "")
Component(api_c1, "kyoo_api", "TypeScript", "")
Component(api_c2, "ApiMetadata", "S3/Volume", "Persistent. Distributed Metadata")
}
Container_Boundary(scanner, "scanner") {
Component(scanner_c1, "kyoo_scanner", "Python", "")
}
System_Boundary(external, "") {
System_Ext(tracker, "ActivityTracker", "")
System_Ext(content, "ContentDatabase", "")
}
Container_Boundary(front, "front") {
Component(front_c1, "kyoo_front", "TypeScript", "")
}
Rel(user, api_c1, "auth via middleware")
Rel(api_c1, api_db1, "")
Rel(api_c1, api_c2, "")
Rel(api_c1, content, "http(s) <br/> retries media images")
Rel(api_c1, tracker, "")
Rel(scanner_c1, api_c1, "auth via middleware")
Rel(front_c1, api_c1, "http(s) SSR <br/> auth via middleware")
```
#### Front
```mermaid
C4Component
UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="2")
title Front Component Diagram
Person(user, "User")
Container_Boundary(front, "front") {
Component(front_c1, "kyoo_front", "TypeScript", "")
}
Container_Boundary(api, "api") {
Component(api_c1, "kyoo_api", "TypeScript", "")
}
Rel(user, front_c1, "")
Rel(front_c1, api_c1, "http(s) SSR <br/> auth via middleware")
```
#### Transcoder
```mermaid
C4Component
UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="1")
title Transcoder Component Diagram
Person(user, "User")
Container_Boundary(transcoder, "transcoder") { Container_Boundary(transcoder, "transcoder") {
Component(transcoder_c2, "TranscodeMetadata", "Volume", "Persistent. Distributed Metadata") ComponentDb(transcoder_db1, "gocoder", "Postgres", "")
Component(transcoder_c1, "kyoo_transcoder", "go, go", "Video Transcoder") Component(transcoder_c1, "kyoo_transcoder", "Go", "")
Component(transcoder_c2, "TranscodeMetadata", "S3/Volume", "Persistent. Distributed Metadata")
Component(transcoder_c3, "TranscodeCache", "Volume", "Volatile. Local cache") Component(transcoder_c3, "TranscodeCache", "Volume", "Volatile. Local cache")
} }
Container_Boundary(media, "MediaLibrary") {
Component_Ext(media_c1, "MediaShare", "Volume", "Read Only") System_Boundary(external, "") {
} System_Ext(media, "MediaLibrary", "")
Container_Boundary(backend, "back") {
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
} }
Rel(transcoder_c1, media_c1, "mounts") Rel(user, transcoder_c1, "auth via middleware")
Rel(transcoder_c1, media, "mounted to filesystem <br/> reads")
Rel(transcoder_c1, transcoder_db1, "")
Rel(transcoder_c1, transcoder_c2, "") Rel(transcoder_c1, transcoder_c2, "")
Rel(transcoder_c1, transcoder_c3, "") Rel(transcoder_c1, transcoder_c3, "")
Rel(backend_c2, transcoder_c1, "") ```
#### Scanner
```mermaid
C4Component
UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="2")
title Scanner Component Diagram
Person(user, "User")
Container_Boundary(api, "api") {
Component(api_c1, "kyoo_api", "TypeScript", "")
}
Container_Boundary(scanner, "scanner") {
Component(scanner_c1, "kyoo_scanner", "Python", "")
ComponentDb(scanner_db1, "scanner", "Postgres", "")
}
System_Boundary(external, "") {
System_Ext(content, "ContentDatabase", "")
System_Ext(media, "MediaLibrary", "")
}
Rel(user, scanner_c1, "http(s) <br/> auth via middleware")
Rel(scanner_c1, api_c1, "http(s) <br/> auth via middleware")
Rel(scanner_c1, scanner_db1, "")
Rel(scanner_c1, content, "http(s) <br/> gathers media metadata")
Rel(scanner_c1, media, "mounted to filesystem <br/> watches")
``` ```

View File

@@ -45,7 +45,7 @@ Then, fill the following environment variables in your `.env` file:
```env ```env
PUBLIC_URL=https://your-kyoo-instance.com PUBLIC_URL=https://your-kyoo-instance.com
OIDC_GOOGLE_NAME=Google OIDC_GOOGLE_NAME=Google
OIDC_GOOGLE_LOGO=https://logo.clearbit.com/google.com OIDC_GOOGLE_LOGO=https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s200
OIDC_GOOGLE_CLIENTID=<client-id> # the client ID you got from Google OIDC_GOOGLE_CLIENTID=<client-id> # the client ID you got from Google
OIDC_GOOGLE_SECRET=<client-secret> # the client secret you got from Google OIDC_GOOGLE_SECRET=<client-secret> # the client secret you got from Google
OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth

View File

@@ -39,10 +39,7 @@ Kyoo does not have a plugin system and aim to have every features built-in (see
## 📺 Clients ## 📺 Clients
Kyoo currently supports Web and Android clients, with additional platforms being thought about. Rough estimates: Kyoo currently supports Web and Android clients, with additional platforms being thought about.
* Today: Web & Android
* Spring 2025: Chromecast
* Summer 2025: Android-TV
Don't see your client? Kyoo is focused on adding features, but welcomes contributors! The frontend is built with React-Native and Expo. Come hang and develop with us on Discord. Don't see your client? Kyoo is focused on adding features, but welcomes contributors! The frontend is built with React-Native and Expo. Come hang and develop with us on Discord.

View File

@@ -11,6 +11,9 @@ AUTH_SERVER=http://auth:4568
IMAGES_PATH=./images IMAGES_PATH=./images
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
# It is recommended to use the below PG environment variables when possible. # It is recommended to use the below PG environment variables when possible.
# POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key # POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key
# The behavior of the below variables match what is documented here: # The behavior of the below variables match what is documented here:

View File

@@ -1,4 +1,4 @@
FROM oven/bun AS builder FROM oven/bun:debian AS builder
WORKDIR /app WORKDIR /app
COPY package.json bun.lock . COPY package.json bun.lock .
@@ -12,17 +12,21 @@ COPY tsconfig.json .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN bun build \ RUN bun build \
--compile \ --compile \
# needed for sharp
--compile-autoload-package-json \
--minify-whitespace \ --minify-whitespace \
--minify-syntax \ --minify-syntax \
--target bun \ --target bun \
--outfile server \ --outfile server \
./src/index.ts ./src/index.ts
FROM gcr.io/distroless/base FROM debian
WORKDIR /app WORKDIR /app
COPY --from=builder /app/server server COPY --from=builder /app/server server
COPY --from=builder /app/node_modules/@img /app/node_modules/@img
COPY ./drizzle ./drizzle
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3567 EXPOSE 3567
CMD ["./server"] CMD ["/app/server"]

View File

@@ -1,150 +1,245 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "api", "name": "api",
"dependencies": { "dependencies": {
"@elysiajs/opentelemetry": "^1.4.8",
"@elysiajs/swagger": "zoriya/elysia-swagger#build", "@elysiajs/swagger": "zoriya/elysia-swagger#build",
"@kubiks/otel-drizzle": "zoriya/drizzle-otel#build",
"@types/bun": "^1.3.1",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.5",
"drizzle-orm": "0.43.1", "drizzle-orm": "0.44.7",
"elysia": "^1.3.1", "elysia": "^1.4.13",
"jose": "^6.0.11", "jose": "^6.1.0",
"node-addon-api": "^8.5.0",
"parjs": "^1.3.9", "parjs": "^1.3.9",
"pg": "^8.16.0", "pg": "^8.16.3",
"sharp": "^0.34.2", "sharp": "^0.34.4",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "2.3.8",
"@types/pg": "^8.15.2", "@types/pg": "^8.15.5",
"bun-types": "^1.2.14",
"node-addon-api": "^8.3.1",
}, },
}, },
}, },
"patchedDependencies": { "patchedDependencies": {
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", "drizzle-orm@0.44.7": "patches/drizzle-orm@0.44.7.patch",
}, },
"packages": { "packages": {
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="], "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="], "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="], "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="], "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="], "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="], "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.8", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-c9unbcdXfehExCv1GsiTCfos5SyIAyDwP7apcMeXmUMBaJZiAYMfiEH8RFFFIfIHJHC/xlNJzUPodkcUaaoJJQ=="],
"@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#f88fbc7", { "dependencies": { "@scalar/themes": "^0.9.81", "@scalar/types": "^0.1.3", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "zoriya-elysia-swagger-f88fbc7"], "@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#f88fbc7", { "dependencies": { "@scalar/themes": "^0.9.81", "@scalar/types": "^0.1.3", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "zoriya-elysia-swagger-f88fbc7"],
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.3", "", { "os": "android", "cpu": "arm" }, "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.3", "", { "os": "android", "cpu": "arm64" }, "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ=="], "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.3", "", { "os": "android", "cpu": "x64" }, "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ=="], "@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w=="], "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A=="], "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw=="], "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q=="], "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.3", "", { "os": "linux", "cpu": "arm" }, "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ=="], "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A=="], "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag=="], "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg=="], "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA=="], "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ=="], "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.3", "", { "os": "linux", "cpu": "x64" }, "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA=="], "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.3", "", { "os": "none", "cpu": "arm64" }, "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.3", "", { "os": "none", "cpu": "x64" }, "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ=="], "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA=="], "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg=="], "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g=="], "@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="], "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="], "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="], "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="], "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="], "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ=="], "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q=="], "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw=="], "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ=="], "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA=="], "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA=="], "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.2", "", { "dependencies": { "@emnapi/runtime": "^1.4.3" }, "cpu": "none" }, "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ=="], "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ=="], "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw=="], "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw=="], "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@kubiks/otel-drizzle": ["@kubiks/otel-drizzle@github:zoriya/drizzle-otel#cc1d59b", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "drizzle-orm": ">=0.28.0" } }, "zoriya-drizzle-otel-cc1d59b"],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="],
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="],
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="],
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="],
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="],
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="],
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="],
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="],
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], "@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
@@ -158,45 +253,65 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="], "@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="],
"@types/pg": ["@types/pg@8.15.2", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-+BKxo5mM6+/A1soSHBI7ufUglqYXntChLDyTbvcAn1Lawi9J7J9Ok3jt6w7I0+T/UDJ4CyhHk66+GZbwmkYxSg=="], "@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="], "blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="], "char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"drizzle-kit": ["drizzle-kit@0.31.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-PUjYKWtzOzPtdtQlTHQG3qfv4Y0XT8+Eas6UbxCmxTj7qgMf+39dDujf1BP1I+qqZtw9uzwTh8jYtkMuCq+B0Q=="], "drizzle-kit": ["drizzle-kit@0.31.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg=="],
"drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"elysia": ["elysia@1.3.1", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-En41P6cDHcHtQ0nvfsn9ayB+8ahQJqG1nzvPX8FVZjOriFK/RtZPQBtXMfZDq/AsVIk7JFZGFEtAVEmztNJVhQ=="], "elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="],
"esbuild": ["esbuild@0.25.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.3", "@esbuild/android-arm": "0.25.3", "@esbuild/android-arm64": "0.25.3", "@esbuild/android-x64": "0.25.3", "@esbuild/darwin-arm64": "0.25.3", "@esbuild/darwin-x64": "0.25.3", "@esbuild/freebsd-arm64": "0.25.3", "@esbuild/freebsd-x64": "0.25.3", "@esbuild/linux-arm": "0.25.3", "@esbuild/linux-arm64": "0.25.3", "@esbuild/linux-ia32": "0.25.3", "@esbuild/linux-loong64": "0.25.3", "@esbuild/linux-mips64el": "0.25.3", "@esbuild/linux-ppc64": "0.25.3", "@esbuild/linux-riscv64": "0.25.3", "@esbuild/linux-s390x": "0.25.3", "@esbuild/linux-x64": "0.25.3", "@esbuild/netbsd-arm64": "0.25.3", "@esbuild/netbsd-x64": "0.25.3", "@esbuild/openbsd-arm64": "0.25.3", "@esbuild/openbsd-x64": "0.25.3", "@esbuild/sunos-x64": "0.25.3", "@esbuild/win32-arm64": "0.25.3", "@esbuild/win32-ia32": "0.25.3", "@esbuild/win32-x64": "0.25.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -204,59 +319,81 @@
"file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="], "file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-addon-api": ["node-addon-api@8.3.1", "", {}, "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA=="], "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-interval-tree": ["node-interval-tree@1.3.3", "", { "dependencies": { "shallowequal": "^1.0.2" } }, "sha512-K9vk96HdTK5fEipJwxSvIIqwTqr4e3HRJeJrNxBSeVMNSC/JWARRaX7etOLOuTmrRMeOI/K5TCJu3aWIwZiNTw=="], "node-interval-tree": ["node-interval-tree@1.3.3", "", { "dependencies": { "shallowequal": "^1.0.2" } }, "sha512-K9vk96HdTK5fEipJwxSvIIqwTqr4e3HRJeJrNxBSeVMNSC/JWARRaX7etOLOuTmrRMeOI/K5TCJu3aWIwZiNTw=="],
"obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"parjs": ["parjs@1.3.9", "", { "dependencies": { "char-info": "0.3.*" } }, "sha512-zmQhbzWM3M391tjwTGvNvvtoT8rRE/bBTjw6+54g8ANaPpnyekDF1d8q5tzN4kxmVud82cNj8zSd+uxSL4LE0A=="], "parjs": ["parjs@1.3.9", "", { "dependencies": { "char-info": "0.3.*" } }, "sha512-zmQhbzWM3M391tjwTGvNvvtoT8rRE/bBTjw6+54g8ANaPpnyekDF1d8q5tzN4kxmVud82cNj8zSd+uxSL4LE0A=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="], "peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="],
"pg": ["pg@8.16.0", "", { "dependencies": { "pg-connection-string": "^2.9.0", "pg-pool": "^3.10.0", "pg-protocol": "^1.10.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg=="], "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.5", "", {}, "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg=="], "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
"pg-connection-string": ["pg-connection-string@2.9.0", "", {}, "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ=="], "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
"pg-pool": ["pg-pool@3.10.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA=="],
"pg-protocol": ["pg-protocol@1.10.0", "", {}, "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q=="], "pg-protocol": ["pg-protocol@1.10.0", "", {}, "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q=="],
"pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
"postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -264,9 +401,9 @@
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
"sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], "sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -274,27 +411,39 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="], "strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="], "uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "pg/pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
@@ -339,13 +488,5 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
"pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
} }
} }

23
api/devspace.yaml Normal file
View File

@@ -0,0 +1,23 @@
version: v2beta1
name: api
dev:
api:
imageSelector: ghcr.io/zoriya/kyoo_api
devImage: docker.io/oven/bun:latest
workingDir: /app
sync:
- path: .:/app
excludePaths:
- node_modules
startContainer: true
onUpload:
exec:
- command: bun install --frozen-lockfile
onChange:
- "./bun.lock"
command:
- bash
- -c
- "bun install && bun dev"
ports:
- port: "3567"

View File

@@ -0,0 +1,3 @@
ALTER TABLE "kyoo"."history" ALTER COLUMN "time" SET DEFAULT 0;--> statement-breakpoint
ALTER TABLE "kyoo"."history" ALTER COLUMN "time" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."mqueue" ADD COLUMN "priority" integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "kyoo"."seasons" ALTER COLUMN "entries_count" SET DEFAULT 0;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,20 @@
"when": 1752446736231, "when": 1752446736231,
"tag": "0022_seasons-count", "tag": "0022_seasons-count",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1763924097229,
"tag": "0023_mqueue-priority",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1763932730557,
"tag": "0024_fix-season-count",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,24 +9,26 @@
"format": "biome check --write ." "format": "biome check --write ."
}, },
"dependencies": { "dependencies": {
"@elysiajs/opentelemetry": "^1.4.8",
"@elysiajs/swagger": "zoriya/elysia-swagger#build", "@elysiajs/swagger": "zoriya/elysia-swagger#build",
"@kubiks/otel-drizzle": "zoriya/drizzle-otel#build",
"@types/bun": "^1.3.1",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.5",
"drizzle-orm": "0.43.1", "drizzle-orm": "0.44.7",
"elysia": "^1.3.1", "elysia": "^1.4.13",
"jose": "^6.0.11", "jose": "^6.1.0",
"node-addon-api": "^8.5.0",
"parjs": "^1.3.9", "parjs": "^1.3.9",
"pg": "^8.16.0", "pg": "^8.16.3",
"sharp": "^0.34.2" "sharp": "^0.34.4"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "2.3.8",
"@types/pg": "^8.15.2", "@types/pg": "^8.15.5"
"bun-types": "^1.2.14",
"node-addon-api": "^8.3.1"
}, },
"module": "src/index.js", "module": "src/index.js",
"patchedDependencies": { "patchedDependencies": {
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch" "drizzle-orm@0.44.7": "patches/drizzle-orm@0.44.7.patch"
} }
} }

View File

@@ -1,8 +1,8 @@
diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs
index a0ef03142f21d319376bc50070ff7fdcd4d18132..45fc94e5a7c3fa4c201e636dd227122164e1bd02 100644 index d776a1fb503f35b5e53c6d3c1c086efa8230ea94..86541bf408e4955029c65be59d7b8ec98bb6e914 100644
--- a/pg-core/dialect.cjs --- a/pg-core/dialect.cjs
+++ b/pg-core/dialect.cjs +++ b/pg-core/dialect.cjs
@@ -348,7 +348,14 @@ class PgDialect { @@ -347,7 +347,14 @@ class PgDialect {
buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) {
const valuesSqlList = []; const valuesSqlList = [];
const columns = table[import_table2.Table.Symbol.Columns]; const columns = table[import_table2.Table.Symbol.Columns];
@@ -19,10 +19,10 @@ index a0ef03142f21d319376bc50070ff7fdcd4d18132..45fc94e5a7c3fa4c201e636dd2271221
([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column)) ([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column))
); );
diff --git a/pg-core/dialect.js b/pg-core/dialect.js diff --git a/pg-core/dialect.js b/pg-core/dialect.js
index 120aaed9c3e4ae0a24653893379b98506c866f6f..48df463c0a6d5864fe2c324c8f86432860e50e00 100644 index 74a16c9e86fe3a89ced32af44af0a72f1cf43cf6..08f820d46a040c315fed40b8b36d94d094174425 100644
--- a/pg-core/dialect.js --- a/pg-core/dialect.js
+++ b/pg-core/dialect.js +++ b/pg-core/dialect.js
@@ -346,7 +346,14 @@ class PgDialect { @@ -345,7 +345,14 @@ class PgDialect {
buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) {
const valuesSqlList = []; const valuesSqlList = [];
const columns = table[Table.Symbol.Columns]; const columns = table[Table.Symbol.Columns];
@@ -39,10 +39,10 @@ index 120aaed9c3e4ae0a24653893379b98506c866f6f..48df463c0a6d5864fe2c324c8f864328
([, column]) => sql.identifier(this.casing.getColumnCasing(column)) ([, column]) => sql.identifier(this.casing.getColumnCasing(column))
); );
diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs
index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..20c8036374a1f25f7c5880c40e8d3c42c05f3eee 100644 index 22e0a4b4ad7ac64b065fc540416c6ed15ff4336b..fd590a6a3feb48c9f1894a0619b09ca6a5d22a5c 100644
--- a/pg-core/query-builders/insert.cjs --- a/pg-core/query-builders/insert.cjs
+++ b/pg-core/query-builders/insert.cjs +++ b/pg-core/query-builders/insert.cjs
@@ -75,11 +75,6 @@ class PgInsertBuilder { @@ -76,11 +76,6 @@ class PgInsertBuilder {
} }
select(selectQuery) { select(selectQuery) {
const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery; const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery;
@@ -55,10 +55,10 @@ index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..20c8036374a1f25f7c5880c40e8d3c42
} }
} }
diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js
index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..998e2ab0bfe3f322bf268a01f71ebd06c57d4d07 100644 index 60a8bb0d1c22b890bd8fbf4c85d5df41ca42444c..8754d0f2923f905816016c42f339c3e9097b4128 100644
--- a/pg-core/query-builders/insert.js --- a/pg-core/query-builders/insert.js
+++ b/pg-core/query-builders/insert.js +++ b/pg-core/query-builders/insert.js
@@ -51,11 +51,6 @@ class PgInsertBuilder { @@ -52,11 +52,6 @@ class PgInsertBuilder {
} }
select(selectQuery) { select(selectQuery) {
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery; const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery;

View File

@@ -4,7 +4,7 @@ pkgs.mkShell {
bun bun
biome biome
# for psql to debug from the cli # for psql to debug from the cli
postgresql_15 postgresql_18
# to build libvips (for sharp) # to build libvips (for sharp)
nodejs nodejs
node-gyp node-gyp
@@ -13,4 +13,7 @@ pkgs.mkShell {
]; ];
SHARP_FORCE_GLOBAL_LIBVIPS = 1; SHARP_FORCE_GLOBAL_LIBVIPS = 1;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
'';
} }

View File

@@ -33,6 +33,17 @@ const Jwt = t.Object({
type Jwt = typeof Jwt.static; type Jwt = typeof Jwt.static;
const validator = TypeCompiler.Compile(Jwt); const validator = TypeCompiler.Compile(Jwt);
export async function verifyJwt(bearer: string) {
// @ts-expect-error ts can't understand that there's two overload idk why
const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, {
issuer: process.env.JWT_ISSUER,
});
const raw = validator.Decode(payload);
const jwt = Value.Default(Jwt, raw) as Prettify<Jwt & { settings: Settings }>;
return { jwt };
}
export const auth = new Elysia({ name: "auth" }) export const auth = new Elysia({ name: "auth" })
.guard({ .guard({
headers: t.Object( headers: t.Object(
@@ -50,18 +61,8 @@ export const auth = new Elysia({ name: "auth" })
message: "No authorization header was found.", message: "No authorization header was found.",
}); });
} }
try { try {
// @ts-expect-error ts can't understand that there's two overload idk why return await verifyJwt(bearer);
const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, {
issuer: process.env.JWT_ISSUER,
});
const raw = validator.Decode(payload);
const jwt = Value.Default(Jwt, raw) as Prettify<
Jwt & { settings: Settings }
>;
return { jwt };
} catch (err) { } catch (err) {
return status(403, { return status(403, {
status: 403, status: 403,
@@ -73,7 +74,7 @@ export const auth = new Elysia({ name: "auth" })
.macro({ .macro({
permissions(perms: string[]) { permissions(perms: string[]) {
return { return {
beforeHandle: ({ jwt, status }) => { beforeHandle: function permissionCheck({ jwt, status }) {
for (const perm of perms) { for (const perm of perms) {
if (!jwt!.permissions.includes(perm)) { if (!jwt!.permissions.includes(perm)) {
return status(403, { return status(403, {

View File

@@ -13,11 +13,24 @@ import { series } from "./controllers/shows/series";
import { showsH } from "./controllers/shows/shows"; import { showsH } from "./controllers/shows/shows";
import { staffH } from "./controllers/staff"; import { staffH } from "./controllers/staff";
import { studiosH } from "./controllers/studios"; import { studiosH } from "./controllers/studios";
import { videosH } from "./controllers/videos"; import { videosReadH, videosWriteH } from "./controllers/videos";
import { db } from "./db";
import type { KError } from "./models/error"; import type { KError } from "./models/error";
import { otel } from "./otel";
import { appWs } from "./websockets";
export const base = new Elysia({ name: "base" }) export const base = new Elysia({ name: "base" })
.onError(({ code, error }) => { .onError(({ code, error }) => {
// sometimes elysia as an unknown code when throwing errors
if (code === "UNKNOWN") {
try {
const details = JSON.parse(error.message);
if (details?.code === "KError") {
const { code, ...ret } = details;
return ret;
}
} catch {}
}
if (code === "VALIDATION") { if (code === "VALIDATION") {
const details = JSON.parse(error.message); const details = JSON.parse(error.message);
if (details.code === "KError") { if (details.code === "KError") {
@@ -34,29 +47,53 @@ export const base = new Elysia({ name: "base" })
details: details, details: details,
} as KError; } as KError;
} }
if (code === "INTERNAL_SERVER_ERROR") {
console.error(error);
return {
status: 500,
message: error.message,
details: error,
} as KError;
}
if (code === "NOT_FOUND") { if (code === "NOT_FOUND") {
return error; return error;
} }
console.error(code, error); console.error(code, error);
return error; return {
status: 500,
message: "Internal server error",
} as KError;
}) })
.get("/health", () => ({ status: "healthy" }) as const, { .get("/health", () => ({ status: "healthy" }) as const, {
detail: { description: "Check if the api is healthy." }, detail: { description: "Check if the api is healthy." },
response: { 200: t.Object({ status: t.Literal("healthy") }) }, response: { 200: t.Object({ status: t.Literal("healthy") }) },
}) })
.as("scoped"); .get(
"/ready",
async ({ status }) => {
try {
await db.execute("select 1");
return { status: "healthy", database: "healthy" } as const;
} catch (e) {
return status(500, {
status: "unhealthy",
database: e,
});
}
},
{
detail: { description: "Check if the api is healthy." },
response: {
200: t.Object({
status: t.Literal("healthy"),
database: t.Literal("healthy"),
}),
500: t.Object({
status: t.Literal("unhealthy"),
database: t.Any(),
}),
},
},
)
.as("global");
export const prefix = "/api"; export const prefix = "/api";
export const handlers = new Elysia({ prefix }) export const handlers = new Elysia({ prefix })
.use(base) .use(base)
.use(otel)
.use(appWs)
.use(auth) .use(auth)
.guard( .guard(
{ {
@@ -84,7 +121,8 @@ export const handlers = new Elysia({ prefix })
.use(imagesH) .use(imagesH)
.use(watchlistH) .use(watchlistH)
.use(historyH) .use(historyH)
.use(nextup), .use(nextup)
.use(videosReadH),
) )
.guard( .guard(
{ {
@@ -98,5 +136,5 @@ export const handlers = new Elysia({ prefix })
// }, // },
permissions: ["core.write"], permissions: ["core.write"],
}, },
(app) => app.use(videosH).use(seed), (app) => app.use(videosWriteH).use(seed),
); );

View File

@@ -54,7 +54,7 @@ export const entryProgressQ = db
}) })
.from(history) .from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk)) .leftJoin(videos, eq(history.videoPk, videos.pk))
.leftJoin(profiles, eq(history.profilePk, profiles.pk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(eq(profiles.id, sql.placeholder("userId"))) .where(eq(profiles.id, sql.placeholder("userId")))
.orderBy(history.entryPk, desc(history.playedDate)) .orderBy(history.entryPk, desc(history.playedDate))
.as("progress"); .as("progress");
@@ -157,7 +157,7 @@ export const mapProgress = ({ aliased }: { aliased: boolean }) => {
const ret = { const ret = {
time: coalesce(time, sql<number>`0`), time: coalesce(time, sql<number>`0`),
percent: coalesce(percent, sql<number>`0`), percent: coalesce(percent, sql<number>`0`),
playedDate: sql`to_char(${playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, playedDate: sql<string>`to_char(${playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
videoId: sql<string>`${videoId}`, videoId: sql<string>`${videoId}`,
}; };
if (!aliased) return ret; if (!aliased) return ret;
@@ -256,7 +256,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub }, jwt: { sub },
status, status,
@@ -294,7 +294,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get entries of a serie" }, detail: { description: "Get entries of a serie" },
@@ -338,6 +338,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
jwt: { sub }, jwt: { sub },
status, status,
}) => { }) => {
@@ -373,7 +374,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Extra[]; })) as Extra[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get extras of a serie" }, detail: { description: "Get extras of a serie" },
@@ -410,6 +411,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
async ({ async ({
query: { limit, after, query, filter }, query: { limit, after, query, filter },
request: { url }, request: { url },
headers,
jwt: { sub }, jwt: { sub },
}) => { }) => {
const sort = newsSort; const sort = newsSort;
@@ -427,7 +429,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get new movies/episodes added recently." }, detail: { description: "Get new movies/episodes added recently." },

View File

@@ -27,9 +27,9 @@ function getRedirectToImageHandler({ filter }: { filter?: SQL }) {
status, status,
redirect, redirect,
}: { }: {
params: { id: string; image: "poster" | "thumbnail" | "banner" | "logo" }; params: { id?: string; image: "poster" | "thumbnail" | "banner" | "logo" };
headers: { "accept-language": string }; headers: { "accept-language": string };
query: { quality: "high" | "medium" | "low" }; query: { quality?: "high" | "medium" | "low" };
set: Context["set"]; set: Context["set"];
status: Context["status"]; status: Context["status"];
redirect: Context["redirect"]; redirect: Context["redirect"];
@@ -212,12 +212,9 @@ export const imagesH = new Elysia({ tags: ["images"] })
}, },
) )
.guard({ .guard({
headers: t.Object( headers: t.Object({
{
"accept-language": AcceptLanguage(), "accept-language": AcceptLanguage(),
}, }),
{ additionalProperties: true },
),
}) })
.get( .get(
"/studios/:id/logo", "/studios/:id/logo",
@@ -307,6 +304,9 @@ export const imagesH = new Elysia({ tags: ["images"] })
description: "The type of image to retrive.", description: "The type of image to retrive.",
}), }),
}), }),
headers: t.Object({
"accept-language": AcceptLanguage(),
}),
}) })
.get( .get(
"/movies/:id/:image", "/movies/:id/:image",

View File

@@ -1,11 +1,22 @@
import { and, count, eq, exists, gt, isNotNull, ne, sql } from "drizzle-orm"; import {
and,
count,
eq,
exists,
gt,
isNotNull,
lte,
ne,
sql,
TransactionRollbackError,
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core"; import { alias } from "drizzle-orm/pg-core";
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { auth, getUserInfo } from "~/auth"; import { auth, getUserInfo } from "~/auth";
import { db } from "~/db"; import { db, type Transaction } from "~/db";
import { entries, history, profiles, shows, videos } from "~/db/schema"; import { entries, history, profiles, shows, videos } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist"; import { watchlist } from "~/db/schema/watchlist";
import { coalesce, values } from "~/db/utils"; import { coalesce, sqlarr } from "~/db/utils";
import { Entry } from "~/models/entry"; import { Entry } from "~/models/entry";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { SeedHistory } from "~/models/history"; import { SeedHistory } from "~/models/history";
@@ -19,6 +30,7 @@ import {
} from "~/models/utils"; } from "~/models/utils";
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
import type { WatchlistStatus } from "~/models/watchlist"; import type { WatchlistStatus } from "~/models/watchlist";
import { traverse } from "~/utils";
import { import {
entryFilters, entryFilters,
entryProgressQ, entryProgressQ,
@@ -27,183 +39,151 @@ import {
} from "../entries"; } from "../entries";
import { getOrCreateProfile } from "./profile"; import { getOrCreateProfile } from "./profile";
const historyProgressQ: typeof entryProgressQ = db export async function updateProgress(userPk: number, progress: SeedHistory[]) {
.select({ try {
percent: history.percent, return await db.transaction(async (tx) => {
time: history.time, const hist = await updateHistory(tx, userPk, progress);
entryPk: history.entryPk, if (hist.created.length + hist.updated.length !== progress.length) {
playedDate: history.playedDate, tx.rollback();
videoId: videos.id, }
}) // only return new and entries whose status has changed.
// we don't need to update the watchlist every 10s when watching a video.
await updateWatchlist(tx, userPk, [
...hist.created,
...hist.updated.filter((x) => x.percent >= 95),
]);
return { status: 201, inserted: hist.created.length } as const;
});
} catch (e) {
if (!(e instanceof TransactionRollbackError)) throw e;
return {
status: 404,
message: "Invalid entry id/slug in progress array",
} as const;
}
}
async function updateHistory(
dbTx: Transaction,
userPk: number,
progress: SeedHistory[],
) {
return dbTx.transaction(async (tx) => {
// `for("update", { of: history })` will put the `kyoo.history` instead
// of `history` in the sql and that triggers a sql error.
const existing = (
await tx
.select({ videoId: videos.id })
.from(history) .from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk)) .for("update", { of: sql`history` as any })
.leftJoin(profiles, eq(history.profilePk, profiles.pk)) .leftJoin(videos, eq(videos.pk, history.videoPk))
.where(eq(profiles.id, sql.placeholder("userId"))) .where(
.as("progress"); and(
eq(history.profilePk, userPk),
export const historyH = new Elysia({ tags: ["profiles"] }) lte(sql`now() - ${history.playedDate}`, sql`interval '1 day'`),
.use(auth)
.guard(
{
query: t.Object({
sort: {
...entrySort,
default: ["-playedDate"],
},
filter: t.Optional(Filter({ def: entryFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
}),
},
(app) =>
app
.get(
"/profiles/me/history",
async ({
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages },
request: { url },
jwt: { sub },
}) => {
const langs = processLanguages(languages);
const items = (await getEntries({
limit,
after,
query,
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
ne(entries.kind, "extra"),
filter,
),
languages: langs,
userId: sub,
progressQ: historyProgressQ,
})) as Entry[];
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
},
{ additionalProperties: true },
),
response: {
200: Page(Entry),
},
},
)
.get(
"/profiles/:id/history",
async ({
params: { id },
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages, authorization },
request: { url },
status,
}) => {
const uInfo = await getUserInfo(id, { authorization });
if ("status" in uInfo) return status(uInfo.status as 404, uInfo);
const langs = processLanguages(languages);
const items = (await getEntries({
limit,
after,
query,
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
ne(entries.kind, "extra"),
filter,
),
languages: langs,
userId: uInfo.id,
progressQ: historyProgressQ,
})) as Entry[];
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
params: t.Object({
id: t.String({
description:
"The id or username of the user to read the watchlist of",
example: "zoriya",
}),
}),
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Entry),
403: KError,
404: {
...KError,
description: "No user found with the specified id/username.",
},
422: KError,
},
},
), ),
) )
.post( ).map((x) => x.videoId);
"/profiles/me/history",
async ({ body, jwt: { sub }, status }) => {
const profilePk = await getOrCreateProfile(sub);
const hist = values( const toUpdate = traverse(
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), progress.filter((x) => existing.includes(x.videoId)),
{ );
percent: "integer", const newEntries = traverse(
time: "integer", progress
playedDate: "timestamptz", .filter((x) => !existing.includes(x.videoId))
videoId: "uuid", .map((x) => ({ ...x, entryUseid: isUuid(x.entry) })),
}, );
).as("hist");
const valEqEntries = sql`
case
when hist.entryUseId::boolean then ${entries.id} = hist.entry::uuid
else ${entries.slug} = hist.entry
end
`;
const rows = await db const updated =
toUpdate === null
? []
: await tx
.update(history)
.set({
time: sql`hist.ts`,
percent: sql`hist.percent`,
playedDate: coalesce(sql`hist.played_date`, sql`now()`),
})
.from(sql`unnest(
${sqlarr(toUpdate.videoId)}::uuid[],
${sqlarr(toUpdate.time)}::integer[],
${sqlarr(toUpdate.percent)}::integer[],
${sqlarr(toUpdate.playedDate)}::timestamp[]
) as hist(video_id, ts, percent, played_date)`)
.innerJoin(videos, eq(videos.id, sql`hist.video_id`))
.where(
and(
eq(history.profilePk, userPk),
eq(history.videoPk, videos.pk),
),
)
.returning({
entryPk: history.entryPk,
videoPk: history.videoPk,
percent: history.percent,
playedDate: history.playedDate,
});
const created =
newEntries === null
? []
: await tx
.insert(history) .insert(history)
.select( .select(
db db
.select({ .select({
profilePk: sql`${profilePk}`.as("profilePk"), profilePk: sql`${userPk}`.as("profilePk"),
entryPk: entries.pk,
videoPk: videos.pk, videoPk: videos.pk,
entryPk: entries.pk,
percent: sql`hist.percent`.as("percent"), percent: sql`hist.percent`.as("percent"),
time: sql`hist.time`.as("time"), time: sql`hist.ts`.as("time"),
playedDate: sql`hist.playedDate`.as("playedDate"), playedDate: coalesce(sql`hist.played_date`, sql`now()`).as(
"playedDate",
),
}) })
.from(hist) .from(sql`unnest(
.innerJoin(entries, valEqEntries) ${sqlarr(newEntries.entry)}::text[],
.leftJoin(videos, eq(videos.id, sql`hist.videoId`)), ${sqlarr(newEntries.entryUseid)}::boolean[],
${sqlarr(newEntries.videoId)}::uuid[],
${sqlarr(newEntries.time)}::integer[],
${sqlarr(newEntries.percent)}::integer[],
${sqlarr(newEntries.playedDate)}::timestamptz[]
) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`)
.innerJoin(
entries,
sql`
case
when hist.entry_use_id then ${entries.id} = hist.entry::uuid
else ${entries.slug} = hist.entry
end
`,
) )
.returning({ pk: history.pk }); .leftJoin(videos, eq(videos.id, sql`hist.video_id`)),
)
.returning({
entryPk: history.entryPk,
videoPk: history.videoPk,
percent: history.percent,
playedDate: history.playedDate,
});
// automatically update watchlist with this new info return { created, updated };
});
}
async function updateWatchlist(
tx: Transaction,
userPk: number,
histArr: {
entryPk: number;
percent: number;
playedDate: string;
}[],
) {
if (histArr.length === 0) return;
const nextEntry = alias(entries, "next_entry"); const nextEntry = alias(entries, "next_entry");
const nextEntryQ = db const nextEntryQ = tx
.select({ .select({
pk: nextEntry.pk, pk: nextEntry.pk,
}) })
@@ -219,7 +199,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
.limit(1) .limit(1)
.as("nextEntryQ"); .as("nextEntryQ");
const seenCountQ = db const seenCountQ = tx
.select({ c: count() }) .select({ c: count() })
.from(entries) .from(entries)
.where( .where(
@@ -231,7 +211,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
.from(history) .from(history)
.where( .where(
and( and(
eq(history.profilePk, profilePk), eq(history.profilePk, userPk),
eq(history.entryPk, entries.pk), eq(history.entryPk, entries.pk),
), ),
), ),
@@ -239,17 +219,18 @@ export const historyH = new Elysia({ tags: ["profiles"] })
), ),
); );
const showKindQ = db const showKindQ = tx
.select({ k: shows.kind }) .select({ k: shows.kind })
.from(shows) .from(shows)
.where(eq(shows.pk, sql`excluded.show_pk`)); .where(eq(shows.pk, sql`excluded.show_pk`));
await db const hist = traverse(histArr)!;
await tx
.insert(watchlist) .insert(watchlist)
.select( .select(
db db
.select({ .selectDistinctOn([entries.showPk], {
profilePk: sql`${profilePk}`.as("profilePk"), profilePk: sql`${userPk}`.as("profilePk"),
showPk: entries.showPk, showPk: entries.showPk,
status: sql<WatchlistStatus>` status: sql<WatchlistStatus>`
case case
@@ -274,19 +255,23 @@ export const historyH = new Elysia({ tags: ["profiles"] })
end end
`.as("next_entry"), `.as("next_entry"),
score: sql`null`.as("score"), score: sql`null`.as("score"),
startedAt: sql`hist.playedDate`.as("startedAt"), startedAt: sql`hist.played_date`.as("startedAt"),
lastPlayedAt: sql`hist.playedDate`.as("lastPlayedAt"), lastPlayedAt: sql`hist.played_date`.as("lastPlayedAt"),
completedAt: sql` completedAt: sql`
case case
when ${nextEntryQ.pk} is null then hist.playedDate when ${nextEntryQ.pk} is null then hist.played_date
else null else null
end end
`.as("completedAt"), `.as("completedAt"),
// see https://github.com/drizzle-team/drizzle-orm/issues/3608 // see https://github.com/drizzle-team/drizzle-orm/issues/3608
updatedAt: sql`now()`.as("updatedAt"), updatedAt: sql`now()`.as("updatedAt"),
}) })
.from(hist) .from(sql`unnest(
.leftJoin(entries, valEqEntries) ${sqlarr(hist.entryPk)}::integer[],
${sqlarr(hist.percent)}::integer[],
${sqlarr(hist.playedDate)}::timestamptz[]
) as hist(entry_pk, percent, played_date)`)
.innerJoin(entries, eq(entries.pk, sql`hist.entry_pk`))
.leftJoinLateral(nextEntryQ, sql`true`), .leftJoinLateral(nextEntryQ, sql`true`),
) )
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -320,8 +305,154 @@ export const historyH = new Elysia({ tags: ["profiles"] })
), ),
}, },
}); });
}
return status(201, { status: 201, inserted: rows.length }); // this one is different than the normal progressQ because we want duplicates
const historyProgressQ: typeof entryProgressQ = db
.select({
percent: history.percent,
time: history.time,
entryPk: history.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(eq(profiles.id, sql.placeholder("userId")))
.as("progress");
export const historyH = new Elysia({ tags: ["profiles"] })
.use(auth)
.guard(
{
query: t.Object({
sort: {
...entrySort,
default: ["-playedDate"],
},
filter: t.Optional(Filter({ def: entryFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
}),
},
(app) =>
app
.get(
"/profiles/me/history",
async ({
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages, ...headers },
request: { url },
jwt: { sub },
}) => {
const langs = processLanguages(languages);
const items = (await getEntries({
limit,
after,
query,
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
ne(entries.kind, "extra"),
filter,
),
languages: langs,
userId: sub,
progressQ: historyProgressQ,
})) as Entry[];
return createPage(items, { url, sort, limit, headers });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
},
{ additionalProperties: true },
),
response: {
200: Page(Entry),
},
},
)
.get(
"/profiles/:id/history",
async ({
params: { id },
query: { sort, filter, query, limit, after },
headers: {
"accept-language": languages,
authorization,
...headers
},
request: { url },
status,
}) => {
const uInfo = await getUserInfo(id, { authorization });
if ("status" in uInfo) return status(uInfo.status as 404, uInfo);
const langs = processLanguages(languages);
const items = (await getEntries({
limit,
after,
query,
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
ne(entries.kind, "extra"),
filter,
),
languages: langs,
userId: uInfo.id,
progressQ: historyProgressQ,
})) as Entry[];
return createPage(items, { url, sort, limit, headers });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
params: t.Object({
id: t.String({
description:
"The id or username of the user to read the watchlist of",
example: "zoriya",
}),
}),
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Entry),
403: KError,
404: {
...KError,
description: "No user found with the specified id/username.",
},
422: KError,
},
},
),
)
.post(
"/profiles/me/history",
async ({ body, jwt: { sub }, status }) => {
const profilePk = await getOrCreateProfile(sub);
const ret = await updateProgress(profilePk, body);
return status(ret.status, ret);
}, },
{ {
detail: { description: "Bulk add entries/movies to your watch history." }, detail: { description: "Bulk add entries/movies to your watch history." },
@@ -334,6 +465,10 @@ export const historyH = new Elysia({ tags: ["profiles"] })
description: "The number of history entry inserted", description: "The number of history entry inserted",
}), }),
}), }),
404: {
...KError,
description: "No entry found with the given id or slug.",
},
422: KError, 422: KError,
}, },
}, },

View File

@@ -69,7 +69,7 @@ export const nextup = new Elysia({ tags: ["profiles"] })
"/profiles/me/nextup", "/profiles/me/nextup",
async ({ async ({
query: { sort, filter, query, limit, after }, query: { sort, filter, query, limit, after },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub }, jwt: { sub },
}) => { }) => {
@@ -124,7 +124,7 @@ export const nextup = new Elysia({ tags: ["profiles"] })
.limit(limit) .limit(limit)
.execute({ userId: sub }); .execute({ userId: sub });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@@ -142,7 +142,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
"/profiles/me/watchlist", "/profiles/me/watchlist",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@@ -162,7 +162,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"], relations: ["nextEntry"],
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies/series in your watchlist" }, detail: { description: "Get all movies/series in your watchlist" },
@@ -195,7 +195,11 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
jwt: { settings }, jwt: { settings },
headers: { "accept-language": languages, authorization }, headers: {
"accept-language": languages,
authorization,
...headers
},
request: { url }, request: { url },
status, status,
}) => { }) => {
@@ -218,7 +222,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"], relations: ["nextEntry"],
userId: uInfo.id, userId: uInfo.id,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@@ -53,7 +53,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
status, status,
}) => { }) => {
@@ -110,7 +110,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get seasons of a serie" }, detail: { description: "Get seasons of a serie" },

View File

@@ -1,4 +1,6 @@
import path from "node:path"; import path from "node:path";
import { getCurrentSpan, setAttributes } from "@elysiajs/opentelemetry";
import { SpanStatusCode } from "@opentelemetry/api";
import { encode } from "blurhash"; import { encode } from "blurhash";
import { and, eq, is, lt, type SQL, sql } from "drizzle-orm"; import { and, eq, is, lt, type SQL, sql } from "drizzle-orm";
import { PgColumn, type PgTable } from "drizzle-orm/pg-core"; import { PgColumn, type PgTable } from "drizzle-orm/pg-core";
@@ -7,13 +9,15 @@ import type { PoolClient } from "pg";
import sharp from "sharp"; import sharp from "sharp";
import { db, type Transaction } from "~/db"; import { db, type Transaction } from "~/db";
import { mqueue } from "~/db/schema/mqueue"; import { mqueue } from "~/db/schema/mqueue";
import { unnestValues } from "~/db/utils";
import type { Image } from "~/models/utils"; import type { Image } from "~/models/utils";
import { record } from "~/otel";
import { getFile } from "~/utils"; import { getFile } from "~/utils";
export const imageDir = process.env.IMAGES_PATH ?? "./images"; export const imageDir = process.env.IMAGES_PATH ?? "/images";
export const defaultBlurhash = "000000"; export const defaultBlurhash = "000000";
type ImageTask = { export type ImageTask = {
id: string; id: string;
url: string; url: string;
table: string; table: string;
@@ -23,12 +27,12 @@ type ImageTask = {
// this will only push a task to the image downloader service and not download it instantly. // this will only push a task to the image downloader service and not download it instantly.
// this is both done to prevent too many requests to be sent at once and to make sure POST // this is both done to prevent too many requests to be sent at once and to make sure POST
// requests are not blocked by image downloading or blurhash calculation // requests are not blocked by image downloading or blurhash calculation
export const enqueueOptImage = async ( export const enqueueOptImage = (
tx: Transaction, imgQueue: ImageTask[],
img: img:
| { url: string | null; column: PgColumn } | { url?: string | null; column: PgColumn }
| { url: string | null; table: PgTable; column: SQL }, | { url?: string | null; table: PgTable; column: SQL },
): Promise<Image | null> => { ): Image | null => {
if (!img.url) return null; if (!img.url) return null;
const hasher = new Bun.CryptoHasher("sha256"); const hasher = new Bun.CryptoHasher("sha256");
@@ -64,11 +68,8 @@ export const enqueueOptImage = async (
table: db.dialect.sqlToQuery(sql`${img.column.table}`).sql, table: db.dialect.sqlToQuery(sql`${img.column.table}`).sql,
column: sql.identifier(img.column.name).value, column: sql.identifier(img.column.name).value,
}; };
await tx.insert(mqueue).values({
kind: "image", imgQueue.push(message);
message,
});
await tx.execute(sql`notify kyoo_image`);
return { return {
id, id,
@@ -77,20 +78,76 @@ export const enqueueOptImage = async (
}; };
}; };
export const processImages = async () => { export const flushImageQueue = record(
async function processOne() { "enqueueImages",
async (tx: Transaction, imgQueue: ImageTask[], priority: number) => {
if (!imgQueue.length) return;
await tx.insert(mqueue).select(
unnestValues(
imgQueue.map((x) => ({ kind: "image", message: x, priority })),
mqueue,
),
);
await tx.execute(sql`notify kyoo_image`);
},
);
export const processImages = record(
"processImages",
async (waitToFinish = false) => {
let running = false;
async function processAll() {
if (running) return;
running = true;
let found = true;
while (found) {
// run 10 downloads at the same time,
const founds = await Promise.all([...new Array(10)].map(processOne));
// continue as long as there's one found (if it failed we wanna retry)
found = founds.includes(true);
}
running = false;
}
const client = (await db.$client.connect()) as PoolClient;
client.on("notification", (evt) => {
if (evt.channel !== "kyoo_image") return;
try {
processAll();
} catch (e) {
console.error(
"Failed to processs images. aborting images downloading",
e,
);
}
});
await client.query("listen kyoo_image");
if (waitToFinish) {
// start processing old tasks
await processAll();
} else {
processAll();
}
return () => client.release(true);
},
);
const processOne = record("download", async () => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [item] = await tx const [item] = await tx
.select() .select()
.from(mqueue) .from(mqueue)
.for("update", { skipLocked: true }) .for("update", { skipLocked: true })
.where(and(eq(mqueue.kind, "image"), lt(mqueue.attempt, 5))) .where(and(eq(mqueue.kind, "image"), lt(mqueue.attempt, 5)))
.orderBy(mqueue.attempt, mqueue.createdAt) .orderBy(mqueue.priority, mqueue.attempt, mqueue.createdAt)
.limit(1); .limit(1);
if (!item) return false; if (!item) return false;
const img = item.message as ImageTask; const img = item.message as ImageTask;
setAttributes({ "item.url": img.url });
try { try {
const blurhash = await downloadImage(img.id, img.url); const blurhash = await downloadImage(img.id, img.url);
const ret: Image = { id: img.id, source: img.url, blurhash }; const ret: Image = { id: img.id, source: img.url, blurhash };
@@ -99,12 +156,18 @@ export const processImages = async () => {
const column = sql.raw(img.column); const column = sql.raw(img.column);
await tx.execute(sql` await tx.execute(sql`
update ${table} set ${column} = ${ret} where ${column}->'id' = ${sql.raw(`'"${img.id}"'::jsonb`)} update ${table} set ${column} = ${ret}
where ${column}->'id' = to_jsonb(${img.id}::text)
`); `);
await tx.delete(mqueue).where(eq(mqueue.id, item.id)); await tx.delete(mqueue).where(eq(mqueue.id, item.id));
} catch (err: any) { } catch (err: any) {
console.error("Failed to download image", img.url, err.message); const span = getCurrentSpan();
if (span) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
}
console.error("Failed to download image", img.url, err);
await tx await tx
.update(mqueue) .update(mqueue)
.set({ attempt: sql`${mqueue.attempt}+1` }) .set({ attempt: sql`${mqueue.attempt}+1` })
@@ -112,31 +175,7 @@ export const processImages = async () => {
} }
return true; return true;
}); });
}
let running = false;
async function processAll() {
if (running) return;
running = true;
let found = true;
while (found) {
found = await processOne();
}
running = false;
}
const client = (await db.$client.connect()) as PoolClient;
client.on("notification", (evt) => {
if (evt.channel !== "kyoo_image") return;
processAll();
}); });
await client.query("listen kyoo_image");
// start processing old tasks
await processAll();
return () => client.release(true);
};
async function downloadImage(id: string, url: string): Promise<string> { async function downloadImage(id: string, url: string): Promise<string> {
const low = await getFile(path.join(imageDir, `${id}.low.jpg`)) const low = await getFile(path.join(imageDir, `${id}.low.jpg`))

View File

@@ -5,13 +5,19 @@ import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedCollection } from "~/models/collections"; import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import { enqueueOptImage } from "../images"; import { record } from "~/otel";
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
type ShowTrans = typeof showTranslations.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert;
export const insertCollection = async ( export const insertCollection = record(
"insertCollection",
async (
collection: SeedCollection | undefined, collection: SeedCollection | undefined,
show: (({ kind: "movie" } & SeedMovie) | ({ kind: "serie" } & SeedSerie)) & { show: (
| ({ kind: "movie" } & SeedMovie)
| ({ kind: "serie" } & SeedSerie)
) & {
nextRefresh: string; nextRefresh: string;
}, },
) => { ) => {
@@ -19,6 +25,7 @@ export const insertCollection = async (
const { translations, ...col } = collection; const { translations, ...col } = collection;
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const imgQueue: ImageTask[] = [];
const [ret] = await tx const [ret] = await tx
.insert(shows) .insert(shows)
.values({ .values({
@@ -48,29 +55,31 @@ export const insertCollection = async (
}) })
.returning({ pk: shows.pk, id: shows.id, slug: shows.slug }); .returning({ pk: shows.pk, id: shows.id, slug: shows.slug });
const trans: ShowTrans[] = await Promise.all( const trans: ShowTrans[] = Object.entries(translations).map(
Object.entries(translations).map(async ([lang, tr]) => ({ ([lang, tr]) => ({
pk: ret.pk, pk: ret.pk,
language: lang, language: lang,
...tr, ...tr,
poster: await enqueueOptImage(tx, { poster: enqueueOptImage(imgQueue, {
url: tr.poster, url: tr.poster,
column: showTranslations.poster, column: showTranslations.poster,
}), }),
thumbnail: await enqueueOptImage(tx, { thumbnail: enqueueOptImage(imgQueue, {
url: tr.thumbnail, url: tr.thumbnail,
column: showTranslations.thumbnail, column: showTranslations.thumbnail,
}), }),
logo: await enqueueOptImage(tx, { logo: enqueueOptImage(imgQueue, {
url: tr.logo, url: tr.logo,
column: showTranslations.logo, column: showTranslations.logo,
}), }),
banner: await enqueueOptImage(tx, { banner: enqueueOptImage(imgQueue, {
url: tr.banner, url: tr.banner,
column: showTranslations.banner, column: showTranslations.banner,
}), }),
})), }),
); );
await flushImageQueue(tx, imgQueue, 100);
// we can't unnest values here because show translations contains arrays.
await tx await tx
.insert(showTranslations) .insert(showTranslations)
.values(trans) .values(trans)
@@ -80,4 +89,5 @@ export const insertCollection = async (
}); });
return ret; return ret;
}); });
}; },
);

View File

@@ -6,9 +6,10 @@ import {
entryVideoJoin, entryVideoJoin,
videos, videos,
} from "~/db/schema"; } from "~/db/schema";
import { conflictUpdateAllExcept, values } from "~/db/utils"; import { conflictUpdateAllExcept, unnest, unnestValues } from "~/db/utils";
import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry";
import { enqueueOptImage } from "../images"; import { record } from "~/otel";
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
import { guessNextRefresh } from "../refresh"; import { guessNextRefresh } from "../refresh";
import { updateAvailableCount, updateAvailableSince } from "./shows"; import { updateAvailableCount, updateAvailableSince } from "./shows";
@@ -42,7 +43,9 @@ const generateSlug = (
} }
}; };
export const insertEntries = async ( export const insertEntries = record(
"insertEntries",
async (
show: { pk: number; slug: string; kind: "movie" | "serie" | "collection" }, show: { pk: number; slug: string; kind: "movie" | "serie" | "collection" },
items: (SeedEntry | SeedExtra)[], items: (SeedEntry | SeedExtra)[],
onlyExtras = false, onlyExtras = false,
@@ -50,14 +53,14 @@ export const insertEntries = async (
if (!items.length) return []; if (!items.length) return [];
const retEntries = await db.transaction(async (tx) => { const retEntries = await db.transaction(async (tx) => {
const vals: EntryI[] = await Promise.all( const imgQueue: ImageTask[] = [];
items.map(async (seed) => { const vals: EntryI[] = items.map((seed) => {
const { translations, videos, video, ...entry } = seed; const { translations, videos, video, ...entry } = seed;
return { return {
...entry, ...entry,
showPk: show.pk, showPk: show.pk,
slug: generateSlug(show.slug, seed), slug: generateSlug(show.slug, seed),
thumbnail: await enqueueOptImage(tx, { thumbnail: enqueueOptImage(imgQueue, {
url: seed.thumbnail, url: seed.thumbnail,
column: entries.thumbnail, column: entries.thumbnail,
}), }),
@@ -72,11 +75,10 @@ export const insertEntries = async (
? entry.number ? entry.number
: undefined, : undefined,
}; };
}), });
);
const ret = await tx const ret = await tx
.insert(entries) .insert(entries)
.values(vals) .select(unnestValues(vals, entries))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: entries.slug, target: entries.slug,
set: conflictUpdateAllExcept(entries, [ set: conflictUpdateAllExcept(entries, [
@@ -89,9 +91,7 @@ export const insertEntries = async (
}) })
.returning({ pk: entries.pk, id: entries.id, slug: entries.slug }); .returning({ pk: entries.pk, id: entries.id, slug: entries.slug });
const trans: EntryTransI[] = ( const trans: EntryTransI[] = items.flatMap((seed, i) => {
await Promise.all(
items.map(async (seed, i) => {
if (seed.kind === "extra") { if (seed.kind === "extra") {
return [ return [
{ {
@@ -106,27 +106,24 @@ export const insertEntries = async (
]; ];
} }
return await Promise.all( return Object.entries(seed.translations).map(([lang, tr]) => ({
Object.entries(seed.translations).map(async ([lang, tr]) => ({
// assumes ret is ordered like items. // assumes ret is ordered like items.
pk: ret[i].pk, pk: ret[i].pk,
language: lang, language: lang,
...tr, ...tr,
poster: poster:
seed.kind === "movie" seed.kind === "movie"
? await enqueueOptImage(tx, { ? enqueueOptImage(imgQueue, {
url: (tr as any).poster, url: (tr as any).poster,
column: entryTranslations.poster, column: entryTranslations.poster,
}) })
: undefined, : undefined,
})), }));
); });
}), await flushImageQueue(tx, imgQueue, 0);
)
).flat();
await tx await tx
.insert(entryTranslations) .insert(entryTranslations)
.values(trans) .select(unnestValues(trans, entryTranslations))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [entryTranslations.pk, entryTranslations.language], target: [entryTranslations.pk, entryTranslations.language],
set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]),
@@ -167,21 +164,22 @@ export const insertEntries = async (
.select( .select(
db db
.select({ .select({
entryPk: sql<number>`vids.entryPk`.as("entry"), entryPk: sql<number>`vids."entryPk"`.as("entry"),
videoPk: videos.pk, videoPk: videos.pk,
slug: computeVideoSlug( slug: computeVideoSlug(
sql`vids.entrySlug`, sql`vids."entrySlug"`,
sql`vids.needRendering`, sql`vids."needRendering"`,
), ),
}) })
.from( .from(
values(vids, { unnest(vids, "vids", {
entryPk: "integer", entryPk: "integer",
entrySlug: "varchar(255)",
needRendering: "boolean", needRendering: "boolean",
videoId: "uuid", videoId: "uuid",
}).as("vids"), }),
) )
.innerJoin(videos, eq(videos.id, sql`vids.videoId`)), .innerJoin(videos, eq(videos.id, sql`vids."videoId"`)),
) )
.onConflictDoNothing() .onConflictDoNothing()
.returning({ .returning({
@@ -201,7 +199,8 @@ export const insertEntries = async (
slug: entry.slug, slug: entry.slug,
videos: retVideos.filter((x) => x.entryPk === entry.pk), videos: retVideos.filter((x) => x.entryPk === entry.pk),
})); }));
}; },
);
export function computeVideoSlug(entrySlug: SQL | Column, needsRendering: SQL) { export function computeVideoSlug(entrySlug: SQL | Column, needsRendering: SQL) {
return sql<string>` return sql<string>`

View File

@@ -1,20 +1,21 @@
import { db } from "~/db"; import { db } from "~/db";
import { seasons, seasonTranslations } from "~/db/schema"; import { seasons, seasonTranslations } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept, unnestValues } from "~/db/utils";
import type { SeedSeason } from "~/models/season"; import type { SeedSeason } from "~/models/season";
import { enqueueOptImage } from "../images"; import { record } from "~/otel";
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
import { guessNextRefresh } from "../refresh"; import { guessNextRefresh } from "../refresh";
type SeasonI = typeof seasons.$inferInsert; type SeasonI = typeof seasons.$inferInsert;
type SeasonTransI = typeof seasonTranslations.$inferInsert; type SeasonTransI = typeof seasonTranslations.$inferInsert;
export const insertSeasons = async ( export const insertSeasons = record(
show: { pk: number; slug: string }, "insertSeasons",
items: SeedSeason[], async (show: { pk: number; slug: string }, items: SeedSeason[]) => {
) => {
if (!items.length) return []; if (!items.length) return [];
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const imgQueue: ImageTask[] = [];
const vals: SeasonI[] = items.map((x) => { const vals: SeasonI[] = items.map((x) => {
const { translations, ...season } = x; const { translations, ...season } = x;
return { return {
@@ -29,7 +30,7 @@ export const insertSeasons = async (
}); });
const ret = await tx const ret = await tx
.insert(seasons) .insert(seasons)
.values(vals) .select(unnestValues(vals, seasons))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: seasons.slug, target: seasons.slug,
set: conflictUpdateAllExcept(seasons, [ set: conflictUpdateAllExcept(seasons, [
@@ -42,36 +43,30 @@ export const insertSeasons = async (
}) })
.returning({ pk: seasons.pk, id: seasons.id, slug: seasons.slug }); .returning({ pk: seasons.pk, id: seasons.id, slug: seasons.slug });
const trans: SeasonTransI[] = ( const trans: SeasonTransI[] = items.flatMap((seed, i) =>
await Promise.all( Object.entries(seed.translations).map(([lang, tr]) => ({
items.map(
async (seed, i) =>
await Promise.all(
Object.entries(seed.translations).map(async ([lang, tr]) => ({
// assumes ret is ordered like items. // assumes ret is ordered like items.
pk: ret[i].pk, pk: ret[i].pk,
language: lang, language: lang,
...tr, ...tr,
poster: await enqueueOptImage(tx, { poster: enqueueOptImage(imgQueue, {
url: tr.poster, url: tr.poster,
column: seasonTranslations.poster, column: seasonTranslations.poster,
}), }),
thumbnail: await enqueueOptImage(tx, { thumbnail: enqueueOptImage(imgQueue, {
url: tr.thumbnail, url: tr.thumbnail,
column: seasonTranslations.thumbnail, column: seasonTranslations.thumbnail,
}), }),
banner: await enqueueOptImage(tx, { banner: enqueueOptImage(imgQueue, {
url: tr.banner, url: tr.banner,
column: seasonTranslations.banner, column: seasonTranslations.banner,
}), }),
})), })),
), );
), await flushImageQueue(tx, imgQueue, -10);
)
).flat();
await tx await tx
.insert(seasonTranslations) .insert(seasonTranslations)
.values(trans) .select(unnestValues(trans, seasonTranslations))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [seasonTranslations.pk, seasonTranslations.language], target: [seasonTranslations.pk, seasonTranslations.language],
set: conflictUpdateAllExcept(seasonTranslations, ["pk", "language"]), set: conflictUpdateAllExcept(seasonTranslations, ["pk", "language"]),
@@ -79,4 +74,5 @@ export const insertSeasons = async (
return ret; return ret;
}); });
}; },
);

View File

@@ -21,19 +21,22 @@ import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import type { Original } from "~/models/utils"; import type { Original } from "~/models/utils";
import { record } from "~/otel";
import { getYear } from "~/utils"; import { getYear } from "~/utils";
import { enqueueOptImage } from "../images"; import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
type Show = typeof shows.$inferInsert; type Show = typeof shows.$inferInsert;
type ShowTrans = typeof showTranslations.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert;
export const insertShow = async ( export const insertShow = record(
"insertShow",
async (
show: Omit<Show, "original">, show: Omit<Show, "original">,
original: Original & { original: Original & {
poster: string | null; poster?: string | null;
thumbnail: string | null; thumbnail?: string | null;
banner: string | null; banner?: string | null;
logo: string | null; logo?: string | null;
}, },
translations: translations:
| SeedMovie["translations"] | SeedMovie["translations"]
@@ -41,24 +44,25 @@ export const insertShow = async (
| SeedCollection["translations"], | SeedCollection["translations"],
) => { ) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const imgQueue: ImageTask[] = [];
const orig = { const orig = {
...original, ...original,
poster: await enqueueOptImage(tx, { poster: enqueueOptImage(imgQueue, {
url: original.poster, url: original.poster,
table: shows, table: shows,
column: sql`${shows.original}['poster']`, column: sql`${shows.original}['poster']`,
}), }),
thumbnail: await enqueueOptImage(tx, { thumbnail: enqueueOptImage(imgQueue, {
url: original.thumbnail, url: original.thumbnail,
table: shows, table: shows,
column: sql`${shows.original}['thumbnail']`, column: sql`${shows.original}['thumbnail']`,
}), }),
banner: await enqueueOptImage(tx, { banner: enqueueOptImage(imgQueue, {
url: original.banner, url: original.banner,
table: shows, table: shows,
column: sql`${shows.original}['banner']`, column: sql`${shows.original}['banner']`,
}), }),
logo: await enqueueOptImage(tx, { logo: enqueueOptImage(imgQueue, {
url: original.logo, url: original.logo,
table: shows, table: shows,
column: sql`${shows.original}['logo']`, column: sql`${shows.original}['logo']`,
@@ -67,30 +71,32 @@ export const insertShow = async (
const ret = await insertBaseShow(tx, { ...show, original: orig }); const ret = await insertBaseShow(tx, { ...show, original: orig });
if ("status" in ret) return ret; if ("status" in ret) return ret;
const trans: ShowTrans[] = await Promise.all( const trans: ShowTrans[] = Object.entries(translations).map(
Object.entries(translations).map(async ([lang, tr]) => ({ ([lang, tr]) => ({
pk: ret.pk, pk: ret.pk,
language: lang, language: lang,
...tr, ...tr,
latinName: tr.latinName ?? null, latinName: tr.latinName ?? null,
poster: await enqueueOptImage(tx, { poster: enqueueOptImage(imgQueue, {
url: tr.poster, url: tr.poster,
column: showTranslations.poster, column: showTranslations.poster,
}), }),
thumbnail: await enqueueOptImage(tx, { thumbnail: enqueueOptImage(imgQueue, {
url: tr.thumbnail, url: tr.thumbnail,
column: showTranslations.thumbnail, column: showTranslations.thumbnail,
}), }),
logo: await enqueueOptImage(tx, { logo: enqueueOptImage(imgQueue, {
url: tr.logo, url: tr.logo,
column: showTranslations.logo, column: showTranslations.logo,
}), }),
banner: await enqueueOptImage(tx, { banner: enqueueOptImage(imgQueue, {
url: tr.banner, url: tr.banner,
column: showTranslations.banner, column: showTranslations.banner,
}), }),
})), }),
); );
await flushImageQueue(tx, imgQueue, 200);
// we can't unnest values here because show translations contains arrays.
await tx await tx
.insert(showTranslations) .insert(showTranslations)
.values(trans) .values(trans)
@@ -100,7 +106,8 @@ export const insertShow = async (
}); });
return ret; return ret;
}); });
}; },
);
async function insertBaseShow(tx: Transaction, show: Show) { async function insertBaseShow(tx: Transaction, show: Show) {
function insert() { function insert() {

View File

@@ -1,58 +1,67 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db } from "~/db"; import { db } from "~/db";
import { roles, staff } from "~/db/schema"; import { roles, staff } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept, unnestValues } from "~/db/utils";
import type { SeedStaff } from "~/models/staff"; import type { SeedStaff } from "~/models/staff";
import { enqueueOptImage } from "../images"; import { record } from "~/otel";
import { uniqBy } from "~/utils";
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
export const insertStaff = async ( export const insertStaff = record(
seed: SeedStaff[] | undefined, "insertStaff",
showPk: number, async (seed: SeedStaff[] | undefined, showPk: number) => {
) => {
if (!seed?.length) return []; if (!seed?.length) return [];
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const people = await Promise.all( const imgQueue: ImageTask[] = [];
seed.map(async (x) => ({ const people = uniqBy(
seed.map((x) => ({
...x.staff, ...x.staff,
image: await enqueueOptImage(tx, { image: enqueueOptImage(imgQueue, {
url: x.staff.image, url: x.staff.image,
column: staff.image, column: staff.image,
}), }),
})), })),
(x) => x.slug,
); );
const ret = await tx const ret = await tx
.insert(staff) .insert(staff)
.values(people) .select(unnestValues(people, staff))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: staff.slug, target: staff.slug,
set: conflictUpdateAllExcept(staff, ["pk", "id", "slug", "createdAt"]), set: conflictUpdateAllExcept(staff, [
"pk",
"id",
"slug",
"createdAt",
]),
}) })
.returning({ pk: staff.pk, id: staff.id, slug: staff.slug }); .returning({ pk: staff.pk, id: staff.id, slug: staff.slug });
const rval = await Promise.all( const rval = seed.map((x, i) => ({
seed.map(async (x, i) => ({
showPk, showPk,
staffPk: ret[i].pk, staffPk: ret.find((y) => y.slug === x.staff.slug)!.pk,
kind: x.kind, kind: x.kind,
order: i, order: i,
character: { character: {
...x.character, ...x.character,
image: await enqueueOptImage(tx, { image: enqueueOptImage(imgQueue, {
url: x.character.image, url: x.character.image,
table: roles, table: roles,
column: sql`${roles.character}['image']`, column: sql`${roles.character}['image']`,
}), }),
}, },
})), }));
);
await flushImageQueue(tx, imgQueue, -200);
// always replace all roles. this is because: // always replace all roles. this is because:
// - we want `order` to stay in sync (& without duplicates) // - we want `order` to stay in sync (& without duplicates)
// - we don't have ways to identify a role so we can't onConflict // - we don't have ways to identify a role so we can't onConflict
await tx.delete(roles).where(eq(roles.showPk, showPk)); await tx.delete(roles).where(eq(roles.showPk, showPk));
await tx.insert(roles).values(rval); await tx.insert(roles).select(unnestValues(rval, roles));
return ret; return ret;
}); });
}; },
);

View File

@@ -1,19 +1,22 @@
import { sql } from "drizzle-orm";
import { db } from "~/db"; import { db } from "~/db";
import { showStudioJoin, studios, studioTranslations } from "~/db/schema"; import { showStudioJoin, studios, studioTranslations } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept, sqlarr, unnestValues } from "~/db/utils";
import type { SeedStudio } from "~/models/studio"; import type { SeedStudio } from "~/models/studio";
import { enqueueOptImage } from "../images"; import { record } from "~/otel";
import { uniqBy } from "~/utils";
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
type StudioI = typeof studios.$inferInsert; type StudioI = typeof studios.$inferInsert;
type StudioTransI = typeof studioTranslations.$inferInsert; type StudioTransI = typeof studioTranslations.$inferInsert;
export const insertStudios = async ( export const insertStudios = record(
seed: SeedStudio[] | undefined, "insertStudios",
showPk: number, async (seed: SeedStudio[] | undefined, showPk: number) => {
) => {
if (!seed?.length) return []; if (!seed?.length) return [];
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
seed = uniqBy(seed!, (x) => x.slug);
const vals: StudioI[] = seed.map((x) => { const vals: StudioI[] = seed.map((x) => {
const { translations, ...item } = x; const { translations, ...item } = x;
return item; return item;
@@ -21,7 +24,7 @@ export const insertStudios = async (
const ret = await tx const ret = await tx
.insert(studios) .insert(studios)
.values(vals) .select(unnestValues(vals, studios))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: studios.slug, target: studios.slug,
set: conflictUpdateAllExcept(studios, [ set: conflictUpdateAllExcept(studios, [
@@ -33,27 +36,22 @@ export const insertStudios = async (
}) })
.returning({ pk: studios.pk, id: studios.id, slug: studios.slug }); .returning({ pk: studios.pk, id: studios.id, slug: studios.slug });
const trans: StudioTransI[] = ( const imgQueue: ImageTask[] = [];
await Promise.all( const trans: StudioTransI[] = seed.flatMap((x, i) =>
seed.map( Object.entries(x.translations).map(([lang, tr]) => ({
async (x, i) =>
await Promise.all(
Object.entries(x.translations).map(async ([lang, tr]) => ({
pk: ret[i].pk, pk: ret[i].pk,
language: lang, language: lang,
name: tr.name, name: tr.name,
logo: await enqueueOptImage(tx, { logo: enqueueOptImage(imgQueue, {
url: tr.logo, url: tr.logo,
column: studioTranslations.logo, column: studioTranslations.logo,
}), }),
})), })),
), );
), await flushImageQueue(tx, imgQueue, -100);
)
).flat();
await tx await tx
.insert(studioTranslations) .insert(studioTranslations)
.values(trans) .select(unnestValues(trans, studioTranslations))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [studioTranslations.pk, studioTranslations.language], target: [studioTranslations.pk, studioTranslations.language],
set: conflictUpdateAllExcept(studioTranslations, ["pk", "language"]), set: conflictUpdateAllExcept(studioTranslations, ["pk", "language"]),
@@ -61,8 +59,18 @@ export const insertStudios = async (
await tx await tx
.insert(showStudioJoin) .insert(showStudioJoin)
.values(ret.map((studio) => ({ showPk: showPk, studioPk: studio.pk }))) .select(
db
.select({
showPk: sql`${showPk}`.as("showPk"),
studioPk: sql`v."studioPk"`.as("studioPk"),
})
.from(
sql`unnest(${sqlarr(ret.map((x) => x.pk))}::integer[]) as v("studioPk")`,
),
)
.onConflictDoNothing(); .onConflictDoNothing();
return ret; return ret;
}); });
}; },
);

View File

@@ -55,20 +55,13 @@ export const seedMovie = async (
const { translations, videos, collection, studios, staff, ...movie } = seed; const { translations, videos, collection, studios, staff, ...movie } = seed;
const nextRefresh = guessNextRefresh(movie.airDate ?? new Date()); const nextRefresh = guessNextRefresh(movie.airDate ?? new Date());
const original = translations[movie.originalLanguage];
if (!original) {
return {
status: 422,
message: "No translation available in the original language.",
};
}
const col = await insertCollection(collection, { const col = await insertCollection(collection, {
kind: "movie", kind: "movie",
nextRefresh, nextRefresh,
...seed, ...seed,
}); });
const original = translations[movie.originalLanguage];
const show = await insertShow( const show = await insertShow(
{ {
kind: "movie", kind: "movie",
@@ -78,10 +71,16 @@ export const seedMovie = async (
entriesCount: 1, entriesCount: 1,
...movie, ...movie,
}, },
{ original
? {
...original, ...original,
latinName: original.latinName ?? null, latinName: original.latinName ?? null,
language: movie.originalLanguage, language: movie.originalLanguage,
}
: {
name: null,
latinName: null,
language: movie.originalLanguage,
}, },
translations, translations,
); );

View File

@@ -91,20 +91,13 @@ export const seedSerie = async (
} = seed; } = seed;
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
const original = translations[serie.originalLanguage];
if (!original) {
return {
status: 422,
message: "No translation available in the original language.",
};
}
const col = await insertCollection(collection, { const col = await insertCollection(collection, {
kind: "serie", kind: "serie",
nextRefresh, nextRefresh,
...seed, ...seed,
}); });
const original = translations[serie.originalLanguage];
const show = await insertShow( const show = await insertShow(
{ {
kind: "serie", kind: "serie",
@@ -113,10 +106,16 @@ export const seedSerie = async (
entriesCount: entries.length, entriesCount: entries.length,
...serie, ...serie,
}, },
{ original
? {
...original, ...original,
latinName: original.latinName ?? null, latinName: original.latinName ?? null,
language: serie.originalLanguage, language: serie.originalLanguage,
}
: {
name: null,
latinName: null,
language: serie.originalLanguage,
}, },
translations, translations,
); );

View File

@@ -143,7 +143,7 @@ export const collections = new Elysia({
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
}) => { }) => {
@@ -158,7 +158,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all collections" }, detail: { description: "Get all collections" },
@@ -227,7 +227,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -265,7 +265,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies in a collection" }, detail: { description: "Get all movies in a collection" },
@@ -284,7 +284,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -322,7 +322,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series in a collection" }, detail: { description: "Get all series in a collection" },
@@ -341,7 +341,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -375,7 +375,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series & movies in a collection" }, detail: { description: "Get all series & movies in a collection" },

View File

@@ -133,7 +133,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@@ -148,7 +148,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies" }, detail: { description: "Get all movies" },

View File

@@ -136,7 +136,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@@ -151,7 +151,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series" }, detail: { description: "Get all series" },

View File

@@ -63,7 +63,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
preferOriginal, preferOriginal,
ignoreInCollection, ignoreInCollection,
}, },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@@ -81,7 +81,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies/series/collections" }, detail: { description: "Get all movies/series/collections" },

View File

@@ -189,7 +189,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
status, status,
@@ -227,7 +227,6 @@ export const staffH = new Elysia({ tags: ["staff"] })
.from(watchlist) .from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk))) .where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk)))
.limit(1)
.as("watchstatus"); .as("watchstatus");
const items = await db const items = await db
@@ -270,7 +269,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
roles.showPk, roles.showPk,
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@@ -317,7 +316,11 @@ export const staffH = new Elysia({ tags: ["staff"] })
) )
.get( .get(
"/staff", "/staff",
async ({ query: { limit, after, sort, query }, request: { url } }) => { async ({
query: { limit, after, sort, query },
request: { url },
headers,
}) => {
const items = await db const items = await db
.select() .select()
.from(staff) .from(staff)
@@ -334,7 +337,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
staff.pk, staff.pk,
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@@ -363,6 +366,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
status, status,
}) => { }) => {
const [movie] = await db const [movie] = await db
@@ -390,7 +394,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort, sort,
filter: and(eq(roles.showPk, movie.pk), filter), filter: and(eq(roles.showPk, movie.pk), filter),
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@@ -430,6 +434,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
status, status,
}) => { }) => {
const [serie] = await db const [serie] = await db
@@ -457,7 +462,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort, sort,
filter: and(eq(roles.showPk, serie.pk), filter), filter: and(eq(roles.showPk, serie.pk), filter),
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@@ -228,7 +228,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
"", "",
async ({ async ({
query: { limit, after, query, sort }, query: { limit, after, query, sort },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
}) => { }) => {
const langs = processLanguages(languages); const langs = processLanguages(languages);
@@ -239,7 +239,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
sort, sort,
languages: langs, languages: langs,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all studios" }, detail: { description: "Get all studios" },
@@ -302,7 +302,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -344,7 +344,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series & movies made by a studio." }, detail: { description: "Get all series & movies made by a studio." },
@@ -363,7 +363,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -406,7 +406,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies made by a studio." }, detail: { description: "Get all movies made by a studio." },
@@ -425,7 +425,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@@ -468,7 +468,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series made by a studio." }, detail: { description: "Get all series made by a studio." },

View File

@@ -7,6 +7,7 @@ import {
lt, lt,
max, max,
min, min,
ne,
notExists, notExists,
or, or,
sql, sql,
@@ -15,7 +16,16 @@ import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { auth } from "~/auth"; import { auth } from "~/auth";
import { db, type Transaction } from "~/db"; import { db, type Transaction } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import {
entries,
entryVideoJoin,
history,
profiles,
shows,
showTranslations,
videos,
} from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { import {
coalesce, coalesce,
conflictUpdateAllExcept, conflictUpdateAllExcept,
@@ -25,15 +35,20 @@ import {
jsonbBuildObject, jsonbBuildObject,
jsonbObjectAgg, jsonbObjectAgg,
sqlarr, sqlarr,
values, unnest,
unnestValues,
} from "~/db/utils"; } from "~/db/utils";
import { Entry } from "~/models/entry"; import { Entry } from "~/models/entry";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { bubbleVideo } from "~/models/examples"; import { bubbleVideo } from "~/models/examples";
import { Progress } from "~/models/history";
import { Movie, type MovieStatus } from "~/models/movie";
import { Serie } from "~/models/serie";
import { import {
AcceptLanguage, AcceptLanguage,
buildRelations, buildRelations,
createPage, createPage,
type Image,
isUuid, isUuid,
keysetPaginate, keysetPaginate,
Page, Page,
@@ -44,6 +59,7 @@ import {
} from "~/models/utils"; } from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions"; import { desc as description } from "~/models/utils/descriptions";
import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { comment } from "~/utils"; import { comment } from "~/utils";
import { import {
entryProgressQ, entryProgressQ,
@@ -86,10 +102,23 @@ async function linkVideos(
.innerJoin(shows, eq(entries.showPk, shows.pk)) .innerJoin(shows, eq(entries.showPk, shows.pk))
.as("entriesQ"); .as("entriesQ");
const hasRenderingQ = tx const renderVid = alias(videos, "renderVid");
const hasRenderingQ = or(
gt(
sql`dense_rank() over (partition by ${entriesQ.pk} order by ${videos.rendering})`,
1,
),
sql`exists(${tx
.select() .select()
.from(entryVideoJoin) .from(entryVideoJoin)
.where(eq(entryVideoJoin.entryPk, entriesQ.pk)); .innerJoin(renderVid, eq(renderVid.pk, entryVideoJoin.videoPk))
.where(
and(
eq(entryVideoJoin.entryPk, entriesQ.pk),
ne(renderVid.rendering, videos.rendering),
),
)})`,
)!;
const ret = await tx const ret = await tx
.insert(entryVideoJoin) .insert(entryVideoJoin)
@@ -98,13 +127,13 @@ async function linkVideos(
.selectDistinctOn([entriesQ.pk, videos.pk], { .selectDistinctOn([entriesQ.pk, videos.pk], {
entryPk: entriesQ.pk, entryPk: entriesQ.pk,
videoPk: videos.pk, videoPk: videos.pk,
slug: computeVideoSlug(entriesQ.slug, sql`exists(${hasRenderingQ})`), slug: computeVideoSlug(entriesQ.slug, hasRenderingQ),
}) })
.from( .from(
values(links, { unnest(links, "j", {
video: "integer", video: "integer",
entry: "jsonb", entry: "jsonb",
}).as("j"), }),
) )
.innerJoin(videos, eq(videos.pk, sql`j.video`)) .innerJoin(videos, eq(videos.pk, sql`j.video`))
.innerJoin( .innerJoin(
@@ -206,14 +235,44 @@ const videoRelations = {
slugs: () => { slugs: () => {
return db return db
.select({ .select({
slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as( slugs: coalesce<string[]>(
"slugs", jsonbAgg(entryVideoJoin.slug),
), sql`'[]'::jsonb`,
).as("slugs"),
}) })
.from(entryVideoJoin) .from(entryVideoJoin)
.where(eq(entryVideoJoin.videoPk, videos.pk)) .where(eq(entryVideoJoin.videoPk, videos.pk))
.as("slugs"); .as("slugs");
}, },
progress: () => {
const query = db
.select({
json: jsonbBuildObject<Progress>({
percent: history.percent,
time: history.time,
playedDate: sql`to_char(${history.playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
videoId: videos.id,
}),
})
.from(history)
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(
and(
eq(profiles.id, sql.placeholder("userId")),
eq(history.videoPk, videos.pk),
),
)
.orderBy(desc(history.playedDate))
.limit(1);
return sql`
(
select coalesce(
${query},
'{"percent": 0, "time": 0, "playedDate": null, "videoId": null}'::jsonb
)
as "progress"
)` as any;
},
entries: ({ languages }: { languages: string[] }) => { entries: ({ languages }: { languages: string[] }) => {
const transQ = getEntryTransQ(languages); const transQ = getEntryTransQ(languages);
@@ -229,6 +288,7 @@ const videoRelations = {
progress: mapProgress({ aliased: false }), progress: mapProgress({ aliased: false }),
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
availableSince: sql`to_char(${entries.availableSince}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
}), }),
), ),
sql`'[]'::jsonb`, sql`'[]'::jsonb`,
@@ -242,6 +302,74 @@ const videoRelations = {
.where(eq(entryVideoJoin.videoPk, videos.pk)) .where(eq(entryVideoJoin.videoPk, videos.pk))
.as("entries"); .as("entries");
}, },
show: ({
languages,
preferOriginal,
}: {
languages: string[];
preferOriginal: boolean;
}) => {
const transQ = db
.selectDistinctOn([showTranslations.pk])
.from(showTranslations)
.orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
)
.as("t");
const watchStatusQ = db
.select({
watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({
...getColumns(watchlist),
percent: watchlist.seenCount,
}).as("watchStatus"),
})
.from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(
and(
eq(profiles.id, sql.placeholder("userId")),
eq(watchlist.showPk, shows.pk),
),
);
return db
.select({
json: jsonbBuildObject<Serie | Movie>({
...getColumns(shows),
...getColumns(transQ),
// movie columns (status is only a typescript hint)
status: sql<MovieStatus>`${shows.status}`,
airDate: shows.startAir,
kind: sql<any>`${shows.kind}`,
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
createdAt: sql`to_char(${shows.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${shows.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
...(preferOriginal && {
poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
banner: sql<Image>`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`,
logo: sql<Image>`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`,
}),
watchStatus: sql`${watchStatusQ}`,
}).as("json"),
})
.from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.where(
eq(
shows.pk,
db
.select({ pk: entries.showPk })
.from(entries)
.innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk))
.where(eq(videos.pk, entryVideoJoin.videoPk)),
),
)
.as("show");
},
previous: ({ languages }: { languages: string[] }) => { previous: ({ languages }: { languages: string[] }) => {
return getNextVideoEntry({ languages, prev: true }); return getNextVideoEntry({ languages, prev: true });
}, },
@@ -263,7 +391,7 @@ function getNextVideoEntry({
const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`);
return db return db
.select({ .select({
json: jsonbBuildObject<Entry>({ json: jsonbBuildObject<{ video: string; entry: Entry }>({
video: entryVideoJoin.slug, video: entryVideoJoin.slug,
entry: { entry: {
...getColumns(entries), ...getColumns(entries),
@@ -274,7 +402,7 @@ function getNextVideoEntry({
createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
}, },
}), }).as("json"),
}) })
.from(entries) .from(entries)
.innerJoin(transQ, eq(entries.pk, transQ.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk))
@@ -326,10 +454,9 @@ function getNextVideoEntry({
.as("next"); .as("next");
} }
export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.model({ .model({
video: Video, video: Video,
"created-videos": t.Array(CreatedVideo),
error: t.Object({}), error: t.Object({}),
}) })
.use(auth) .use(auth)
@@ -337,9 +464,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
":id", ":id",
async ({ async ({
params: { id }, params: { id },
query: { with: relations }, query: { with: relations, preferOriginal },
headers: { "accept-language": langs }, headers: { "accept-language": langs },
jwt: { sub }, jwt: { sub, settings },
status, status,
}) => { }) => {
const languages = processLanguages(langs); const languages = processLanguages(langs);
@@ -351,10 +478,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.select({ .select({
...getColumns(videos), ...getColumns(videos),
...buildRelations( ...buildRelations(
["slugs", "entries", ...relations], ["slugs", "progress", "entries", ...relations],
videoRelations, videoRelations,
{ {
languages, languages,
preferOriginal: preferOriginal ?? settings.preferOriginal,
}, },
), ),
}) })
@@ -369,7 +497,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
message: `No video found with id or slug '${id}'`, message: `No video found with id or slug '${id}'`,
}); });
} }
return video; return video as any;
}, },
{ {
detail: { detail: {
@@ -382,10 +510,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}), }),
}), }),
query: t.Object({ query: t.Object({
with: t.Array(t.UnionEnum(["previous", "next"]), { with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
default: [], default: [],
description: "Include related entries in the response.", description: "Include related entries in the response.",
}), }),
preferOriginal: t.Optional(
t.Boolean({
description: description.preferOriginal,
}),
),
}), }),
headers: t.Object( headers: t.Object(
{ {
@@ -400,6 +533,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
slugs: t.Array( slugs: t.Array(
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
), ),
progress: Progress,
entries: t.Array(Entry), entries: t.Array(Entry),
previous: t.Optional( previous: t.Optional(
t.Nullable( t.Nullable(
@@ -423,6 +557,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}), }),
), ),
), ),
show: t.Optional(
t.Union([
t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]),
t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]),
]),
),
}), }),
]), ]),
404: { 404: {
@@ -433,6 +573,133 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
}, },
}, },
) )
.get(
":id/info",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/info`);
},
{
detail: { description: "Get a video's metadata informations" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to retrieve.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/direct",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const filename = path.substring(path.lastIndexOf("/") + 1);
return redirect(`/video/${path}/direct/${filename}`);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/master.m3u8",
async ({ params: { id }, request, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const query = request.url.substring(request.url.indexOf("?"));
return redirect(`/video/${path}/master.m3u8${query}`);
},
{
detail: { description: "Get redirected to the master.m3u8 of the video" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get( .get(
"", "",
async () => { async () => {
@@ -552,16 +819,27 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
422: KError, 422: KError,
}, },
}, },
) );
export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.model({
video: Video,
"created-videos": t.Array(CreatedVideo),
error: t.Object({}),
})
.use(auth)
.post( .post(
"", "",
async ({ body, status }) => { async ({ body, status }) => {
if (body.length === 0) {
return status(422, { status: 422, message: "No videos" });
}
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
let vids: { pk: number; id: string; path: string; guess: Guess }[] = []; let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
try { try {
vids = await tx vids = await tx
.insert(videos) .insert(videos)
.values(body) .select(unnestValues(body, videos))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [videos.path], target: [videos.path],
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
@@ -650,6 +928,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
description: description:
"Invalid rendering specified. (conflicts with an existing video)", "Invalid rendering specified. (conflicts with an existing video)",
}, },
422: KError,
}, },
}, },
) )

View File

@@ -1,13 +1,14 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import tls, { type ConnectionOptions } from "node:tls"; import tls, { type ConnectionOptions } from "node:tls";
import { instrumentDrizzleClient } from "@kubiks/otel-drizzle";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { migrate as migrateDb } from "drizzle-orm/node-postgres/migrator"; import { migrate as migrateDb } from "drizzle-orm/node-postgres/migrator";
import type { PoolConfig } from "pg"; import type { PoolConfig } from "pg";
import { record } from "~/otel";
import * as schema from "./schema"; import * as schema from "./schema";
async function getPostgresConfig(): Promise<PoolConfig> {
const config: PoolConfig = { const config: PoolConfig = {
connectionString: process.env.POSTGRES_URL, connectionString: process.env.POSTGRES_URL,
host: process.env.PGHOST ?? "postgres", host: process.env.PGHOST ?? "postgres",
@@ -19,10 +20,13 @@ async function getPostgresConfig(): Promise<PoolConfig> {
application_name: process.env.PGAPPNAME ?? "kyoo", application_name: process.env.PGAPPNAME ?? "kyoo",
}; };
async function parseSslConfig(): Promise<PoolConfig> {
// Due to an upstream bug, if `ssl` is not falsey, an SSL connection will always be attempted. This means // Due to an upstream bug, if `ssl` is not falsey, an SSL connection will always be attempted. This means
// that non-SSL connection options under `ssl` (which is incorrectly named) cannot be set unless SSL is enabled. // that non-SSL connection options under `ssl` (which is incorrectly named) cannot be set unless SSL is enabled.
if (!process.env.PGSSLMODE || process.env.PGSSLMODE === "disable") if (!process.env.PGSSLMODE || process.env.PGSSLMODE === "disable") {
config.ssl = false;
return config; return config;
}
// Despite this field's name, it is used to configure everything below the application layer. // Despite this field's name, it is used to configure everything below the application layer.
const ssl: ConnectionOptions = {}; const ssl: ConnectionOptions = {};
@@ -107,28 +111,52 @@ async function getPostgresConfig(): Promise<PoolConfig> {
return config; return config;
} }
const postgresConfig = await getPostgresConfig(); const postgresConfig = await parseSslConfig();
// use this when using drizzle-kit since it can't parse await statements
// const postgresConfig = config;
console.log("Connecting to postgres with config", {
...postgresConfig,
password: postgresConfig.password ? "<redacted>" : undefined,
ssl:
postgresConfig.ssl && typeof postgresConfig.ssl === "object"
? {
...postgresConfig.ssl,
key: "<redacted>",
cert: "<redacted>",
ca: "<redacted>",
}
: postgresConfig.ssl,
});
export const db = drizzle({ export const db = drizzle({
schema, schema,
connection: postgresConfig, connection: postgresConfig,
casing: "snake_case", casing: "snake_case",
}); });
instrumentDrizzleClient(db, {
maxQueryTextLength: 100_000_000,
});
export const migrate = async () => { export const migrate = record("migrate", async () => {
const APP_SCHEMA = "kyoo";
try {
await db.execute( await db.execute(
sql.raw(` sql.raw(`
create extension if not exists pg_trgm; create schema if not exists ${APP_SCHEMA};
SET pg_trgm.word_similarity_threshold = 0.4; create extension if not exists pg_trgm schema ${APP_SCHEMA};
ALTER DATABASE "${postgresConfig.database}" SET pg_trgm.word_similarity_threshold = 0.4; set pg_trgm.word_similarity_threshold = 0.4;
alter database "${postgresConfig.database}" set pg_trgm.word_similarity_threshold = 0.4;
`), `),
); );
} catch (err: any) {
console.error("Error while updating pg_trgm", err.message);
}
await migrateDb(db, { await migrateDb(db, {
migrationsSchema: "kyoo", migrationsSchema: APP_SCHEMA,
migrationsFolder: "./drizzle", migrationsFolder: "./drizzle",
}); });
console.log(`Database ${postgresConfig.database} migrated!`); console.log(`Database ${postgresConfig.database} migrated!`);
}; });
export type Transaction = export type Transaction =
| typeof db | typeof db

View File

@@ -12,9 +12,8 @@ import {
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows"; import { shows } from "./shows";
import { image, language, schema } from "./utils"; import { image, language, schema, timestamp } from "./utils";
import { entryVideoJoin } from "./videos"; import { entryVideoJoin } from "./videos";
export const entryType = schema.enum("entry_type", [ export const entryType = schema.enum("entry_type", [

View File

@@ -1,9 +1,8 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { check, index, integer } from "drizzle-orm/pg-core"; import { check, index, integer } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { entries } from "./entries"; import { entries } from "./entries";
import { profiles } from "./profiles"; import { profiles } from "./profiles";
import { schema } from "./utils"; import { schema, timestamp } from "./utils";
import { videos } from "./videos"; import { videos } from "./videos";
export const history = schema.table( export const history = schema.table(
@@ -13,12 +12,14 @@ export const history = schema.table(
profilePk: integer() profilePk: integer()
.notNull() .notNull()
.references(() => profiles.pk, { onDelete: "cascade" }), .references(() => profiles.pk, { onDelete: "cascade" }),
// we need to attach an history to an entry because we want to keep history
// when we delete a video file
entryPk: integer() entryPk: integer()
.notNull() .notNull()
.references(() => entries.pk, { onDelete: "cascade" }), .references(() => entries.pk, { onDelete: "cascade" }),
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
percent: integer().notNull().default(0), percent: integer().notNull().default(0),
time: integer(), time: integer().notNull().default(0),
playedDate: timestamp({ withTimezone: true, mode: "iso" }) playedDate: timestamp({ withTimezone: true, mode: "iso" })
.notNull() .notNull()
.default(sql`now()`), .default(sql`now()`),

View File

@@ -1,7 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { index, integer, jsonb, uuid, varchar } from "drizzle-orm/pg-core"; import { index, integer, jsonb, uuid, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "../utils"; import { schema, timestamp } from "./utils";
import { schema } from "./utils";
export const mqueue = schema.table( export const mqueue = schema.table(
"mqueue", "mqueue",
@@ -9,6 +8,7 @@ export const mqueue = schema.table(
id: uuid().notNull().primaryKey().defaultRandom(), id: uuid().notNull().primaryKey().defaultRandom(),
kind: varchar({ length: 255 }).notNull(), kind: varchar({ length: 255 }).notNull(),
message: jsonb().notNull(), message: jsonb().notNull(),
priority: integer().notNull().default(0),
attempt: integer().notNull().default(0), attempt: integer().notNull().default(0),
createdAt: timestamp({ withTimezone: true, mode: "iso" }) createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull() .notNull()

View File

@@ -10,9 +10,8 @@ import {
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows"; import { shows } from "./shows";
import { image, language, schema } from "./utils"; import { image, language, schema, timestamp } from "./utils";
export const season_extid = () => export const season_extid = () =>
jsonb() jsonb()
@@ -40,7 +39,7 @@ export const seasons = schema.table(
startAir: date(), startAir: date(),
endAir: date(), endAir: date(),
entriesCount: integer().notNull(), entriesCount: integer().notNull().default(0),
availableCount: integer().notNull().default(0), availableCount: integer().notNull().default(0),
externalId: season_extid(), externalId: season_extid(),
@@ -92,7 +91,7 @@ export const seasonRelations = relations(seasons, ({ one, many }) => ({
export const seasonTrRelations = relations(seasonTranslations, ({ one }) => ({ export const seasonTrRelations = relations(seasonTranslations, ({ one }) => ({
season: one(seasons, { season: one(seasons, {
relationName: "season_translation", relationName: "season_translations",
fields: [seasonTranslations.pk], fields: [seasonTranslations.pk],
references: [seasons.pk], references: [seasons.pk],
}), }),

View File

@@ -13,12 +13,11 @@ import {
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { Image, Original } from "~/models/utils"; import type { Image, Original } from "~/models/utils";
import { timestamp } from "../utils";
import { entries } from "./entries"; import { entries } from "./entries";
import { seasons } from "./seasons"; import { seasons } from "./seasons";
import { roles } from "./staff"; import { roles } from "./staff";
import { showStudioJoin } from "./studios"; import { showStudioJoin } from "./studios";
import { externalid, image, language, schema } from "./utils"; import { externalid, image, language, schema, timestamp } from "./utils";
export const showKind = schema.enum("show_kind", [ export const showKind = schema.enum("show_kind", [
"serie", "serie",

View File

@@ -8,9 +8,8 @@ import {
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { Character } from "~/models/staff"; import type { Character } from "~/models/staff";
import { timestamp } from "../utils";
import { shows } from "./shows"; import { shows } from "./shows";
import { externalid, image, schema } from "./utils"; import { externalid, image, schema, timestamp } from "./utils";
export const roleKind = schema.enum("role_kind", [ export const roleKind = schema.enum("role_kind", [
"actor", "actor",

View File

@@ -7,9 +7,8 @@ import {
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows"; import { shows } from "./shows";
import { externalid, image, language, schema } from "./utils"; import { externalid, image, language, schema, timestamp } from "./utils";
export const studios = schema.table("studios", { export const studios = schema.table("studios", {
pk: integer().primaryKey().generatedAlwaysAsIdentity(), pk: integer().primaryKey().generatedAlwaysAsIdentity(),

View File

@@ -1,4 +1,4 @@
import { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core"; import { customType, jsonb, pgSchema, varchar } from "drizzle-orm/pg-core";
import type { Image } from "~/models/utils"; import type { Image } from "~/models/utils";
export const schema = pgSchema("kyoo"); export const schema = pgSchema("kyoo");
@@ -20,3 +20,19 @@ export const externalid = () =>
>() >()
.notNull() .notNull()
.default({}); .default({});
export const timestamp = customType<{
data: string;
driverData: string;
config: { withTimezone: boolean; precision?: number; mode: "iso" };
}>({
dataType(config) {
const precision = config?.precision ? ` (${config.precision})` : "";
return `timestamp${precision}${config?.withTimezone ? " with time zone" : ""}`;
},
fromDriver(value: string): string {
// postgres format: 2025-06-22 16:13:37.489301+00
// what we want: 2025-06-22T16:13:37Z
return `${value.substring(0, 10)}T${value.substring(11, 19)}Z`;
},
});

View File

@@ -10,9 +10,8 @@ import {
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { Guess } from "~/models/video"; import type { Guess } from "~/models/video";
import { timestamp } from "../utils";
import { entries } from "./entries"; import { entries } from "./entries";
import { schema } from "./utils"; import { schema, timestamp } from "./utils";
export const videos = schema.table( export const videos = schema.table(
"videos", "videos",

View File

@@ -1,10 +1,9 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { check, integer, primaryKey } from "drizzle-orm/pg-core"; import { check, integer, primaryKey } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { entries } from "./entries"; import { entries } from "./entries";
import { profiles } from "./profiles"; import { profiles } from "./profiles";
import { shows } from "./shows"; import { shows } from "./shows";
import { schema } from "./utils"; import { schema, timestamp } from "./utils";
export const watchlistStatus = schema.enum("watchlist_status", [ export const watchlistStatus = schema.enum("watchlist_status", [
"watching", "watching",

View File

@@ -8,15 +8,16 @@ import {
type Subquery, type Subquery,
sql, sql,
Table, Table,
type TableConfig,
View, View,
ViewBaseConfig, ViewBaseConfig,
} from "drizzle-orm"; } from "drizzle-orm";
import type { CasingCache } from "drizzle-orm/casing"; import type { CasingCache } from "drizzle-orm/casing";
import type { AnyMySqlSelect } from "drizzle-orm/mysql-core"; import type { AnyMySqlSelect } from "drizzle-orm/mysql-core";
import { import type {
type AnyPgSelect, AnyPgSelect,
customType, PgTableWithColumns,
type SelectedFieldsFlat, SelectedFieldsFlat,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core"; import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core";
import type { WithSubquery } from "drizzle-orm/subquery"; import type { WithSubquery } from "drizzle-orm/subquery";
@@ -73,41 +74,121 @@ export function conflictUpdateAllExcept<
} }
// drizzle is bugged and doesn't allow js arrays to be used in raw sql. // drizzle is bugged and doesn't allow js arrays to be used in raw sql.
export function sqlarr(array: unknown[]) { export function sqlarr(array: unknown[]): string {
return `{${array.map((item) => `"${item}"`).join(",")}}`; function escapeStr(str: string) {
return str.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
} }
// See https://github.com/drizzle-team/drizzle-orm/issues/4044 return `{${array
export function values<K extends string>( .map((item) =>
items: Record<K, unknown>[], item === "null" || item === null || item === undefined
typeInfo: Partial<Record<K, string>> = {}, ? "null"
) { : Array.isArray(item)
if (items[0] === undefined) ? sqlarr(item)
: typeof item === "object"
? `"${escapeStr(JSON.stringify(item))}"`
: `"${escapeStr(item.toString())}"`,
)
.join(", ")}}`;
}
/* goal:
* unnestValues([{a: 1, b: 2}, {a: 3, b: 4}], tbl)
*
* ```sql
* select a, b, now() as updated_at from unnest($1::integer[], $2::integer[]);
* ```
* params:
* $1: [1, 2]
* $2: [3, 4]
*
* select
*/
export const unnestValues = <
T extends Record<string, unknown>,
C extends TableConfig = never,
>(
values: T[],
typeInfo: PgTableWithColumns<C>,
) => {
if (values[0] === undefined)
throw new Error("Invalid values, expecting at least one items"); throw new Error("Invalid values, expecting at least one items");
const [firstProp, ...props] = Object.keys(items[0]) as K[];
const values = items
.map((x, i) => {
let ret = sql`(${x[firstProp]}`;
if (i === 0 && typeInfo[firstProp])
ret = sql`${ret}::${sql.raw(typeInfo[firstProp])}`;
for (const val of props) {
ret = sql`${ret}, ${x[val]}`;
if (i === 0 && typeInfo[val])
ret = sql`${ret}::${sql.raw(typeInfo[val])}`;
}
return sql`${ret})`;
})
.reduce((acc, x) => sql`${acc}, ${x}`);
const valueNames = [firstProp, ...props].join(", ");
return { const columns = getTableColumns(typeInfo);
as: (name: string) => { const keys = Object.keys(values[0]).filter((x) => x in columns);
return sql`(values ${values}) as ${sql.raw(name)}(${sql.raw(valueNames)})`; // @ts-expect-error: drizzle internal
const casing = db.dialect.casing as CasingCache;
const dbNames = Object.fromEntries(
Object.entries(columns).map(([k, v]) => [k, casing.getColumnCasing(v)]),
);
const vals = values.reduce(
(acc, cur, i) => {
for (const k of keys) {
if (k in cur) acc[k].push(cur[k]);
else acc[k].push(null);
}
for (const k of Object.keys(cur)) {
if (k in acc) continue;
if (!(k in columns)) continue;
keys.push(k);
acc[k] = new Array(i).fill(null);
acc[k].push(cur[k]);
}
return acc;
}, },
Object.fromEntries(keys.map((x) => [x, [] as unknown[]])),
);
const computed = Object.entries(columns)
.filter(([k, v]) => (v.defaultFn || v.onUpdateFn) && !keys.includes(k))
.map(([k]) => k);
return db
.select(
Object.fromEntries([
...keys.map((x) => [x, sql.raw(`"${dbNames[x]}"`)]),
...computed.map((x) => [
x,
(columns[x].defaultFn?.() ?? columns[x].onUpdateFn!()).as(dbNames[x]),
]),
]) as {
[k in keyof typeof typeInfo.$inferInsert]-?: SQL.Aliased<
(typeof typeInfo.$inferInsert)[k]
>;
},
)
.from(
sql`unnest(${sql.join(
keys.map(
(k) =>
sql`${sqlarr(vals[k])}${sql.raw(`::${columns[k].getSQLType()}[]`)}`,
),
sql.raw(", "),
)}) as v(${sql.raw(keys.map((x) => `"${dbNames[x]}"`).join(", "))})`,
);
}; };
}
export const coalesce = <T>(val: SQL<T> | Column, def: SQL<T> | Column) => { export const unnest = <T extends Record<string, unknown>>(
values: T[],
name: string,
typeInfo: Record<keyof T, string>,
) => {
const keys = Object.keys(typeInfo);
const vals = values.reduce(
(acc, cur) => {
for (const k of keys) {
if (k in cur) acc[k].push(cur[k]);
else acc[k].push(null);
}
return acc;
},
Object.fromEntries(keys.map((x) => [x, [] as unknown[]])),
);
return sql`unnest(${sql.join(
keys.map((k) => sql`${sqlarr(vals[k])}${sql.raw(`::${typeInfo[k]}[]`)}`),
sql.raw(", "),
)}) as ${sql.raw(name)}(${sql.raw(keys.map((x) => `"${x}"`).join(", "))})`;
};
export const coalesce = <T>(val: SQL<T> | SQLWrapper, def: SQL<T> | Column) => {
return sql<T>`coalesce(${val}, ${def})`; return sql<T>`coalesce(${val}, ${def})`;
}; };
@@ -148,23 +229,12 @@ export const jsonbBuildObject = <T>(select: JsonFields) => {
}; };
export const isUniqueConstraint = (e: unknown): boolean => { export const isUniqueConstraint = (e: unknown): boolean => {
if (typeof e !== "object" || !e || !("cause" in e)) return false;
const cause = e.cause;
return ( return (
typeof e === "object" && e != null && "code" in e && e.code === "23505" typeof cause === "object" &&
cause != null &&
"code" in cause &&
cause.code === "23505"
); );
}; };
export const timestamp = customType<{
data: string;
driverData: string;
config: { withTimezone: boolean; precision?: number; mode: "iso" };
}>({
dataType(config) {
const precision = config?.precision ? ` (${config.precision})` : "";
return `timestamp${precision}${config?.withTimezone ? " with time zone" : ""}`;
},
fromDriver(value: string): string {
// postgres format: 2025-06-22 16:13:37.489301+00
// what we want: 2025-06-22T16:13:37Z
return `${value.substring(0, 10)}T${value.substring(11, 19)}Z`;
},
});

View File

@@ -2,13 +2,12 @@ import { swagger } from "@elysiajs/swagger";
import Elysia from "elysia"; import Elysia from "elysia";
import { handlers } from "./base"; import { handlers } from "./base";
import { processImages } from "./controllers/seed/images"; import { processImages } from "./controllers/seed/images";
import { migrate } from "./db"; import { db, migrate } from "./db";
import { comment } from "./utils"; import { comment } from "./utils";
await migrate(); await migrate();
// run image processor task in background const disposeImages = await processImages();
processImages();
const app = new Elysia() const app = new Elysia()
.use( .use(
@@ -88,4 +87,14 @@ const app = new Elysia()
.use(handlers) .use(handlers)
.listen(3567); .listen(3567);
process.on("SIGTERM", () => {
app.stop().then(async () => {
console.log("Api stopping");
disposeImages();
await db.$client.end();
console.log("Api stopped");
process.exit(0);
});
});
console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`);

View File

@@ -3,15 +3,13 @@ import { comment } from "~/utils";
export const Progress = t.Object({ export const Progress = t.Object({
percent: t.Integer({ minimum: 0, maximum: 100 }), percent: t.Integer({ minimum: 0, maximum: 100 }),
time: t.Nullable( time: t.Integer({
t.Integer({
minimum: 0, minimum: 0,
description: comment` description: comment`
When this episode was stopped (in seconds since the start). When this episode was stopped (in seconds since the start).
This value is null if the entry was never watched or is finished. This value is null if the entry was never watched or is finished.
`, `,
}), }),
),
playedDate: t.Nullable(t.String({ format: "date-time" })), playedDate: t.Nullable(t.String({ format: "date-time" })),
videoId: t.Nullable( videoId: t.Nullable(
t.String({ t.String({
@@ -30,11 +28,11 @@ export const Progress = t.Object({
export type Progress = typeof Progress.static; export type Progress = typeof Progress.static;
export const SeedHistory = t.Intersect([ export const SeedHistory = t.Intersect([
Progress,
t.Object({ t.Object({
entry: t.String({ entry: t.String({
description: "Id or slug of the entry/movie you watched", description: "Id or slug of the entry/movie you watched",
}), }),
}), }),
Progress,
]); ]);
export type SeedHistory = typeof SeedHistory.static; export type SeedHistory = typeof SeedHistory.static;

View File

@@ -7,10 +7,12 @@ export const Original = t.Object({
description: "The language code this was made in.", description: "The language code this was made in.",
examples: ["ja"], examples: ["ja"],
}), }),
name: t.String({ name: t.Nullable(
t.String({
description: "The name in the original language", description: "The name in the original language",
examples: ["進撃の巨人"], examples: ["進撃の巨人"],
}), }),
),
latinName: t.Nullable( latinName: t.Nullable(
t.String({ t.String({
description: comment` description: comment`

View File

@@ -18,11 +18,31 @@ export const Page = <T extends TSchema>(schema: T, options?: ObjectOptions) =>
export const createPage = <T>( export const createPage = <T>(
items: T[], items: T[],
{ url, sort, limit }: { url: string; sort: Sort; limit: number }, {
url,
sort,
limit,
headers,
}: {
url: string;
sort: Sort;
limit: number;
headers?: Record<string, string | undefined>;
},
) => { ) => {
let next: string | null = null;
const uri = new URL(url); const uri = new URL(url);
const forwardedProto = headers?.["x-forwarded-proto"];
if (forwardedProto) {
uri.protocol = forwardedProto;
}
const forwardedHost = headers?.["x-forwarded-host"];
if (forwardedHost) {
uri.host = forwardedHost;
}
const current = uri.toString();
let next: string | null = null;
if (sort.random) { if (sort.random) {
uri.searchParams.set("sort", `random:${sort.random.seed}`); uri.searchParams.set("sort", `random:${sort.random.seed}`);
url = uri.toString(); url = uri.toString();
@@ -35,5 +55,5 @@ export const createPage = <T>(
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
next = uri.toString(); next = uri.toString();
} }
return { items, this: url, next }; return { items, this: current, next };
}; };

View File

@@ -55,10 +55,11 @@ export const Sort = (
), ),
) )
.Decode((sort: string[]): Sort => { .Decode((sort: string[]): Sort => {
if (!Array.isArray(sort)) sort = [sort];
const random = sort.find((x) => x.startsWith("random")); const random = sort.find((x) => x.startsWith("random"));
if (random) { if (random) {
const seed = random.includes(":") const seed = random.includes(":")
? Number.parseInt(random.substring("random:".length)) ? Number.parseInt(random.substring("random:".length), 10)
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); : Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { tablePk, random: { seed }, sort: [] }; return { tablePk, random: { seed }, sort: [] };
} }

43
api/src/otel.ts Normal file
View File

@@ -0,0 +1,43 @@
import { record as elysiaRecord, opentelemetry } from "@elysiajs/opentelemetry";
import { OTLPMetricExporter as GrpcMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc";
import { OTLPMetricExporter as HttpMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter as GrpcTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { OTLPTraceExporter as HttpTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
import Elysia from "elysia";
const protocol =
process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ??
process.env.OTEL_EXPORTER_OTLP_PROTOCOL ??
"http/protobuf";
export const otel = new Elysia()
.use(
opentelemetry({
serviceName: "kyoo.api",
spanProcessors: [
new BatchSpanProcessor(
protocol === "grpc"
? new GrpcTraceExporter()
: new HttpTraceExporter(),
),
],
metricReader: new PeriodicExportingMetricReader({
exporter:
protocol === "grpc"
? new GrpcMetricExporter()
: new HttpMetricExporter(),
}),
}),
)
.as("global");
export function record<T extends (...args: any) => any>(
spanName: string,
fn: T,
): T {
const wrapped = (...args: Parameters<T>) =>
elysiaRecord(spanName, () => fn(...args));
return wrapped as T;
}

View File

@@ -28,3 +28,31 @@ export function getFile(path: string): BunFile | S3File {
return Bun.file(path); return Bun.file(path);
} }
export function uniqBy<T>(a: T[], key: (val: T) => string): T[] {
const seen: Record<string, boolean> = {};
return a.filter((item) => {
const k = key(item);
if (seen[k]) return false;
seen[k] = true;
return true;
});
}
export function traverse<T extends Record<string, any>>(
arr: T[],
): { [K in keyof T]: T[K][] } | null {
if (arr.length === 0) return null;
const result = {} as { [K in keyof T]: T[K][] };
arr.forEach((obj, i) => {
for (const key in obj) {
if (!result[key]) {
result[key] = new Array(i).fill(null);
}
result[key].push(obj[key]);
}
});
return result;
}

102
api/src/websockets.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { TObject, TString } from "@sinclair/typebox";
import Elysia, { type TSchema, t } from "elysia";
import { verifyJwt } from "./auth";
import { updateProgress } from "./controllers/profiles/history";
import { getOrCreateProfile } from "./controllers/profiles/profile";
import { SeedHistory } from "./models/history";
const actionMap = {
ping: handler({
message(ws) {
ws.send({ response: "pong" });
},
}),
watch: handler({
body: t.Omit(SeedHistory, ["playedDate"]),
permissions: ["core.read"],
async message(ws, body) {
const profilePk = await getOrCreateProfile(ws.data.jwt.sub);
const ret = await updateProgress(profilePk, [
{ ...body, playedDate: null },
]);
ws.send(ret);
},
}),
};
const baseWs = new Elysia()
.guard({
headers: t.Object(
{
authorization: t.Optional(t.TemplateLiteral("Bearer ${string}")),
"Sec-WebSocket-Protocol": t.Optional(
t.Array(
t.Union([t.Literal("kyoo"), t.TemplateLiteral("Bearer ${string}")]),
),
),
},
{ additionalProperties: true },
),
})
.resolve(
async ({
headers: { authorization, "Sec-WebSocket-Protocol": wsProtocol },
status,
}) => {
const auth =
authorization ??
(wsProtocol?.length === 2 &&
wsProtocol[0] === "kyoo" &&
wsProtocol[1].startsWith("Bearer ")
? wsProtocol[1]
: null);
const bearer = auth?.slice(7);
if (!bearer) {
return status(403, {
status: 403,
message: "No authorization header was found.",
});
}
try {
return await verifyJwt(bearer);
} catch (err) {
return status(403, {
status: 403,
message: "Invalid jwt. Verification vailed",
details: err,
});
}
},
);
export const appWs = baseWs.ws("/ws", {
body: t.Union(
Object.entries(actionMap).map(([k, v]) =>
t.Intersect([t.Object({ action: t.Literal(k) }), v.body ?? t.Object({})]),
),
) as unknown as TObject<{ action: TString }>,
async open(ws) {
if (!ws.data.jwt.sub) {
ws.close(3000, "Unauthorized");
}
},
async message(ws, { action, ...body }) {
const handler = actionMap[action as keyof typeof actionMap];
for (const perm of handler.permissions ?? []) {
if (!ws.data.jwt.permissions.includes(perm)) {
return ws.close(3000, `Missing permission: '${perm}'.`);
}
}
await handler.message(ws as any, body as any);
},
});
type Ws = Parameters<NonNullable<Parameters<typeof baseWs.ws>[1]["open"]>>[0];
function handler<Schema extends TSchema = TObject<{}>>(ret: {
body?: Schema;
permissions?: string[];
message: (ws: Ws, body: Schema["static"]) => void | Promise<void>;
}) {
return ret;
}

View File

@@ -0,0 +1,48 @@
import { buildUrl } from "tests/utils";
import { handlers } from "~/base";
import { getJwtHeaders } from "./jwt";
export const getCollection = async (
id: string,
{
langs,
...query
}: { langs?: string; preferOriginal?: boolean; with?: string[] },
) => {
const resp = await handlers.handle(
new Request(buildUrl(`collections/${id}`, query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};
export const getCollections = async ({
langs,
...query
}: {
langs?: string;
preferOriginal?: boolean;
with?: string[];
}) => {
const resp = await handlers.handle(
new Request(buildUrl("collections", query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@@ -1,4 +1,5 @@
export * from "~/base"; export * from "~/base";
export * from "./collections-helper";
export * from "./movies-helper"; export * from "./movies-helper";
export * from "./series-helper"; export * from "./series-helper";
export * from "./shows-helper"; export * from "./shows-helper";

View File

@@ -20,6 +20,7 @@ const [resp, body] = await createVideo([
title: "mia", title: "mia",
episodes: [{ season: 1, episode: 13 }], episodes: [{ season: 1, episode: 13 }],
from: "test", from: "test",
history: [],
}, },
part: null, part: null,
path: "/video/mia s1e13.mkv", path: "/video/mia s1e13.mkv",
@@ -33,6 +34,7 @@ const [resp, body] = await createVideo([
episodes: [{ season: 2, episode: 1 }], episodes: [{ season: 2, episode: 1 }],
years: [2017], years: [2017],
from: "test", from: "test",
history: [],
}, },
part: null, part: null,
path: "/video/mia 2017 s2e1.mkv", path: "/video/mia 2017 s2e1.mkv",
@@ -41,7 +43,7 @@ const [resp, body] = await createVideo([
for: [{ slug: `${madeInAbyss.slug}-s2e1` }], for: [{ slug: `${madeInAbyss.slug}-s2e1` }],
}, },
{ {
guess: { title: "bubble", from: "test" }, guess: { title: "bubble", from: "test", history: [] },
part: null, part: null,
path: "/video/bubble.mkv", path: "/video/bubble.mkv",
rendering: "sha5", rendering: "sha5",

View File

@@ -1,21 +1,25 @@
import { describe, expect, it } from "bun:test"; import { beforeAll, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { createMovie, createSerie } from "tests/helpers";
import { expectStatus } from "tests/utils";
import { defaultBlurhash, processImages } from "~/controllers/seed/images"; import { defaultBlurhash, processImages } from "~/controllers/seed/images";
import { db } from "~/db"; import { db } from "~/db";
import { mqueue, shows, staff, studios, videos } from "~/db/schema"; import { mqueue, shows, staff, studios, videos } from "~/db/schema";
import { madeInAbyss } from "~/models/examples"; import { dune, madeInAbyss } from "~/models/examples";
import { createSerie } from "../helpers";
describe("images", () => { describe("images", () => {
it("Create a serie download images", async () => { beforeAll(async () => {
await db.delete(shows); await db.delete(shows);
await db.delete(studios); await db.delete(studios);
await db.delete(staff); await db.delete(staff);
await db.delete(videos); await db.delete(videos);
await db.delete(mqueue); await db.delete(mqueue);
});
it("Create a serie download images", async () => {
await db.delete(mqueue);
await createSerie(madeInAbyss); await createSerie(madeInAbyss);
const release = await processImages(); const release = await processImages(true);
// remove notifications to prevent other images to be downloaded (do not curl 20000 images for nothing) // remove notifications to prevent other images to be downloaded (do not curl 20000 images for nothing)
release(); release();
@@ -26,4 +30,34 @@ describe("images", () => {
expect(ret!.original.poster!.blurhash).toBeString(); expect(ret!.original.poster!.blurhash).toBeString();
expect(ret!.original.poster!.blurhash).not.toBe(defaultBlurhash); expect(ret!.original.poster!.blurhash).not.toBe(defaultBlurhash);
}); });
it("Download 404 image", async () => {
await db.delete(mqueue);
const url404 = "https://mockhttp.org/status/404";
const [ret, body] = await createMovie({
...dune,
translations: {
en: {
...dune.translations.en,
poster: url404,
thumbnail: null,
banner: null,
logo: null,
},
},
});
expectStatus(ret, body).toBe(201);
const release = await processImages(true);
// remove notifications to prevent other images to be downloaded (do not curl 20000 images for nothing)
release();
const failed = await db.query.mqueue.findFirst({
where: and(
eq(mqueue.kind, "image"),
eq(sql`${mqueue.message}->>'url'`, url404),
),
});
expect(failed!.attempt).toBe(5);
});
}); });

View File

@@ -0,0 +1,106 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { getJwtHeaders } from "tests/helpers/jwt";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { shows } from "~/db/schema";
import { bubble } from "~/models/examples";
import { dune1984 } from "~/models/examples/dune-1984";
import { dune } from "~/models/examples/dune-2021";
import { createMovie, getMovies, handlers } from "../helpers";
beforeAll(async () => {
await db.delete(shows);
for (const movie of [bubble, dune1984, dune]) {
const [ret, _] = await createMovie(movie);
expect(ret.status).toBe(201);
}
});
describe("X-Forwarded-Proto header support", () => {
it("Pagination URLs use HTTPS when X-Forwarded-Proto is https", async () => {
const resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
},
}),
);
const body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "https://localhost/api/movies?limit=2",
next: expect.stringContaining("https://localhost/api/movies?limit=2"),
});
});
it("Pagination URLs use HTTP when no X-Forwarded-Proto header", async () => {
const [resp, body] = await getMovies({
limit: 2,
langs: "en",
});
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "http://localhost/api/movies?limit=2",
next: expect.stringContaining("http://localhost/api/movies?limit=2"),
});
});
it("X-Forwarded-Host header changes the host in pagination URLs", async () => {
const resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
const body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "https://kyoo.example.com/api/movies?limit=2",
next: expect.stringContaining(
"https://kyoo.example.com/api/movies?limit=2",
),
});
});
it("Second page of pagination respects X-Forwarded headers", async () => {
let resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
let body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body.next).toBeTruthy();
expect(body.next).toContain("https://kyoo.example.com");
// Follow the next link with the same headers
resp = await handlers.handle(
new Request(body.next, {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body.this).toContain("https://kyoo.example.com");
});
});

View File

@@ -6,9 +6,12 @@ import {
getStaffRoles, getStaffRoles,
} from "tests/helpers"; } from "tests/helpers";
import { expectStatus } from "tests/utils"; import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { staff } from "~/db/schema";
import { madeInAbyss } from "~/models/examples"; import { madeInAbyss } from "~/models/examples";
beforeAll(async () => { beforeAll(async () => {
await db.delete(staff);
await createSerie(madeInAbyss); await createSerie(madeInAbyss);
}); });

View File

@@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { expectStatus } from "tests/utils"; import { expectStatus } from "tests/utils";
import { db } from "~/db"; import { db } from "~/db";
import { seasons, shows, videos } from "~/db/schema"; import { entries, seasons, shows, videos } from "~/db/schema";
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples"; import { madeInAbyss, madeInAbyssVideo } from "~/models/examples";
import { createSerie } from "../helpers"; import { createSerie } from "../helpers";
@@ -104,4 +104,61 @@ describe("Serie seeding", () => {
], ],
}); });
}); });
it("Can create a serie with quotes", async () => {
await db.delete(entries);
const [resp, body] = await createSerie({
...madeInAbyss,
slug: "quote-test",
seasons: [
{
...madeInAbyss.seasons[0],
translations: {
en: {
...madeInAbyss.seasons[0].translations.en,
name: "Season'1",
},
},
},
{
...madeInAbyss.seasons[1],
translations: {
en: {
...madeInAbyss.seasons[0].translations.en,
name: 'Season"2',
description: `This's """""quote, idk'''''`,
},
},
},
],
});
expectStatus(resp, body).toBe(201);
expect(body.id).toBeString();
expect(body.slug).toBe("quote-test");
const ret = await db.query.shows.findFirst({
where: eq(shows.id, body.id),
with: {
seasons: {
orderBy: seasons.seasonNumber,
with: { translations: true },
},
entries: {
with: {
translations: true,
evj: { with: { video: true } },
},
},
},
});
expect(ret).not.toBeNull();
expect(ret!.seasons).toBeArrayOfSize(2);
expect(ret!.seasons[0].translations[0].name).toBe("Season'1");
expect(ret!.seasons[1].translations[0].name).toBe('Season"2');
expect(ret!.entries).toBeArrayOfSize(
madeInAbyss.entries.length + madeInAbyss.extras.length,
);
});
}); });

View File

@@ -1,9 +1,12 @@
import { beforeAll } from "bun:test"; import { beforeAll } from "bun:test";
import { migrate } from "~/db";
process.env.PGDATABASE = "kyoo_test";
process.env.JWT_SECRET = "this is a secret"; process.env.JWT_SECRET = "this is a secret";
process.env.JWT_ISSUER = "https://kyoo.zoriya.dev"; process.env.JWT_ISSUER = "https://kyoo.zoriya.dev";
process.env.IMAGES_PATH = "./images";
beforeAll(async () => { beforeAll(async () => {
// lazy load this so env set before actually applies
const { migrate } = await import("~/db");
await migrate(); await migrate();
}); });

View File

@@ -591,4 +591,127 @@ describe("Video seeding", () => {
expect(vid!.evj[1].slug).toBe("made-in-abyss-s2e1"); expect(vid!.evj[1].slug).toBe("made-in-abyss-s2e1");
expect(vid!.evj[1].entry.slug).toBe("made-in-abyss-s2e1"); expect(vid!.evj[1].entry.slug).toBe("made-in-abyss-s2e1");
}); });
it("work with duplicated episodes", async () => {
await db.delete(videos);
const [resp, body] = await createVideo({
guess: {
title: "mia",
episodes: [
{ season: 1, episode: 13 },
{ season: 1, episode: 13 },
],
from: "test",
history: [],
},
part: null,
path: "/video/mia s1e13.mkv",
rendering: "duptest",
version: 1,
for: [
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
],
});
expectStatus(resp, body).toBe(201);
expect(body).toBeArrayOfSize(1);
expect(body[0].id).toBeString();
const vid = await db.query.videos.findFirst({
where: eq(videos.id, body[0].id),
with: {
evj: { with: { entry: true } },
},
});
expect(vid).not.toBeNil();
expect(vid!.path).toBe("/video/mia s1e13.mkv");
expect(vid!.guess).toMatchObject({ title: "mia", from: "test" });
expect(body[0].entries).toBeArrayOfSize(1);
expect(vid!.evj).toBeArrayOfSize(1);
expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13");
expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13");
});
it("work with duplicated two episodes", async () => {
await db.delete(videos);
const [resp, body] = await createVideo([
{
guess: {
title: "mia",
episodes: [
{ season: 1, episode: 13 },
{ season: 1, episode: 13 },
],
from: "test",
history: [],
},
part: null,
path: "/video/mia s1e13.mkv",
rendering: "duptest-two",
version: 1,
for: [
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
],
},
{
guess: {
title: "mia",
episodes: [
{ season: 1, episode: 13 },
{ season: 1, episode: 13 },
],
from: "test",
history: [],
},
part: null,
path: "/video/mia s1e13 bis.mkv",
rendering: "duptest-two-bis",
version: 1,
for: [
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
{ serie: madeInAbyss.slug, season: 1, episode: 13 },
],
},
]);
expectStatus(resp, body).toBe(201);
expect(body).toBeArrayOfSize(2);
expect(body[0].id).toBeString();
expect(body[1].id).toBeString();
expect(body[0].entries).toBeArrayOfSize(1);
expect(body[0].entries[0].slug).toBe("made-in-abyss-s1e13");
expect(body[1].entries).toBeArrayOfSize(1);
expect(body[1].entries[0].slug).toBe("made-in-abyss-s1e13-duptest-two-bis");
let vid = await db.query.videos.findFirst({
where: eq(videos.id, body[0].id),
with: {
evj: { with: { entry: true } },
},
});
expect(vid).not.toBeNil();
expect(vid!.path).toBe("/video/mia s1e13.mkv");
expect(vid!.guess).toMatchObject({ title: "mia", from: "test" });
expect(vid!.evj).toBeArrayOfSize(1);
expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13");
vid = await db.query.videos.findFirst({
where: eq(videos.id, body[1].id),
with: {
evj: { with: { entry: true } },
},
});
expect(vid).not.toBeNil();
expect(vid!.path).toBe("/video/mia s1e13 bis.mkv");
expect(vid!.guess).toMatchObject({ title: "mia", from: "test" });
expect(vid!.evj).toBeArrayOfSize(1);
expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-duptest-two-bis");
});
}); });

View File

@@ -1,9 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2021", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "node",
"types": ["bun-types"],
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,

View File

@@ -1,7 +1,7 @@
** **
!/go.mod !/go.mod
!/go.sum !/go.sum
!/**.go !/**/*.go
# generated via sqlc # generated via sqlc
!/sql !/sql
!/dbc !/dbc

View File

@@ -19,12 +19,15 @@ PROTECTED_CLAIMS="permissions"
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
PUBLIC_URL=http://localhost:8901 PUBLIC_URL=http://localhost:8901
# You can create apikeys at runtime via POST /apikey but you can also have some defined in the env. # You can create apikeys at runtime via POST /key but you can also have some defined in the env.
# Replace $YOURNAME with the name of the key you want (only alpha are valid) # Replace $YOURNAME with the name of the key you want (only alpha are valid)
# The value will be the apikey (max 128 bytes) # The value will be the apikey (max 128 bytes)
# KEIBI_APIKEY_$YOURNAME=oaeushtaoesunthoaensuth # KEIBI_APIKEY_$YOURNAME=oaeushtaoesunthoaensuth
# KEIBI_APIKEY_$YOURNAME_CLAIMS='{"permissions": ["users.read"]}' # KEIBI_APIKEY_$YOURNAME_CLAIMS='{"permissions": ["users.read"]}'
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
# Database things # Database things
# It is recommended to use the below PG environment variables when possible. # It is recommended to use the below PG environment variables when possible.
# POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key # POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key
@@ -43,8 +46,3 @@ PGPORT=5432
# PGSSLROOTCERT=/my/serving.crt # PGSSLROOTCERT=/my/serving.crt
# PGSSLCERT=/my/client.crt # PGSSLCERT=/my/client.crt
# PGSSLKEY=/my/client.key # PGSSLKEY=/my/client.key
# Default is keibi, you can specify "disabled" to use the default search_path of the user.
# If this is not "disabled", the schema will be created (if it does not exists) and
# the search_path of the user will be ignored (only the schema specified will be used).
POSTGRES_SCHEMA=keibi

View File

@@ -1,11 +1,11 @@
FROM golang:1.25 AS build FROM --platform=$BUILDPLATFORM golang:1.25 AS build
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /keibi RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=$TARGETARCH go build -o /keibi
FROM gcr.io/distroless/base-debian11 FROM gcr.io/distroless/base-debian11
WORKDIR /app WORKDIR /app

View File

@@ -60,8 +60,8 @@ GET `/users/$id/sessions` can be used by admins to list others session
### Api keys ### Api keys
``` ```
Get `/apikeys` Get `/keys`
Post `/apikeys` {...claims} Create a new api keys with given claims Post `/keys` {...claims} Create a new api keys with given claims
``` ```
An api key can be used like an opaque token, calling /jwt with it will return a valid jwt with the claims you specified during the post request to create it. An api key can be used like an opaque token, calling /jwt with it will return a valid jwt with the claims you specified during the post request to create it.

View File

@@ -4,10 +4,9 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt"
"maps" "maps"
"net/http" "net/http"
"strings" "slices"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -45,7 +44,7 @@ func MapDbKey(key *dbc.Apikey) ApiKeyWToken {
CreatedAt: key.CreatedAt, CreatedAt: key.CreatedAt,
LastUsed: key.LastUsed, LastUsed: key.LastUsed,
}, },
Token: fmt.Sprintf("%s-%s", key.Name, key.Token), Token: key.Token,
} }
} }
@@ -75,7 +74,10 @@ func (h *Handler) CreateApiKey(c echo.Context) error {
return err return err
} }
if _, conflict := h.config.EnvApiKeys[req.Name]; conflict { conflict := slices.ContainsFunc(h.config.EnvApiKeys, func(k ApiKeyWToken) bool {
return k.Name == req.Name
})
if conflict {
return echo.NewHTTPError(409, "An env apikey is already defined with the same name") return echo.NewHTTPError(409, "An env apikey is already defined with the same name")
} }
@@ -174,17 +176,15 @@ func (h *Handler) ListApiKey(c echo.Context) error {
} }
func (h *Handler) createApiJwt(apikey string) (string, error) { func (h *Handler) createApiJwt(apikey string) (string, error) {
info := strings.SplitN(apikey, "-", 2) var key *ApiKeyWToken
if len(info) != 2 { for _, k := range h.config.EnvApiKeys {
return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key format") if k.Token == apikey {
key = &k
break
} }
}
key, fromEnv := h.config.EnvApiKeys[info[0]] if key == nil {
if !fromEnv { dbKey, err := h.db.GetApiKey(context.Background(), apikey)
dbKey, err := h.db.GetApiKey(context.Background(), dbc.GetApiKeyParams{
Name: info[0],
Token: info[1],
})
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key") return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key")
} else if err != nil { } else if err != nil {
@@ -195,7 +195,8 @@ func (h *Handler) createApiJwt(apikey string) (string, error) {
h.db.TouchApiKey(context.Background(), dbKey.Pk) h.db.TouchApiKey(context.Background(), dbKey.Pk)
}() }()
key = MapDbKey(&dbKey) found := MapDbKey(&dbKey)
key = &found
} }
claims := maps.Clone(key.Claims) claims := maps.Clone(key.Claims)
@@ -210,6 +211,7 @@ func (h *Handler) createApiJwt(apikey string) (string, error) {
Time: time.Now().UTC().Add(time.Hour), Time: time.Now().UTC().Add(time.Hour),
} }
jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
jwt.Header["kid"] = h.config.JwtKid
t, err := jwt.SignedString(h.config.JwtPrivateKey) t, err := jwt.SignedString(h.config.JwtPrivateKey)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -12,6 +12,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
@@ -31,7 +32,7 @@ type Configuration struct {
GuestClaims jwt.MapClaims GuestClaims jwt.MapClaims
ProtectedClaims []string ProtectedClaims []string
ExpirationDelay time.Duration ExpirationDelay time.Duration
EnvApiKeys map[string]ApiKeyWToken EnvApiKeys []ApiKeyWToken
} }
var DefaultConfig = Configuration{ var DefaultConfig = Configuration{
@@ -39,7 +40,7 @@ var DefaultConfig = Configuration{
FirstUserClaims: make(jwt.MapClaims), FirstUserClaims: make(jwt.MapClaims),
ProtectedClaims: []string{"permissions"}, ProtectedClaims: []string{"permissions"},
ExpirationDelay: 30 * 24 * time.Hour, ExpirationDelay: 30 * 24 * time.Hour,
EnvApiKeys: make(map[string]ApiKeyWToken), EnvApiKeys: make([]ApiKeyWToken, 0),
} }
func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
@@ -137,14 +138,14 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
} }
name = strings.ToLower(name) name = strings.ToLower(name)
ret.EnvApiKeys[name] = ApiKeyWToken{ ret.EnvApiKeys = append(ret.EnvApiKeys, ApiKeyWToken{
ApiKey: ApiKey{ ApiKey: ApiKey{
Id: uuid.New(), Id: uuid.New(),
Name: name, Name: name,
Claims: claims, Claims: claims,
}, },
Token: v[1], Token: v[1],
} })
} }
apikeys, err := db.ListApiKeys(context.Background()) apikeys, err := db.ListApiKeys(context.Background())
@@ -152,7 +153,10 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
return nil, err return nil, err
} }
for _, key := range apikeys { for _, key := range apikeys {
if _, defined := ret.EnvApiKeys[key.Name]; defined { dup := slices.ContainsFunc(ret.EnvApiKeys, func(k ApiKeyWToken) bool {
return k.Name == key.Name
})
if dup {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"an api key with the name %s is already defined in database. Can't specify a new one via env var", "an api key with the name %s is already defined in database. Can't specify a new one via env var",
key.Name, key.Name,

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.30.0
// source: apikeys.sql // source: apikeys.sql
package dbc package dbc
@@ -13,7 +13,7 @@ import (
) )
const createApiKey = `-- name: CreateApiKey :one const createApiKey = `-- name: CreateApiKey :one
insert into apikeys(name, token, claims, created_by) insert into keibi.apikeys(name, token, claims, created_by)
values ($1, $2, $3, $4) values ($1, $2, $3, $4)
returning returning
pk, id, name, token, claims, created_by, created_at, last_used pk, id, name, token, claims, created_by, created_at, last_used
@@ -48,7 +48,7 @@ func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Api
} }
const deleteApiKey = `-- name: DeleteApiKey :one const deleteApiKey = `-- name: DeleteApiKey :one
delete from apikeys delete from keibi.apikeys
where id = $1 where id = $1
returning returning
pk, id, name, token, claims, created_by, created_at, last_used pk, id, name, token, claims, created_by, created_at, last_used
@@ -74,19 +74,13 @@ const getApiKey = `-- name: GetApiKey :one
select select
pk, id, name, token, claims, created_by, created_at, last_used pk, id, name, token, claims, created_by, created_at, last_used
from from
apikeys keibi.apikeys
where where
name = $1 token = $1
and token = $2
` `
type GetApiKeyParams struct { func (q *Queries) GetApiKey(ctx context.Context, token string) (Apikey, error) {
Name string `json:"name"` row := q.db.QueryRow(ctx, getApiKey, token)
Token string `json:"token"`
}
func (q *Queries) GetApiKey(ctx context.Context, arg GetApiKeyParams) (Apikey, error) {
row := q.db.QueryRow(ctx, getApiKey, arg.Name, arg.Token)
var i Apikey var i Apikey
err := row.Scan( err := row.Scan(
&i.Pk, &i.Pk,
@@ -105,7 +99,7 @@ const listApiKeys = `-- name: ListApiKeys :many
select select
pk, id, name, token, claims, created_by, created_at, last_used pk, id, name, token, claims, created_by, created_at, last_used
from from
apikeys keibi.apikeys
order by order by
last_used last_used
` `
@@ -141,7 +135,7 @@ func (q *Queries) ListApiKeys(ctx context.Context) ([]Apikey, error) {
const touchApiKey = `-- name: TouchApiKey :exec const touchApiKey = `-- name: TouchApiKey :exec
update update
apikeys keibi.apikeys
set set
last_used = now()::timestamptz last_used = now()::timestamptz
where where

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.30.0
package dbc package dbc

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.30.0
package dbc package dbc

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.30.0
// source: sessions.sql // source: sessions.sql
package dbc package dbc
@@ -13,7 +13,7 @@ import (
) )
const clearOtherSessions = `-- name: ClearOtherSessions :exec const clearOtherSessions = `-- name: ClearOtherSessions :exec
delete from sessions as s using users as u delete from keibi.sessions as s using keibi.users as u
where s.user_pk = u.pk where s.user_pk = u.pk
and s.id != $1 and s.id != $1
and u.id = $2 and u.id = $2
@@ -30,7 +30,7 @@ func (q *Queries) ClearOtherSessions(ctx context.Context, arg ClearOtherSessions
} }
const createSession = `-- name: CreateSession :one const createSession = `-- name: CreateSession :one
insert into sessions(token, user_pk, device) insert into keibi.sessions(token, user_pk, device)
values ($1, $2, $3) values ($1, $2, $3)
returning returning
pk, id, token, user_pk, created_date, last_used, device pk, id, token, user_pk, created_date, last_used, device
@@ -58,7 +58,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
} }
const deleteSession = `-- name: DeleteSession :one const deleteSession = `-- name: DeleteSession :one
delete from sessions as s using users as u delete from keibi.sessions as s using keibi.users as u
where s.user_pk = u.pk where s.user_pk = u.pk
and s.id = $1 and s.id = $1
and u.id = $2 and u.id = $2
@@ -93,8 +93,8 @@ select
s.last_used, s.last_used,
u.pk, u.id, u.username, u.email, u.password, u.claims, u.created_date, u.last_seen u.pk, u.id, u.username, u.email, u.password, u.claims, u.created_date, u.last_seen
from from
users as u keibi.users as u
inner join sessions as s on u.pk = s.user_pk inner join keibi.sessions as s on u.pk = s.user_pk
where where
s.token = $1 s.token = $1
limit 1 limit 1
@@ -130,8 +130,8 @@ const getUserSessions = `-- name: GetUserSessions :many
select select
s.pk, s.id, s.token, s.user_pk, s.created_date, s.last_used, s.device s.pk, s.id, s.token, s.user_pk, s.created_date, s.last_used, s.device
from from
sessions as s keibi.sessions as s
inner join users as u on u.pk = s.user_pk inner join keibi.users as u on u.pk = s.user_pk
where where
u.pk = $1 u.pk = $1
order by order by
@@ -168,7 +168,7 @@ func (q *Queries) GetUserSessions(ctx context.Context, pk int32) ([]Session, err
const touchSession = `-- name: TouchSession :exec const touchSession = `-- name: TouchSession :exec
update update
sessions keibi.sessions
set set
last_used = now()::timestamptz last_used = now()::timestamptz
where where

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.30.0
// source: users.sql // source: users.sql
package dbc package dbc
@@ -13,12 +13,12 @@ import (
) )
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
insert into users(username, email, password, claims) insert into keibi.users(username, email, password, claims)
values ($1, $2, $3, case when not exists ( values ($1, $2, $3, case when not exists (
select select
pk, id, username, email, password, claims, created_date, last_seen pk, id, username, email, password, claims, created_date, last_seen
from from
users) then keibi.users) then
$4::jsonb $4::jsonb
else else
$5::jsonb $5::jsonb
@@ -58,7 +58,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
} }
const deleteUser = `-- name: DeleteUser :one const deleteUser = `-- name: DeleteUser :one
delete from users delete from keibi.users
where id = $1 where id = $1
returning returning
pk, id, username, email, password, claims, created_date, last_seen pk, id, username, email, password, claims, created_date, last_seen
@@ -84,7 +84,7 @@ const getAllUsers = `-- name: GetAllUsers :many
select select
pk, id, username, email, password, claims, created_date, last_seen pk, id, username, email, password, claims, created_date, last_seen
from from
users keibi.users
order by order by
id id
limit $1 limit $1
@@ -123,7 +123,7 @@ const getAllUsersAfter = `-- name: GetAllUsersAfter :many
select select
pk, id, username, email, password, claims, created_date, last_seen pk, id, username, email, password, claims, created_date, last_seen
from from
users keibi.users
where where
id >= $2 id >= $2
order by order by
@@ -173,8 +173,8 @@ select
h.username, h.username,
h.profile_url h.profile_url
from from
users as u keibi.users as u
left join oidc_handle as h on u.pk = h.user_pk left join keibi.oidc_handle as h on u.pk = h.user_pk
where ($1::boolean where ($1::boolean
and u.id = $2) and u.id = $2)
or (not $1 or (not $1
@@ -232,7 +232,7 @@ const getUserByLogin = `-- name: GetUserByLogin :one
select select
pk, id, username, email, password, claims, created_date, last_seen pk, id, username, email, password, claims, created_date, last_seen
from from
users keibi.users
where where
email = $1 email = $1
or username = $1 or username = $1
@@ -257,9 +257,9 @@ func (q *Queries) GetUserByLogin(ctx context.Context, login string) (User, error
const touchUser = `-- name: TouchUser :exec const touchUser = `-- name: TouchUser :exec
update update
users keibi.users
set set
last_used = now()::timestamptz last_seen = now()::timestamptz
where where
pk = $1 pk = $1
` `
@@ -271,12 +271,12 @@ func (q *Queries) TouchUser(ctx context.Context, pk int32) error {
const updateUser = `-- name: UpdateUser :one const updateUser = `-- name: UpdateUser :one
update update
users keibi.users
set set
username = coalesce($2, username), username = coalesce($2, username),
email = coalesce($3, email), email = coalesce($3, email),
password = coalesce($4, password), password = coalesce($4, password),
claims = coalesce($5, claims) claims = claims || coalesce($5, '{}'::jsonb)
where where
id = $1 id = $1
returning returning

18
auth/devspace.yaml Normal file
View File

@@ -0,0 +1,18 @@
version: v2beta1
name: auth
dev:
auth:
imageSelector: ghcr.io/zoriya/kyoo_auth
devImage: docker.io/golang:1.25
workingDir: /app
sync:
- path: .:/app
startContainer: true
onUpload:
restartContainer: true
command:
- bash
- -c
- "go mod download; go run -race ."
ports:
- port: "4568"

View File

@@ -1,36 +1,61 @@
module github.com/zoriya/kyoo/keibi module github.com/zoriya/kyoo/keibi
go 1.23.3 go 1.24.0
toolchain go1.25.1 toolchain go1.25.5
require ( require (
github.com/alexedwards/argon2id v1.0.0 github.com/alexedwards/argon2id v1.0.0
github.com/exaring/otelpgx v0.9.4
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/labstack/echo-jwt/v4 v4.3.1 github.com/labstack/echo-jwt/v4 v4.4.0
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/lestrrat-go/jwx/v3 v3.0.10 github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/swaggo/echo-swagger v1.4.1 github.com/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/log v0.15.0
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
) )
require ( require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0 // indirect github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/mod v0.25.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/mod v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
) )
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ghodss/yaml v1.0.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect
@@ -38,10 +63,8 @@ require (
github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.29.0
github.com/golang-migrate/migrate/v4 v4.19.0 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -55,13 +78,16 @@ require (
github.com/swaggo/files/v2 v2.0.2 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
golang.org/x/net v0.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0
golang.org/x/sync v0.16.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
golang.org/x/sys v0.34.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/text v0.27.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/tools v0.34.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -6,13 +6,17 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
@@ -25,12 +29,15 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/exaring/otelpgx v0.9.4 h1:V0XdEPXAaeBteeL8WbEPLWVCwKh3Be2aVX7/vCBpli4=
github.com/exaring/otelpgx v0.9.4/go.mod h1:R5/M5LWsPPBZc1SrRE5e0DiU48bI78C1/GPTWs6I66U=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -49,25 +56,24 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -84,8 +90,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= github.com/labstack/echo-jwt/v4 v4.4.0 h1:nrXaEnJupfc2R4XChcLRDyghhMZup77F8nIzHnBK19U=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= github.com/labstack/echo-jwt/v4 v4.4.0/go.mod h1:kYXWgWms9iFqI3ldR+HAEj/Zfg5rZtR7ePOgktG4Hjg=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -94,12 +100,16 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc= github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q= github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw= github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk= github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
@@ -124,19 +134,20 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
@@ -150,37 +161,71 @@ github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLr
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0 h1:9PCiXc7BmfD7+BI8POoc3bQSoRSEo01eNqPVu1/+pDY=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0/go.mod h1:NGBbj2Bgb5Oe/35f9WaU3qRnOey+7X+bxnnSS5zzvLA=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -190,8 +235,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -203,17 +248,27 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -34,19 +34,30 @@ func (h *Handler) CreateJwt(c echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
c.Response().Header().Add("Authorization", fmt.Sprintf("Bearer %s", token))
return c.JSON(http.StatusOK, Jwt{ return c.JSON(http.StatusOK, Jwt{
Token: &token, Token: &token,
}) })
} }
auth := c.Request().Header.Get("Authorization") auth := c.Request().Header.Get("Authorization")
var jwt *string var token string
if !strings.HasPrefix(auth, "Bearer ") { if auth == "" {
c, _ := c.Request().Cookie("X-Bearer")
if c != nil {
token = c.Value
}
} else if strings.HasPrefix(auth, "Bearer ") {
token = auth[len("Bearer "):]
} else if auth != "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid bearer format.")
}
var jwt *string
if token == "" {
jwt = h.createGuestJwt() jwt = h.createGuestJwt()
} else { } else {
token := auth[len("Bearer "):]
tkn, err := h.createJwt(token) tkn, err := h.createJwt(token)
if err != nil { if err != nil {
return err return err

View File

@@ -5,9 +5,12 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/user" "os/user"
"slices"
"sort"
"strings" "strings"
"github.com/zoriya/kyoo/keibi/dbc" "github.com/zoriya/kyoo/keibi/dbc"
@@ -19,10 +22,12 @@ import (
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
"github.com/labstack/echo-jwt/v4" echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/swaggo/echo-swagger" echoSwagger "github.com/swaggo/echo-swagger"
"github.com/exaring/otelpgx"
) )
func ErrorHandler(err error, c echo.Context) { func ErrorHandler(err error, c echo.Context) {
@@ -59,7 +64,27 @@ func (v *Validator) Validate(i any) error {
} }
func (h *Handler) CheckHealth(c echo.Context) error { func (h *Handler) CheckHealth(c echo.Context) error {
return c.JSON(200, struct{ Status string }{Status: "healthy"}) return c.JSON(200, struct {
Status string `json:"status"`
}{Status: "healthy"})
}
func (h *Handler) CheckReady(c echo.Context) error {
_, err := h.rawDb.Exec(c.Request().Context(), "select 1")
status := "healthy"
db := "healthy"
ret := 200
if err != nil {
status = "unhealthy"
db = err.Error()
ret = 500
}
return c.JSON(ret, struct {
Status string `json:"status"`
Database string `json:"database"`
}{Status: status, Database: db})
} }
func GetenvOr(env string, def string) string { func GetenvOr(env string, def string) string {
@@ -106,10 +131,11 @@ func OpenDatabase() (*pgxpool.Pool, error) {
config.ConnConfig.RuntimeParams["application_name"] = "keibi" config.ConnConfig.RuntimeParams["application_name"] = "keibi"
} }
schema := GetenvOr("POSTGRES_SCHEMA", "keibi") config.ConnConfig.Tracer = otelpgx.NewTracer(
if _, ok := config.ConnConfig.RuntimeParams["search_path"]; !ok { otelpgx.WithSpanNameFunc(dbGetSpanName),
config.ConnConfig.RuntimeParams["search_path"] = schema otelpgx.WithDisableQuerySpanNamePrefix(),
} otelpgx.WithIncludeQueryParameters(),
)
db, err := pgxpool.NewWithConfig(ctx, config) db, err := pgxpool.NewWithConfig(ctx, config)
if err != nil { if err != nil {
@@ -117,18 +143,14 @@ func OpenDatabase() (*pgxpool.Pool, error) {
return nil, err return nil, err
} }
if schema != "disabled" { slog.Info("Database migration state", "state", "starting")
_, err = db.Exec(ctx, fmt.Sprintf("create schema if not exists %s", schema))
if err != nil {
return nil, err
}
}
fmt.Println("Migrating database")
dbi := stdlib.OpenDBFromPool(db) dbi := stdlib.OpenDBFromPool(db)
defer dbi.Close() defer dbi.Close()
driver, err := pgxd.WithInstance(dbi, &pgxd.Config{}) dbi.Exec("create schema if not exists keibi")
driver, err := pgxd.WithInstance(dbi, &pgxd.Config{
SchemaName: "keibi",
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -137,13 +159,14 @@ func OpenDatabase() (*pgxpool.Pool, error) {
return nil, err return nil, err
} }
m.Up() m.Up()
fmt.Println("Migrating finished") slog.Info("Database migration state", "state", "completed")
return db, nil return db, nil
} }
type Handler struct { type Handler struct {
db *dbc.Queries db *dbc.Queries
rawDb *pgxpool.Pool
config *Configuration config *Configuration
} }
@@ -207,8 +230,70 @@ func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc {
// @in header // @in header
// @name Authorization // @name Authorization
func main() { func main() {
ctx := context.Background()
logger, _, err := SetupLogger(ctx)
if err != nil {
slog.Error("logger init", "err", err)
}
cleanup, err := setupOtel(ctx)
if err != nil {
slog.Error("Failed to setup otel: ", "err", err)
return
}
defer cleanup(ctx)
e := echo.New() e := echo.New()
e.Use(middleware.Logger()) e.HideBanner = true
e.Logger.SetOutput(logger)
instrument(e)
ignorepath := []string{
"/.well-known/jwks.json",
"/auth/health",
"/auth/ready",
}
slog.Info("Skipping request logging for these paths", "paths", func() string { sort.Strings(ignorepath); return strings.Join(ignorepath, ",") }())
// using example from https://echo.labstack.com/docs/middleware/logger#examples
// full configs https://github.com/labstack/echo/blob/master/middleware/request_logger.go
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
// declare a small set of paths to ignore
Skipper: func(c echo.Context) bool {
p := c.Request().URL.Path
return slices.Contains(ignorepath, p)
},
LogStatus: true,
LogURI: true,
LogError: true,
LogHost: true,
LogMethod: true,
LogUserAgent: true,
HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
if v.Error == nil {
logger.LogAttrs(ctx, slog.LevelInfo, "web_request",
slog.String("method", v.Method),
slog.Int("status", v.Status),
slog.String("host", v.Host),
slog.String("uri", v.URI),
slog.String("agent", v.UserAgent),
)
} else {
logger.LogAttrs(ctx, slog.LevelError, "web_request_error",
slog.String("method", v.Method),
slog.Int("status", v.Status),
slog.String("host", v.Host),
slog.String("uri", v.URI),
slog.String("agent", v.UserAgent),
slog.String("err", v.Error.Error()),
)
}
return nil
},
}))
e.Validator = &Validator{validator: validator.New(validator.WithRequiredStructEnabled())} e.Validator = &Validator{validator: validator.New(validator.WithRequiredStructEnabled())}
e.HTTPErrorHandler = ErrorHandler e.HTTPErrorHandler = ErrorHandler
@@ -220,6 +305,7 @@ func main() {
h := Handler{ h := Handler{
db: dbc.New(db), db: dbc.New(db),
rawDb: db,
} }
conf, err := LoadConfiguration(h.db) conf, err := LoadConfiguration(h.db)
if err != nil { if err != nil {
@@ -237,6 +323,7 @@ func main() {
})) }))
g.GET("/health", h.CheckHealth) g.GET("/health", h.CheckHealth)
g.GET("/ready", h.CheckReady)
r.GET("/users", h.ListUsers) r.GET("/users", h.ListUsers)
r.GET("/users/:id", h.GetUser) r.GET("/users/:id", h.GetUser)
@@ -257,6 +344,7 @@ func main() {
r.DELETE("/keys/:id", h.DeleteApiKey) r.DELETE("/keys/:id", h.DeleteApiKey)
g.GET("/jwt", h.CreateJwt) g.GET("/jwt", h.CreateJwt)
g.Any("/jwt/*", h.CreateJwt)
e.GET("/.well-known/jwks.json", h.GetJwks) e.GET("/.well-known/jwks.json", h.GetJwks)
e.GET("/.well-known/openid-configuration", h.GetOidcConfig) e.GET("/.well-known/openid-configuration", h.GetOidcConfig)

224
auth/otel.go Normal file
View File

@@ -0,0 +1,224 @@
package main
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
logotel "go.opentelemetry.io/otel/log"
logotelglobal "go.opentelemetry.io/otel/log/global"
logotelnoop "go.opentelemetry.io/otel/log/noop"
metricotel "go.opentelemetry.io/otel/metric"
metricotelnoop "go.opentelemetry.io/otel/metric/noop"
logsdk "go.opentelemetry.io/otel/sdk/log"
metricsdk "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
tracesdk "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
traceotel "go.opentelemetry.io/otel/trace"
traceotelnoop "go.opentelemetry.io/otel/trace/noop"
)
func setupOtel(ctx context.Context) (func(context.Context) error, error) {
res, err := resource.New(
ctx,
resource.WithAttributes(semconv.ServiceNameKey.String("kyoo.auth")),
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithProcess(),
resource.WithOS(),
resource.WithContainer(),
resource.WithHost(),
)
if err != nil {
return nil, err
}
slog.Info("Configuring OTEL")
var le logsdk.Exporter
var me metricsdk.Exporter
var te tracesdk.SpanExporter
switch {
case strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) == "":
slog.Info("Using OLTP type", "type", "noop")
le = nil
me = nil
te = nil
case strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))) == "grpc":
slog.Info("Using OLTP type", "type", "grpc")
le, err = otlploggrpc.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
me, err = otlpmetricgrpc.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
te, err = otlptracegrpc.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
default:
slog.Info("Using OLTP type", "type", "http")
le, err = otlploghttp.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
me, err = otlpmetrichttp.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
te, err = otlptracehttp.New(ctx)
if err != nil {
slog.Error("Failed setting up OLTP: ", err)
return nil, err
}
}
if err != nil {
return nil, err
}
// default to noop providers
var lp logotel.LoggerProvider = logotelnoop.NewLoggerProvider()
var mp metricotel.MeterProvider = metricotelnoop.NewMeterProvider()
var tp traceotel.TracerProvider = traceotelnoop.NewTracerProvider()
// use exporter if configured
if le != nil {
lp = logsdk.NewLoggerProvider(
logsdk.WithProcessor(logsdk.NewBatchProcessor(le)),
logsdk.WithResource(res),
)
}
if me != nil {
mp = metricsdk.NewMeterProvider(
metricsdk.WithReader(
metricsdk.NewPeriodicReader(me),
),
metricsdk.WithResource(res),
)
}
if te != nil {
tp = tracesdk.NewTracerProvider(
tracesdk.WithBatcher(te),
tracesdk.WithResource(res),
)
}
// set providers
logotelglobal.SetLoggerProvider(lp)
otel.SetMeterProvider(mp)
otel.SetTracerProvider(tp)
// configure shutting down
// noop providers do not have a Shudown method
log_shutdown := func(ctx context.Context) error {
if otelprovider, ok := lp.(*logsdk.LoggerProvider); ok && otelprovider != nil {
return otelprovider.Shutdown(ctx)
}
return nil
}
metric_shutdown := func(ctx context.Context) error {
if otelprovider, ok := mp.(*metricsdk.MeterProvider); ok && otelprovider != nil {
return otelprovider.Shutdown(ctx)
}
return nil
}
trace_shutdown := func(ctx context.Context) error {
if otelprovider, ok := tp.(*tracesdk.TracerProvider); ok && otelprovider != nil {
return otelprovider.Shutdown(ctx)
}
return nil
}
return func(ctx context.Context) error {
slog.Info("Shutting down OTEL")
// run shutdowns and collect errors
var errs []error
if err := trace_shutdown(ctx); err != nil {
errs = append(errs, err)
}
if err := metric_shutdown(ctx); err != nil {
errs = append(errs, err)
}
if err := log_shutdown(ctx); err != nil {
errs = append(errs, err)
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}, nil
}
func instrument(e *echo.Echo) {
e.Use(otelecho.Middleware("kyoo.auth", otelecho.WithSkipper(func(c echo.Context) bool {
return (c.Path() == "/auth/health" ||
c.Path() == "/auth/ready" ||
strings.HasPrefix(c.Path(), "/.well-known/"))
})))
}
// stolen from https://github.com/exaring/otelpgx/issues/47
func dbGetSpanName(sql string) string {
if len(sql) >= 10 && sql[0:9] == "-- name: " {
// -- name: {name} :{type}
if index := strings.Index(sql[9:], ":"); index != -1 {
// remove leading space before :
// optimised assuming this comment has been generated by SQLC
return sql[9 : 9+(index-1)]
}
// -- name: {name}
if index := strings.Index(sql[9:], "\n"); index != -1 {
if sql[len(sql[9:9+index])-1] != ' ' {
return sql[9 : 9+index]
}
return strings.TrimSpace(sql[9 : 9+index])
}
}
if len(sql) >= 9 && sql[0:8] == "-- name:" {
// -- name:{name}
if index := strings.Index(sql[8:], "\n"); index != -1 {
if sql[len(sql[8:8+index])-1] != ' ' {
return sql[8 : 8+index]
}
return strings.TrimSpace(sql[8 : 8+index])
}
}
if len(sql) >= 8 && sql[0:7] == "--name:" {
// -- name:{name}
if index := strings.Index(sql[7:], "\n"); index != -1 {
if sql[len(sql[7:7+index])-1] != ' ' {
return sql[7 : 7+index]
}
return strings.TrimSpace(sql[7 : 7+index])
}
}
return sql
}

Some files were not shown because too many files have changed in this diff Show More