diff --git a/.circleci/config.yml b/.circleci/config.yml index 88b45d65..81abf315 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,44 +21,44 @@ commands: build-scan-docker: steps: - - run: - name: Build API Image - command: docker build -f ./docker/api.Dockerfile -t ghcr.io/scuffletv/api:$(git rev-parse HEAD) . - - run: name: Install Dependencies command: apk add --no-cache curl jq wget tar gzip ca-certificates + - run: + name: Build Platform API Image + command: docker build -f ./docker/platform/api.Dockerfile -t ghcr.io/scuffletv/platform/api:$(git rev-parse HEAD) . + - trivy/scan: - args: image ghcr.io/scuffletv/api:$(git rev-parse HEAD) + args: image ghcr.io/scuffletv/platform/api:$(git rev-parse HEAD) - run: - name: Build Edge Image - command: docker build -f ./docker/edge.Dockerfile -t ghcr.io/scuffletv/edge:$(git rev-parse HEAD) . + name: Build Platform Website Image + command: docker build -f ./docker/platform/website.Dockerfile -t ghcr.io/scuffletv/platform/website:$(git rev-parse HEAD) . - trivy/scan: - args: image ghcr.io/scuffletv/edge:$(git rev-parse HEAD) + args: image ghcr.io/scuffletv/platform/website:$(git rev-parse HEAD) - run: - name: Build Ingest Image - command: docker build -f ./docker/ingest.Dockerfile -t ghcr.io/scuffletv/ingest:$(git rev-parse HEAD) . + name: Build Video Edge Image + command: docker build -f ./docker/video/edge.Dockerfile -t ghcr.io/scuffletv/video/edge:$(git rev-parse HEAD) . - trivy/scan: - args: image ghcr.io/scuffletv/ingest:$(git rev-parse HEAD) + args: image ghcr.io/scuffletv/video/edge:$(git rev-parse HEAD) - run: - name: Build Transcoder Image - command: docker build -f ./docker/transcoder.Dockerfile -t ghcr.io/scuffletv/transcoder:$(git rev-parse HEAD) . + name: Build Video Ingest Image + command: docker build -f ./docker/video/ingest.Dockerfile -t ghcr.io/scuffletv/video/ingest:$(git rev-parse HEAD) . - trivy/scan: - args: image --severity CRITICAL,HIGH ghcr.io/scuffletv/transcoder:$(git rev-parse HEAD) + args: image ghcr.io/scuffletv/video/ingest:$(git rev-parse HEAD) - run: - name: Build Website Image - command: docker build -f ./docker/website.Dockerfile -t ghcr.io/scuffletv/website:$(git rev-parse HEAD) . + name: Build Video Transcoder Image + command: docker build -f ./docker/video/transcoder.Dockerfile -t ghcr.io/scuffletv/video/transcoder:$(git rev-parse HEAD) . - trivy/scan: - args: image ghcr.io/scuffletv/website:$(git rev-parse HEAD) + args: image ghcr.io/scuffletv/video/transcoder:$(git rev-parse HEAD) jobs: lint-test: @@ -90,16 +90,10 @@ jobs: - checkout - - run: - name: Combine Cargo.lock Files - command: | - sha256sum Cargo.lock >> locksum - sha256sum frontend/player/Cargo.lock >> locksum - - restore_cache: name: Restore Rust Lint/Test Cache keys: - - &lint-test-cache lint-test-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "locksum" }} + - &lint-test-cache lint-test-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "Cargo.lock" }} - lint-test-cache-{{ .Environment.CACHE_VERSION }}- - run: @@ -121,7 +115,6 @@ jobs: name: Cargo Sweep Start command: | cargo sweep -s - cargo sweep -s frontend/player - run: name: Run Lint @@ -140,6 +133,7 @@ jobs: --status-level all \ --profile ci \ --tests \ + --exclude video-player \ --config "profile.dev.debug = 0" - codecov/upload: @@ -157,14 +151,12 @@ jobs: name: Cargo Sweep Finish command: | cargo sweep -f - cargo sweep -f frontend/player - save_cache: name: Save Test Cache key: *lint-test-cache paths: - target - - frontend/player/target build: resource_class: large @@ -181,23 +173,16 @@ jobs: name: Install dependencies command: mask bootstrap --no-db --no-docker --no-env --no-js-tests --no-rust - - run: - name: Combine Cargo.lock Files - command: | - sha256sum Cargo.lock >> locksum - sha256sum frontend/player/Cargo.lock >> locksum - - restore_cache: name: Restore Rust Build Cache keys: - - &build-cache build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "locksum" }} + - &build-cache build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "Cargo.lock" }} - build-cache-{{ .Environment.CACHE_VERSION }}- - run: name: Cargo Sweep Start command: | cargo sweep -s - cargo sweep -s frontend/player - run: name: Build Rust @@ -211,67 +196,65 @@ jobs: name: Cargo Sweep Finish command: | cargo sweep -f - cargo sweep -f frontend/player - save_cache: name: Save Build Cache key: *build-cache paths: - target - - frontend/player/target - store_artifacts: - path: target/x86_64-unknown-linux-gnu/release/api - destination: api + path: target/x86_64-unknown-linux-gnu/release/platform-api + destination: platform-api - store_artifacts: - path: target/x86_64-unknown-linux-gnu/release/edge - destination: edge + path: target/x86_64-unknown-linux-gnu/release/video-edge + destination: video-edge - store_artifacts: - path: target/x86_64-unknown-linux-gnu/release/ingest - destination: ingest + path: target/x86_64-unknown-linux-gnu/release/video-ingest + destination: video-ingest - store_artifacts: - path: target/x86_64-unknown-linux-gnu/release/transcoder - destination: transcoder + path: target/x86_64-unknown-linux-gnu/release/video-transcoder + destination: video-transcoder - run: name: Compress Website Build - command: tar -czf website.tar.gz -C frontend/website build --transform s/build/website/ + command: tar -czf website.tar.gz -C platform/website build --transform s/build/website/ - store_artifacts: path: website.tar.gz - run: name: Compress Player Build - command: tar -czf player.tar.gz -C frontend/player dist --transform s/dist/player/ + command: tar -czf player.tar.gz -C video/player dist --transform s/dist/player/ - store_artifacts: path: player.tar.gz - run: name: Compress Player Package - command: tar -czf player-pkg.tar.gz -C frontend/player pkg --transform s/pkg/player-pkg/ + command: tar -czf player-pkg.tar.gz -C video/player pkg --transform s/pkg/player-pkg/ - store_artifacts: path: player-pkg.tar.gz - #- run: - # name: Compress Player Demo - # command: tar -czf player-demo.tar.gz -C frontend/player demo-dist --transform s/demo-dist/player-demo/ + - run: + name: Compress Player Demo + command: tar -czf player-demo.tar.gz -C video/player demo-dist --transform s/demo-dist/player-demo/ - #- store_artifacts: - # path: player-demo.tar.gz + - store_artifacts: + path: player-demo.tar.gz - persist_to_workspace: root: . paths: - - target/x86_64-unknown-linux-gnu/release/api - - target/x86_64-unknown-linux-gnu/release/edge - - target/x86_64-unknown-linux-gnu/release/ingest - - target/x86_64-unknown-linux-gnu/release/transcoder - - frontend/website/build + - target/x86_64-unknown-linux-gnu/release/platform-api + - target/x86_64-unknown-linux-gnu/release/video-edge + - target/x86_64-unknown-linux-gnu/release/video-ingest + - target/x86_64-unknown-linux-gnu/release/video-transcoder + - platform/website/build docker: resource_class: medium @@ -336,26 +319,26 @@ jobs: echo "Tagging images with $TAG" - docker tag ghcr.io/scuffletv/api:$(git rev-parse HEAD) ghcr.io/scuffletv/api:$TAG - docker tag ghcr.io/scuffletv/edge:$(git rev-parse HEAD) ghcr.io/scuffletv/edge:$TAG - docker tag ghcr.io/scuffletv/ingest:$(git rev-parse HEAD) ghcr.io/scuffletv/ingest:$TAG - docker tag ghcr.io/scuffletv/transcoder:$(git rev-parse HEAD) ghcr.io/scuffletv/transcoder:$TAG - docker tag ghcr.io/scuffletv/website:$(git rev-parse HEAD) ghcr.io/scuffletv/website:$TAG + docker tag ghcr.io/scuffletv/platform/api:$(git rev-parse HEAD) ghcr.io/scuffletv/platform/api:$TAG + docker tag ghcr.io/scuffletv/platform/website:$(git rev-parse HEAD) ghcr.io/scuffletv/platform/website:$TAG + docker tag ghcr.io/scuffletv/video/edge:$(git rev-parse HEAD) ghcr.io/scuffletv/video/edge:$TAG + docker tag ghcr.io/scuffletv/video/ingest:$(git rev-parse HEAD) ghcr.io/scuffletv/video/ingest:$TAG + docker tag ghcr.io/scuffletv/video/transcoder:$(git rev-parse HEAD) ghcr.io/scuffletv/video/transcoder:$TAG - docker push ghcr.io/scuffletv/api:$(git rev-parse HEAD) - docker push ghcr.io/scuffletv/api:$TAG + docker push ghcr.io/scuffletv/platform/api:$(git rev-parse HEAD) + docker push ghcr.io/scuffletv/platform/api:$TAG - docker push ghcr.io/scuffletv/edge:$(git rev-parse HEAD) - docker push ghcr.io/scuffletv/edge:$TAG + docker push ghcr.io/scuffletv/platform/website:$(git rev-parse HEAD) + docker push ghcr.io/scuffletv/platform/website:$TAG - docker push ghcr.io/scuffletv/ingest:$(git rev-parse HEAD) - docker push ghcr.io/scuffletv/ingest:$TAG + docker push ghcr.io/scuffletv/video/edge:$(git rev-parse HEAD) + docker push ghcr.io/scuffletv/video/edge:$TAG - docker push ghcr.io/scuffletv/transcoder:$(git rev-parse HEAD) - docker push ghcr.io/scuffletv/transcoder:$TAG + docker push ghcr.io/scuffletv/video/ingest:$(git rev-parse HEAD) + docker push ghcr.io/scuffletv/video/ingest:$TAG - docker push ghcr.io/scuffletv/website:$(git rev-parse HEAD) - docker push ghcr.io/scuffletv/website:$TAG + docker push ghcr.io/scuffletv/video/transcoder:$(git rev-parse HEAD) + docker push ghcr.io/scuffletv/video/transcoder:$TAG workflows: lint-test-build: diff --git a/.prettierignore b/.prettierignore index b74331c0..aa2cc058 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ -frontend/website/**/* -**/target/**/* -**/pkg/**/* -terraform/.terraform/**/* +video/player +platform/website +**/target +**/pkg +terraform/.terraform **/pnpm-lock.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f145310a..60b53fe2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,7 +1,9 @@ * @ScuffleTV/maintainers -frontend/ @ScuffleTV/frontend @ScuffleTV/maintainers +platform/website @ScuffleTV/frontend @ScuffleTV/maintainers video/ @ScuffleTV/video @ScuffleTV/maintainers -backend/ @ScuffleTV/backend @ScuffleTV/maintainers +video/player @ScuffleTV/video @ScuffleTV/frontend @ScuffleTV/maintainers + +platform/backend @ScuffleTV/backend @ScuffleTV/maintainers diff --git a/Cargo.lock b/Cargo.lock index c22dd934..7554d8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "aac" -version = "0.1.0" +version = "0.0.1" dependencies = [ "byteorder", "bytes", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" dependencies = [ "memchr", ] @@ -78,7 +78,7 @@ checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" [[package]] name = "amf0" -version = "0.1.0" +version = "0.0.1" dependencies = [ "byteorder", "bytes", @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c" dependencies = [ "anstyle", "windows-sys", @@ -205,52 +205,6 @@ version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" -[[package]] -name = "api" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "argon2", - "async-graphql", - "async-stream", - "bitmask-enum", - "chrono", - "common", - "config", - "dotenvy", - "email_address", - "fred", - "futures", - "futures-util", - "hmac", - "http", - "hyper", - "hyper-tungstenite", - "jwt", - "lapin", - "negative-impl", - "portpicker", - "prost", - "prost-build", - "rand", - "reqwest", - "routerify", - "serde", - "serde_json", - "serial_test", - "sha2", - "sqlx", - "tempfile", - "tokio", - "tokio-stream", - "tokio-tungstenite", - "tonic", - "tonic-build", - "tracing 0.1.37", - "uuid", -] - [[package]] name = "arc-swap" version = "1.6.0" @@ -301,7 +255,7 @@ dependencies = [ "async-lock", "async-task", "concurrent-queue", - "fastrand", + "fastrand 1.9.0", "futures-lite", "slab", ] @@ -334,9 +288,9 @@ dependencies = [ [[package]] name = "async-graphql" -version = "5.0.10" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35ef8f9be23ee30fe1eb1cf175c689bc33517c6c6d0fd0669dade611e5ced7f" +checksum = "0def00150a38be3267a3796b9c7feda20262dc879f9ae9d48464b24813da2b69" dependencies = [ "async-graphql-derive", "async-graphql-parser", @@ -353,7 +307,7 @@ dependencies = [ "futures-util", "handlebars", "http", - "indexmap 1.9.3", + "indexmap 2.0.0", "lru", "mime", "multer", @@ -365,7 +319,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sha2", + "sha2 0.10.7", "static_assertions", "tempfile", "thiserror", @@ -376,9 +330,9 @@ dependencies = [ [[package]] name = "async-graphql-derive" -version = "5.0.10" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0f6ceed3640b4825424da70a5107e79d48d9b2bc6318dfc666b2fc4777f8c4" +checksum = "8aea94ff9eaaf80d13e862754192de44c5d93eb660c6688bd0f43f1c441f67b8" dependencies = [ "Inflector", "async-graphql-parser", @@ -386,15 +340,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 1.0.109", + "strum", + "syn 2.0.28", "thiserror", ] [[package]] name = "async-graphql-parser" -version = "5.0.10" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc308cd3bc611ee86c9cf19182d2b5ee583da40761970e41207f088be3db18f" +checksum = "b898be948c43e00babf9154f5e92d12d011698f0171e2da9d2cfc2ffcfeaf28f" dependencies = [ "async-graphql-value", "pest", @@ -404,12 +359,12 @@ dependencies = [ [[package]] name = "async-graphql-value" -version = "5.0.10" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d461325bfb04058070712296601dfe5e5bd6cdff84780a0a8c569ffb15c87eb3" +checksum = "8afef4917e23f7e651074dbfca64d82f194b817f00bd74d9df05d5408eb83e1e" dependencies = [ "bytes", - "indexmap 1.9.3", + "indexmap 2.0.0", "serde", "serde_json", ] @@ -436,13 +391,47 @@ dependencies = [ [[package]] name = "async-lock" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" dependencies = [ "event-listener", ] +[[package]] +name = "async-nats" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257238e2a3629ee5618502a75d1b91f8017c24638c75349fc8d2d80cf1f7c4c" +dependencies = [ + "base64 0.21.2", + "bytes", + "futures", + "http", + "itoa", + "memchr", + "nkeys", + "nuid", + "once_cell", + "rand", + "regex", + "ring", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror", + "time", + "tokio", + "tokio-retry", + "tokio-rustls", + "tracing 0.1.37", + "url", +] + [[package]] name = "async-reactor-trait" version = "1.1.0" @@ -474,7 +463,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -485,13 +474,13 @@ checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" [[package]] name = "async-trait" -version = "0.1.71" +version = "0.1.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a564d521dd56509c4c47480d00b80ee55f7e385ae48db5744c67ad50c92d2ebf" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -517,7 +506,7 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "av1" -version = "0.1.0" +version = "0.0.1" dependencies = [ "byteorder", "bytes", @@ -526,9 +515,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", "axum-core", @@ -616,21 +605,21 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" dependencies = [ "serde", ] [[package]] name = "bitmask-enum" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78d456f91b4c1fdebf2698214e599fec3d7f8b46e3140fb254a9ea88c970ab0a" +checksum = "49fb8528abca6895a5ada33d62aedd538a5c33e77068256483b44a3230270163" dependencies = [ "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -639,7 +628,16 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", ] [[package]] @@ -670,7 +668,7 @@ dependencies = [ "async-lock", "async-task", "atomic-waker", - "fastrand", + "fastrand 1.9.0", "futures-lite", "log", ] @@ -745,9 +743,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.79" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "305fe645edc1442a0fa8b6726ba61d422798d37a52e12eaecf4b022ebbb88f01" +dependencies = [ + "libc", +] [[package]] name = "cfg-if" @@ -780,18 +781,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.3.12" +version = "4.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eab9e8ceb9afdade1ab3f0fd8dbce5b1b2f468ad653baf10e771781b2b67b73" +checksum = "c27cdf28c0f604ba3f512b0c9a409f8de8513e4816705deb0498b627e7c3a3fd" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.3.12" +version = "4.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2763db829349bf00cfc06251268865ed4363b93a943174f638daf3ecdba2cd" +checksum = "08a9f1ab5e9f01a9b81f202e8562eb9a10de70abf9eaeac1be465c28b75aa4aa" dependencies = [ "anstream", "anstyle", @@ -814,16 +815,14 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "common" -version = "0.1.0" +version = "0.0.1" dependencies = [ "anyhow", "arc-swap", - "async-stream", "async-trait", "config", "futures", "http", - "lapin", "log", "once_cell", "portpicker", @@ -836,9 +835,9 @@ dependencies = [ "tonic", "tonic-build", "tower", - "tracing 0.1.37", - "tracing-log", - "tracing-subscriber", + "tracing 0.2.0", + "tracing-log 0.2.0", + "tracing-subscriber 0.3.0", "trust-dns-resolver", ] @@ -853,7 +852,7 @@ dependencies = [ [[package]] name = "config" -version = "0.1.0" +version = "0.0.1" dependencies = [ "clap", "config_derive", @@ -869,15 +868,16 @@ dependencies = [ "thiserror", "toml", "tracing 0.1.37", + "uuid", ] [[package]] name = "config_derive" -version = "0.1.0" +version = "0.0.1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -888,11 +888,27 @@ dependencies = [ "serde", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" -version = "0.9.4" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" + +[[package]] +name = "const-oid" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "795bc6e66a8e340f075fcf6227e417a2dc976b92b91f3cdc778bb858778b6747" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" [[package]] name = "convert_case" @@ -1000,11 +1016,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + [[package]] name = "darling" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" dependencies = [ "darling_core", "darling_macro", @@ -1012,27 +1041,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] name = "darling_macro" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core", "quote", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] @@ -1056,15 +1085,33 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "der" -version = "0.7.7" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +dependencies = [ + "const-oid 0.6.2", +] + +[[package]] +name = "der" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7ed52955ce76b1554f509074bb357d3fb8ac9b51288a65a3fd480d1dfba946" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.5", + "pem-rfc7468 0.7.0", "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7684a49fb1af197853ef7b2ee694bc1f5b4179556f1e5710e1760c5db6f5e929" +dependencies = [ + "serde", +] + [[package]] name = "des" version = "0.8.1" @@ -1074,14 +1121,23 @@ dependencies = [ "cipher", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", + "block-buffer 0.10.4", + "const-oid 0.9.5", "crypto-common", "subtle", ] @@ -1099,49 +1155,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "edge" -version = "0.1.0" +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "bytes", - "chrono", - "common", - "config", - "dotenvy", - "fred", - "futures", - "futures-util", - "hyper", - "native-tls", - "nix", - "portpicker", - "prost", - "prost-build", - "routerify", - "serde", - "serde_json", - "serial_test", - "sha2", - "tempfile", - "tokio", - "tokio-native-tls", - "tokio-stream", - "tokio-util", - "tonic", - "tonic-build", - "tracing 0.1.37", - "url", - "url-parse", - "uuid", + "signature 1.6.4", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.9.9", + "zeroize", ] [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" dependencies = [ "serde", ] @@ -1197,9 +1235,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" dependencies = [ "errno-dragonfly", "libc", @@ -1244,7 +1282,7 @@ dependencies = [ [[package]] name = "exp_golomb" -version = "0.1.0" +version = "0.0.1" dependencies = [ "bytes", "bytesio", @@ -1268,6 +1306,12 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + [[package]] name = "fixed" version = "1.23.1" @@ -1446,7 +1490,7 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" dependencies = [ - "fastrand", + "fastrand 1.9.0", "futures-core", "futures-io", "memchr", @@ -1463,7 +1507,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -1535,6 +1579,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "h2" version = "0.3.20" @@ -1556,7 +1625,7 @@ dependencies = [ [[package]] name = "h264" -version = "0.1.0" +version = "0.0.1" dependencies = [ "byteorder", "bytes", @@ -1566,7 +1635,7 @@ dependencies = [ [[package]] name = "h265" -version = "0.1.0" +version = "0.0.1" dependencies = [ "byteorder", "bytes", @@ -1662,7 +1731,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1715,9 +1784,9 @@ checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" @@ -1776,12 +1845,12 @@ dependencies = [ [[package]] name = "hyper-tungstenite" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226df6fd0aece319a325419d770aa9d947defa60463f142cd82b329121f906a3" +checksum = "7cc7dcb1ab67cd336f468a12491765672e61a3b6b148634dbfe2fe8acd3fe7d9" dependencies = [ "hyper", - "pin-project", + "pin-project-lite", "tokio", "tokio-tungstenite", "tungstenite", @@ -1845,7 +1914,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "serde", ] [[package]] @@ -1856,54 +1924,14 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", "hashbrown 0.14.0", + "serde", ] [[package]] -name = "ingest" -version = "0.1.0" -dependencies = [ - "aac", - "anyhow", - "async-stream", - "async-trait", - "bytes", - "bytesio", - "chrono", - "common", - "config", - "dotenvy", - "flv", - "futures", - "futures-util", - "hyper", - "lapin", - "mp4", - "native-tls", - "pnet", - "portpicker", - "prost", - "prost-build", - "rtmp", - "serde", - "serde_json", - "serial_test", - "tempfile", - "tokio", - "tokio-executor-trait", - "tokio-native-tls", - "tokio-reactor-trait", - "tonic", - "tonic-build", - "tracing 0.1.37", - "transmuxer", - "uuid", -] - -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ "block-padding", "generic-array", @@ -1963,7 +1991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ "hermit-abi", - "rustix 0.38.4", + "rustix 0.38.8", "windows-sys", ] @@ -1999,11 +2027,12 @@ checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" dependencies = [ "base64 0.13.1", "crypto-common", - "digest", + "digest 0.10.7", "hmac", + "openssl", "serde", "serde_json", - "sha2", + "sha2 0.10.7", ] [[package]] @@ -2074,9 +2103,9 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" [[package]] name = "lock_api" @@ -2090,9 +2119,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "lru" @@ -2135,9 +2164,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "matchit" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" [[package]] name = "md-5" @@ -2145,7 +2174,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2263,7 +2292,7 @@ checksum = "681fa6e0925389292df5b310dc2b8f9407748194cedb9d6fb678adec155fb73c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -2280,6 +2309,22 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "nkeys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e9261eb915c785ea65708bc45ef43507ea46914e1a73f1412d1a38aba967c8e" +dependencies = [ + "byteorder", + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom", + "log", + "rand", + "signatory", +] + [[package]] name = "no-std-net" version = "0.6.0" @@ -2306,6 +2351,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "nuid" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c1bb65186718d348306bf1afdeb20d9ab45b2ab80fb793c0fdcf59ffbb4f38" +dependencies = [ + "lazy_static", + "rand", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -2331,7 +2386,7 @@ checksum = "9e6a0fd4f737c707bd9086cc16c925f294943eb62eb71499e9fd4cf71f8b9f4e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -2378,9 +2433,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", "libm", @@ -2411,11 +2466,17 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "openssl" -version = "0.10.55" +version = "0.10.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" +checksum = "729b745ad4a5575dd06a3e1af1414bd330ee561c01b3899eb584baeaa8def17e" dependencies = [ "bitflags 1.3.2", "cfg-if", @@ -2434,7 +2495,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -2445,9 +2506,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.90" +version = "0.9.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" +checksum = "866b5f16f90776b9bb8dc1e1802ac6f0513de3a7a7465867bfbc563dc737faac" dependencies = [ "cc", "libc", @@ -2566,7 +2627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2576,6 +2637,32 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pb" +version = "0.0.1" +dependencies = [ + "prettyplease 0.2.12", + "proc-macro2", + "prost", + "prost-build", + "quote", + "syn 2.0.28", + "tonic", + "tonic-build", + "ulid", + "uuid", + "walkdir", +] + +[[package]] +name = "pem-rfc7468" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f22eb0e3c593294a99e9ff4b24cf6b752d43f193aa4415fe5077c159996d497" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2593,9 +2680,9 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9" +checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" dependencies = [ "thiserror", "ucd-trie", @@ -2603,9 +2690,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b" +checksum = "666d00490d4ac815001da55838c500eafb0320019bbaa44444137c48b443a853" dependencies = [ "pest", "pest_generator", @@ -2613,26 +2700,26 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190" +checksum = "68ca01446f50dbda87c1786af8770d535423fa8a53aec03b8f4e3d7eb10e0929" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] name = "pest_meta" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0" +checksum = "56af0a30af74d0445c0bf6d9d051c979b516a1a5af790d251daee76005420a48" dependencies = [ "once_cell", "pest", - "sha2", + "sha2 0.10.7", ] [[package]] @@ -2647,29 +2734,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] name = "pin-project-lite" -version = "0.2.10" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" +checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" [[package]] name = "pin-utils" @@ -2695,9 +2782,21 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.8", + "pkcs8 0.10.2", + "spki 0.7.2", +] + +[[package]] +name = "pkcs8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447" +dependencies = [ + "der 0.4.5", + "pem-rfc7468 0.2.3", + "spki 0.4.1", + "zeroize", ] [[package]] @@ -2706,8 +2805,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.8", + "spki 0.7.2", ] [[package]] @@ -2716,11 +2815,56 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "platform-api" +version = "0.0.1" +dependencies = [ + "anyhow", + "arc-swap", + "argon2", + "async-graphql", + "async-stream", + "bitmask-enum", + "chrono", + "common", + "config", + "dotenvy", + "email_address", + "fred", + "futures", + "futures-util", + "hmac", + "http", + "hyper", + "hyper-tungstenite", + "jwt", + "lapin", + "negative-impl", + "pb", + "portpicker", + "prost", + "rand", + "reqwest", + "routerify", + "serde", + "serde_json", + "serial_test", + "sha2 0.10.7", + "sqlx", + "tempfile", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tonic", + "tracing 0.1.37", + "uuid", +] + [[package]] name = "pnet" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd959a8268165518e2bf5546ba84c7b3222744435616381df3c456fe8d983576" +checksum = "130c5b738eeda2dc5796fe2671e49027e6935e817ab51b930a36ec9e6a206a64" dependencies = [ "ipnetwork", "pnet_base", @@ -2732,18 +2876,18 @@ dependencies = [ [[package]] name = "pnet_base" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872e46346144ebf35219ccaa64b1dffacd9c6f188cd7d012bd6977a2a838f42e" +checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c" dependencies = [ "no-std-net", ] [[package]] name = "pnet_datalink" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c302da22118d2793c312a35fb3da6846cb0fab6c3ad53fd67e37809b06cdafce" +checksum = "ad5854abf0067ebbd3967f7d45ebc8976ff577ff0c7bd101c4973ae3c70f98fe" dependencies = [ "ipnetwork", "libc", @@ -2754,30 +2898,30 @@ dependencies = [ [[package]] name = "pnet_macros" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a780e80005c2e463ec25a6e9f928630049a10b43945fea83207207d4a7606f4" +checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804" dependencies = [ "proc-macro2", "quote", "regex", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] name = "pnet_macros_support" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d932134f32efd7834eb8b16d42418dac87086347d1bc7d142370ef078582bc" +checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56" dependencies = [ "pnet_base", ] [[package]] name = "pnet_packet" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bde678bbd85cb1c2d99dc9fc596e57f03aa725f84f3168b0eaf33eeccb41706" +checksum = "a9a005825396b7fe7a38a8e288dbc342d5034dac80c15212436424fef8ea90ba" dependencies = [ "glob", "pnet_base", @@ -2787,9 +2931,9 @@ dependencies = [ [[package]] name = "pnet_sys" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf7a58b2803d818a374be9278a1fe8f88fce14b936afbe225000cfcd9c73f16" +checksum = "417c0becd1b573f6d544f73671070b039051e5ad819cc64aa96377b536128d00" dependencies = [ "libc", "winapi", @@ -2797,9 +2941,9 @@ dependencies = [ [[package]] name = "pnet_transport" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "813d1c0e4defbe7ee22f6fe1755f122b77bfb5abe77145b1b5baaf463cab9249" +checksum = "2637e14d7de974ee2f74393afccbc8704f3e54e6eb31488715e72481d1662cc3" dependencies = [ "libc", "pnet_base", @@ -2848,6 +2992,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "prettyplease" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62" +dependencies = [ + "proc-macro2", + "syn 2.0.28", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2890,7 +3044,7 @@ dependencies = [ "log", "multimap", "petgraph", - "prettyplease", + "prettyplease 0.1.25", "prost", "prost-types", "regex", @@ -2929,9 +3083,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.31" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" dependencies = [ "proc-macro2", ] @@ -2944,7 +3098,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2954,9 +3108,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + [[package]] name = "rand_core" version = "0.6.4" @@ -3011,13 +3171,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.3", + "regex-automata 0.3.6", "regex-syntax 0.7.4", ] @@ -3032,9 +3192,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.3" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310" +checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" dependencies = [ "aho-corasick", "memchr", @@ -3135,17 +3295,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" dependencies = [ "byteorder", - "const-oid", - "digest", + "const-oid 0.9.5", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-iter", "num-traits", "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.1.0", + "spki 0.7.2", "subtle", "zeroize", ] @@ -3169,7 +3329,7 @@ dependencies = [ "num-traits", "rand", "serde_json", - "sha2", + "sha2 0.10.7", "tokio", "tracing 0.1.37", "uuid", @@ -3197,22 +3357,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.4" +version = "0.38.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" +checksum = "19ed4fa021d81c8392ce04db050a3da9a60299050b7ae1cf482d862b54a7218f" dependencies = [ - "bitflags 2.3.3", + "bitflags 2.4.0", "errno", "libc", - "linux-raw-sys 0.4.3", + "linux-raw-sys 0.4.5", "windows-sys", ] [[package]] name = "rustls" -version = "0.21.5" +version = "0.21.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36" +checksum = "1d1feddffcfcc0b33f5c6ce9a29e341e4cd59c3f78e7ee45f4a40c038b1d6cbb" dependencies = [ "log", "ring", @@ -3255,9 +3415,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.1" +version = "0.101.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e" +checksum = "261e9e0888cba427c3316e6322805653c9425240b6fd96cee7cb671ab70ab8d0" dependencies = [ "ring", "untrusted", @@ -3275,6 +3435,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.22" @@ -3284,11 +3453,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sct" @@ -3302,9 +3477,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -3315,9 +3490,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys", "libc", @@ -3331,9 +3506,9 @@ checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" [[package]] name = "serde" -version = "1.0.171" +version = "1.0.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c" dependencies = [ "serde_derive", ] @@ -3348,15 +3523,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_derive" -version = "1.0.171" +version = "1.0.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafe972d60b0b9bee71a91b92fee2d4fb3c9d7e8f6b179aa99f27203d99a4816" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "serde_derive_internals" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" +checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -3370,15 +3567,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b" +checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "serde_nanos" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae801b7733ca8d6a2b580debe99f67f36826a0f5b8a36055dc6bc40f8d6bc71" +dependencies = [ + "serde", +] + [[package]] name = "serde_path_to_error" version = "0.1.14" @@ -3389,6 +3595,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + [[package]] name = "serde_spanned" version = "0.6.3" @@ -3412,9 +3629,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.23" +version = "0.9.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da6075b41c7e3b079e5f246eb6094a44850d3a4c25a67c581c80796c80134012" +checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574" dependencies = [ "indexmap 2.0.0", "itoa", @@ -3445,7 +3662,7 @@ checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -3456,7 +3673,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -3467,7 +3684,20 @@ checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -3478,7 +3708,7 @@ checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -3499,14 +3729,32 @@ dependencies = [ "libc", ] +[[package]] +name = "signatory" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfecc059e81632eef1dd9b79e22fc28b8fe69b30d3357512a77a0ad8ee3c782" +dependencies = [ + "pkcs8 0.7.6", + "rand_core 0.6.4", + "signature 1.6.4", + "zeroize", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + [[package]] name = "signature" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] @@ -3559,6 +3807,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32" +dependencies = [ + "der 0.4.5", +] + [[package]] name = "spki" version = "0.7.2" @@ -3566,7 +3823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" dependencies = [ "base64ct", - "der", + "der 0.7.8", ] [[package]] @@ -3583,7 +3840,8 @@ dependencies = [ [[package]] name = "sqlx" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e58421b6bc416714d5115a2ca953718f6c621a51b68e4f4922aea5a4391a721" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3595,7 +3853,8 @@ dependencies = [ [[package]] name = "sqlx-core" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4cef4251aabbae751a3710927945901ee1d97ee96d757f6880ebb9a79bfd53" dependencies = [ "ahash 0.8.3", "atoi", @@ -3623,7 +3882,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.7", "smallvec", "sqlformat", "thiserror", @@ -3637,7 +3896,8 @@ dependencies = [ [[package]] name = "sqlx-macros" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208e3165167afd7f3881b16c1ef3f2af69fa75980897aac8874a0696516d12c2" dependencies = [ "proc-macro2", "quote", @@ -3649,7 +3909,8 @@ dependencies = [ [[package]] name = "sqlx-macros-core" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a4a8336d278c62231d87f24e8a7a74898156e34c1c18942857be2acb29c7dfc" dependencies = [ "dotenvy", "either", @@ -3660,7 +3921,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.7", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3674,16 +3935,17 @@ dependencies = [ [[package]] name = "sqlx-mysql" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482" dependencies = [ "atoi", "base64 0.21.2", - "bitflags 2.3.3", + "bitflags 2.4.0", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -3704,7 +3966,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.7", "smallvec", "sqlx-core", "stringprep", @@ -3717,11 +3979,12 @@ dependencies = [ [[package]] name = "sqlx-postgres" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e" dependencies = [ "atoi", "base64 0.21.2", - "bitflags 2.3.3", + "bitflags 2.4.0", "byteorder", "chrono", "crc", @@ -3744,7 +4007,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.7", "smallvec", "sqlx-core", "stringprep", @@ -3757,7 +4020,8 @@ dependencies = [ [[package]] name = "sqlx-sqlite" version = "0.7.1" -source = "git+https://github.com/launchbadge/sqlx?branch=main#c70cfaf035b3ffcb1d6f244f6070e92646e83515" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4c21bf34c7cae5b283efb3ac1bcc7670df7561124dc2f8bdc0b59be40f79a2" dependencies = [ "atoi", "chrono", @@ -3799,6 +4063,28 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.28", +] + [[package]] name = "subtle" version = "2.5.0" @@ -3818,9 +4104,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.26" +version = "2.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" +checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" dependencies = [ "proc-macro2", "quote", @@ -3848,15 +4134,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.6.0" +version = "3.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" dependencies = [ - "autocfg", "cfg-if", - "fastrand", + "fastrand 2.0.0", "redox_syscall", - "rustix 0.37.23", + "rustix 0.38.8", "windows-sys", ] @@ -3871,22 +4156,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.43" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.43" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -3899,6 +4184,34 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fdd63d58b18d663fbdf70e049f00a22c8e42be082203be7f26589213cd75ea" +dependencies = [ + "deranged", + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb71511c991639bb078fd5bf97757e03914361c48100d52878b8e52b46fb92cd" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -3916,11 +4229,10 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.29.1" +version = "1.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" +checksum = "40de3a2ba249dcb097e01be5e67a5ff53cf250397715a071a81543e8a832a920" dependencies = [ - "autocfg", "backtrace", "bytes", "libc", @@ -3929,7 +4241,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.4.9", + "socket2 0.5.3", "tokio-macros", "windows-sys", ] @@ -3963,7 +4275,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", ] [[package]] @@ -3991,7 +4303,18 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand", + "tokio", +] + +[[package]] +name = "tokio-rustls" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" @@ -4014,9 +4337,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec509ac96e9a0c43427c74f003127d953a265737636129424288d27cb5c4b12c" +checksum = "2b2dbec703c26b00d74844519606ef15d09a7d6857860f84ad223dec002ddea2" dependencies = [ "futures-util", "log", @@ -4109,7 +4432,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" dependencies = [ - "prettyplease", + "prettyplease 0.1.25", "proc-macro2", "prost-build", "quote", @@ -4157,16 +4480,17 @@ dependencies = [ "cfg-if", "log", "pin-project-lite", - "tracing-attributes", + "tracing-attributes 0.1.26", "tracing-core 0.1.31", ] [[package]] name = "tracing" version = "0.2.0" -source = "git+https://github.com/ScuffleTV/tracing#e2dd0f6e0e31896f41607717159c71fd24f26b4f" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" dependencies = [ "pin-project-lite", + "tracing-attributes 0.2.0", "tracing-core 0.2.0", ] @@ -4178,7 +4502,17 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", +] + +[[package]] +name = "tracing-attributes" +version = "0.2.0" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", ] [[package]] @@ -4188,12 +4522,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", + "valuable", ] [[package]] name = "tracing-core" version = "0.2.0" -source = "git+https://github.com/ScuffleTV/tracing#e2dd0f6e0e31896f41607717159c71fd24f26b4f" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" dependencies = [ "once_cell", ] @@ -4210,10 +4545,21 @@ dependencies = [ "tracing 0.1.37", ] +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core 0.1.31", +] + [[package]] name = "tracing-log" version = "0.2.0" -source = "git+https://github.com/ScuffleTV/tracing#e2dd0f6e0e31896f41607717159c71fd24f26b4f" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" dependencies = [ "env_logger", "log", @@ -4224,7 +4570,7 @@ dependencies = [ [[package]] name = "tracing-serde" version = "0.2.0" -source = "git+https://github.com/ScuffleTV/tracing#e2dd0f6e0e31896f41607717159c71fd24f26b4f" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" dependencies = [ "serde", "tracing-core 0.2.0", @@ -4233,7 +4579,7 @@ dependencies = [ [[package]] name = "tracing-subscriber" version = "0.3.0" -source = "git+https://github.com/ScuffleTV/tracing#e2dd0f6e0e31896f41607717159c71fd24f26b4f" +source = "git+https://github.com/ScuffleTV/tracing#4a8a498f240b5f4b6050622323f9d2a1adad816f" dependencies = [ "matchers", "nu-ansi-term", @@ -4246,50 +4592,22 @@ dependencies = [ "thread_local", "tracing 0.2.0", "tracing-core 0.2.0", - "tracing-log", + "tracing-log 0.2.0", "tracing-serde", ] [[package]] -name = "transcoder" -version = "0.1.0" +name = "tracing-subscriber" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ - "aac", - "anyhow", - "async-stream", - "async-trait", - "bytes", - "bytesio", - "chrono", - "common", - "config", - "dotenvy", - "flv", - "fred", - "futures", - "futures-util", - "hyper", - "lapin", - "mp4", - "native-tls", - "nix", - "portpicker", - "prost", - "prost-build", - "serde", - "serial_test", - "sha2", - "tempfile", - "tokio", - "tokio-native-tls", - "tokio-stream", - "tokio-util", - "tonic", - "tonic-build", - "tracing 0.1.37", - "transmuxer", - "url-parse", - "uuid", + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core 0.1.31", + "tracing-log 0.1.3", ] [[package]] @@ -4361,11 +4679,36 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +[[package]] +name = "tsify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.28", +] + [[package]] name = "tungstenite" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fba1a6d6bb030745759a9a2a588bfe8490fc8b4751a277db3a0be1c9ebbf67" +checksum = "e862a1c4128df0112ab625f55cd5c934bcb4312ba80b39ae4b4835a3fd58e649" dependencies = [ "byteorder", "bytes", @@ -4392,6 +4735,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "ulid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3aaa69b04e5b66cc27309710a569ea23593612387d67daaf102e73aa974fd" +dependencies = [ + "rand", + "serde", + "uuid", +] + [[package]] name = "unicode-bidi" version = "0.3.13" @@ -4446,22 +4800,23 @@ dependencies = [ "form_urlencoded", "idna 0.4.0", "percent-encoding", + "serde", ] [[package]] name = "url-parse" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b316bdc240d73d26f84d3b0779b89a8fddc9f0b3aa54cf4979b74dda4e08b2" +checksum = "0d375da66174ba9b3697f36468fb6b9a981074537569a87ad2dc43de2a598063" dependencies = [ "regex", ] [[package]] name = "urlencoding" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf-8" @@ -4477,14 +4832,20 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom", "serde", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4497,12 +4858,242 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "video-api" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-graphql", + "async-stream", + "async-trait", + "bytes", + "chrono", + "common", + "config", + "fred", + "futures", + "futures-util", + "hmac", + "jwt", + "pb", + "prost", + "rand", + "serde", + "sha2 0.10.7", + "sqlx", + "sqlx-postgres", + "tokio", + "tokio-stream", + "tonic", + "tracing 0.1.37", + "uuid", + "video-database", +] + +[[package]] +name = "video-database" +version = "0.0.1" +dependencies = [ + "async-graphql", + "async-trait", + "bytes", + "chrono", + "futures", + "futures-util", + "pb", + "prost", + "serde", + "sqlx", + "sqlx-postgres", + "tokio", + "tracing 0.1.37", + "ulid", + "uuid", +] + +[[package]] +name = "video-edge" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-nats", + "async-stream", + "async-trait", + "bytes", + "chrono", + "common", + "config", + "dotenvy", + "futures", + "futures-util", + "hmac", + "hyper", + "jwt", + "native-tls", + "nix", + "openssl", + "pb", + "portpicker", + "prost", + "routerify", + "serde", + "serde_json", + "serial_test", + "sha2 0.10.7", + "sqlx", + "sqlx-postgres", + "tempfile", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "tonic", + "tracing 0.1.37", + "ulid", + "url", + "url-parse", + "uuid", + "video-database", +] + +[[package]] +name = "video-ingest" +version = "0.0.1" +dependencies = [ + "aac", + "anyhow", + "async-nats", + "async-stream", + "async-trait", + "base64 0.21.2", + "bytes", + "bytesio", + "chrono", + "common", + "config", + "dotenvy", + "flv", + "futures", + "futures-util", + "hyper", + "mp4", + "native-tls", + "pb", + "pnet", + "portpicker", + "prost", + "rtmp", + "serde", + "serde_json", + "serial_test", + "sqlx", + "sqlx-postgres", + "tempfile", + "tokio", + "tokio-executor-trait", + "tokio-native-tls", + "tokio-reactor-trait", + "tokio-stream", + "tonic", + "tracing 0.1.37", + "transmuxer", + "ulid", + "uuid", + "video-database", +] + +[[package]] +name = "video-player" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.21.2", + "bytes", + "bytesio", + "console_error_panic_hook", + "futures", + "gloo-timers", + "h264", + "js-sys", + "mp4", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tokio", + "tokio-stream", + "tracing 0.1.37", + "tracing-core 0.1.31", + "tracing-subscriber 0.3.17", + "tsify", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "video-transcoder" +version = "0.0.1" +dependencies = [ + "aac", + "anyhow", + "async-nats", + "async-stream", + "async-trait", + "bytes", + "bytesio", + "chrono", + "common", + "config", + "dotenvy", + "flv", + "futures", + "futures-util", + "hyper", + "mp4", + "native-tls", + "nix", + "pb", + "portpicker", + "prost", + "serde", + "serde_json", + "serial_test", + "sha2 0.10.7", + "sqlx", + "sqlx-postgres", + "tempfile", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "tonic", + "tracing 0.1.37", + "transmuxer", + "ulid", + "url-parse", + "uuid", + "video-database", +] + [[package]] name = "waker-fn" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4539,7 +5130,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", "wasm-bindgen-shared", ] @@ -4573,7 +5164,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.28", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4584,6 +5175,30 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +[[package]] +name = "wasm-bindgen-test" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6e302a7ea94f83a6d09e78e7dc7d9ca7b186bc2829c24a22d0753efd680671" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "scoped-tls", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecb993dd8c836930ed130e020e77d9b2e65dd0fbab1b67c790b0f5d80b11a575" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "web-sys" version = "0.3.64" @@ -4668,9 +5283,9 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.1" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +checksum = "d1eeca1c172a285ee6c2c84c341ccea837e7c01b12fbb2d0fe3c9e550ce49ec8" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", @@ -4683,51 +5298,51 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "b10d0c968ba7f6166195e13d593af609ec2e3d24f916f081690695cf5eaffb2f" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "571d8d4e62f26d4932099a9efe89660e8bd5087775a2ab5cdd8b747b811f1058" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "2229ad223e178db5fbbc8bd8d3835e51e566b8474bfca58d2e6150c48bb723cd" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "600956e2d840c194eedfc5d18f8242bc2e17c7775b6684488af3a9fff6fe3287" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "ea99ff3f8b49fb7a8e0d305e5aec485bd068c2ba691b6e277d29eaeac945868a" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "8f1a05a1ece9a7a0d5a7ccf30ba2c33e3a61a30e042ffd247567d1de1d94120d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "d419259aba16b663966e29e6d7c6ecfa0bb8425818bb96f6f1f3c3eb71a6e7b9" [[package]] name = "winnow" -version = "0.5.0" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fac9742fd1ad1bd9643b991319f72dd031016d44b77039a26977eb667141e7" +checksum = "5504cc7644f4b593cbc05c4a55bf9bd4e94b867c3c0bd440934174d50482427d" dependencies = [ "memchr", ] @@ -4762,3 +5377,17 @@ name = "zeroize" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] diff --git a/Cargo.toml b/Cargo.toml index 4e8d1b13..9e4ce120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,52 @@ [workspace] members = [ - "backend/api", + "platform/api", "video/edge", "video/ingest", "video/transcoder", - "video/bytesio", - "video/container/flv", - "video/container/mp4", - "video/codec/h264", - "video/codec/h265", - "video/codec/av1", - "video/codec/aac", - "video/protocol/rtmp", - "video/transmuxer", - "video/utils/amf0", - "video/utils/exp_golomb", + "video/lib/*", + "video/api", + "video/player", + "video/database", "common", + "proto", "config/config", "config/config_derive", - "config/config_test" + "config/config_test", ] -exclude = [ - "frontend/player", -] +resolver = "2" + +[profile.wasm] +lto = 'fat' +panic = 'abort' +opt-level = "z" +codegen-units = 1 +strip = true +inherits = "release" + +[workspace.dependencies] +aac = { path = "video/lib/aac" } +amf0 = { path = "video/lib/amf0" } +av1 = { path = "video/lib/av1" } +bytesio = { path = "video/lib/bytesio", default-features = false } +exp_golomb = { path = "video/lib/exp_golomb" } +flv = { path = "video/lib/flv" } +h264 = { path = "video/lib/h264" } +h265 = { path = "video/lib/h265" } +mp4 = { path = "video/lib/mp4" } +rtmp = { path = "video/lib/rtmp" } +transmuxer = { path = "video/lib/transmuxer" } +common = { path = "common", default-features = false } +config = { path = "config/config" } +pb = { path = "proto" } +video-database = { path = "video/database" } [patch.crates-io] fred = { git = "https://github.com/ScuffleTV/fred.rs" } -tracing-log = { git = "https://github.com/ScuffleTV/tracing" } + +[patch."https://github.com/tokio-rs/tracing.git"] +tracing = { git = "https://github.com/ScuffleTV/tracing" } tracing-subscriber = { git = "https://github.com/ScuffleTV/tracing" } +tracing-log = { git = "https://github.com/ScuffleTV/tracing" } diff --git a/README.md b/README.md index 4606daff..784430d2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![codecov](https://codecov.io/gh/ScuffleTV/scuffle/branch/main/graph/badge.svg?token=LJCYSZR4IV)](https://codecov.io/gh/ScuffleTV/scuffle) [![CircleCI](https://dl.circleci.com/status-badge/img/gh/ScuffleTV/scuffle/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/ScuffleTV/scuffle/tree/main) +[![dependency status](https://deps.rs/repo/github/ScuffleTV/scuffle/status.svg)](https://deps.rs/repo/github/ScuffleTV/scuffle) ## Welcome to Scuffle! diff --git a/common/Cargo.toml b/common/Cargo.toml index cbaf3cf7..d4b7c659 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,13 +1,12 @@ [package] name = "common" -version = "0.1.0" +version = "0.0.1" edition = "2021" authors = ["Scuffle "] description = "Scuffle Common Library" [features] logging = ["dep:log", "dep:tracing", "dep:tracing-log", "dep:tracing-subscriber", "dep:arc-swap", "dep:anyhow", "dep:once_cell", "dep:thiserror", "dep:serde"] -rmq = ["dep:lapin", "dep:arc-swap", "dep:anyhow", "dep:futures", "dep:tracing", "dep:tokio", "dep:async-stream", "prelude"] grpc = ["dep:tonic", "dep:anyhow", "dep:async-trait", "dep:futures", "dep:http", "dep:tower", "dep:trust-dns-resolver", "dep:tracing"] context = ["dep:tokio", "dep:tokio-util"] prelude = ["dep:tokio"] @@ -15,34 +14,32 @@ signal = [] macros = [] config = ["dep:config", "dep:serde", "logging"] -default = ["logging", "rmq", "grpc", "context", "prelude", "signal", "macros", "config"] +default = ["logging", "grpc", "context", "prelude", "signal", "macros", "config"] [dependencies] -log = { version = "0", optional = true } -http = { version = "0", optional = true } -tower = { version = "0", optional = true } -config = { path = "../config/config", optional = true } -anyhow = { version = "1", optional = true } -futures = { version = "0", optional = true } -tracing = { version = "0", optional = true } -arc-swap = { version = "1", optional = true } -tokio-util = { version = "0", optional = true } -async-trait = { version = "0", optional = true } -async-stream = { version = "0", optional = true } -tonic = { version = "0", features = ["tls"], optional = true } -tokio = { version = "1", features = ["sync", "rt"], optional = true } -serde = { version = "1", features = ["derive"], optional = true } -lapin = { version = "2.0.3", features = ["native-tls"], optional = true } -tracing-log = { version = "0", features = ["env_logger"], optional = true } -once_cell = { version = "1", optional = true } -trust-dns-resolver = { version = "0", features = ["tokio-runtime"], optional = true } -tracing-subscriber = { version = "0", features = ["fmt", "env-filter", "json"], optional = true } -thiserror = { version = "1", optional = true } +log = { version = "0.4.19", optional = true } +http = { version = "0.2.9", optional = true } +tower = { version = "0.4.13", optional = true } +config = { workspace = true, optional = true } +anyhow = { version = "1.0.72", optional = true } +futures = { version = "0.3.28", optional = true } +tracing = { version = "0.2.0", optional = true, git = "https://github.com/tokio-rs/tracing.git" } +arc-swap = { version = "1.6.0", optional = true } +tokio-util = { version = "0.7.8", optional = true } +async-trait = { version = "0.1.72", optional = true } +tonic = { version = "0.9.2", features = ["tls"], optional = true } +tokio = { version = "1.29.1", features = ["sync", "rt"], optional = true } +serde = { version = "1.0.183", features = ["derive"], optional = true } +tracing-log = { version = "0.2.0", features = ["env_logger"], optional = true, git = "https://github.com/tokio-rs/tracing.git" } +once_cell = { version = "1.18.0", optional = true } +trust-dns-resolver = { version = "0.22.0", features = ["tokio-runtime"], optional = true } +tracing-subscriber = { version = "0.3.0", features = ["fmt", "env-filter", "json"], optional = true, git = "https://github.com/tokio-rs/tracing.git" } +thiserror = { version = "1.0.44", optional = true } [dev-dependencies] -prost = "0" -tempfile = "3" -portpicker = "0" +prost = "0.11.9" +tempfile = "3.7.1" +portpicker = "0.1.1" [build-dependencies] -tonic-build = "0" +tonic-build = "0.9.2" diff --git a/common/src/config.rs b/common/src/config.rs index f2ba9033..d170da91 100644 --- a/common/src/config.rs +++ b/common/src/config.rs @@ -80,15 +80,31 @@ pub struct RedisSentinelConfig { #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] -pub struct RmqConfig { - /// The URI to use for connecting to RabbitMQ - pub uri: String, +pub struct NatsConfig { + /// The URI to use for connecting to Nats + pub servers: Vec, + + /// The username to use for authentication (user-pass auth) + pub username: Option, + + /// The password to use for authentication (user-pass auth) + pub password: Option, + + /// The token to use for authentication (token auth) + pub token: Option, + + /// The TLS configuration (can be used for mTLS) + pub tls: Option, } -impl Default for RmqConfig { +impl Default for NatsConfig { fn default() -> Self { Self { - uri: "amqp://rabbitmq:rabbitmq@localhost:5672/scuffle".to_string(), + servers: vec!["localhost:4222".into()], + token: None, + password: None, + tls: None, + username: None, } } } diff --git a/common/src/grpc.rs b/common/src/grpc.rs index 4cc6e5a9..89432177 100644 --- a/common/src/grpc.rs +++ b/common/src/grpc.rs @@ -36,7 +36,8 @@ pub struct ChannelOpts { #[derive(Clone, Debug)] pub struct TlsSettings { /// The domain on the certificate. - pub domain: String, + pub domain: Option, + /// The client certificate. pub identity: Identity, /// The CA certificate to verify the server. @@ -241,10 +242,13 @@ impl ChannelController { // If TLS is enabled, we need to add the TLS config to the Endpoint. let endpoint = if self.tls.is_some() { let tls = self.tls.as_ref().unwrap(); - let tls = ClientTlsConfig::new() - .domain_name(tls.domain.clone()) - .ca_certificate(tls.ca_cert.clone()) - .identity(tls.identity.clone()); + let tls = if let Some(domain) = &tls.domain { + ClientTlsConfig::new().domain_name(domain) + } else { + ClientTlsConfig::new() + } + .ca_certificate(tls.ca_cert.clone()) + .identity(tls.identity.clone()); match endpoint.tls_config(tls) { Ok(endpoint) => endpoint, diff --git a/common/src/lib.rs b/common/src/lib.rs index 4b32a44a..e4b64b9e 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -10,8 +10,6 @@ pub mod grpc; pub mod logging; #[cfg(feature = "prelude")] pub mod prelude; -#[cfg(feature = "rmq")] -pub mod rmq; #[cfg(feature = "signal")] pub mod signal; diff --git a/common/src/rmq.rs b/common/src/rmq.rs deleted file mode 100644 index c8cbc899..00000000 --- a/common/src/rmq.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::{ - sync::{atomic::AtomicUsize, Arc}, - time::Duration, -}; - -use anyhow::{anyhow, Result}; -use arc_swap::ArcSwap; -use async_stream::stream; -use futures::{Stream, StreamExt}; -use lapin::{ - options::BasicConsumeOptions, topology::TopologyDefinition, types::FieldTable, Channel, - Connection, ConnectionProperties, -}; -use tokio::sync::{broadcast, mpsc, Mutex}; -use tracing::{info_span, Instrument}; - -use crate::prelude::FutureTimeout; - -pub struct ConnectionPool { - uri: String, - timeout: Duration, - properties: ConnectionProperties, - error_queue: mpsc::Sender, - error_queue_rx: Mutex>, - new_connection_waker: broadcast::Sender<()>, - connections: Vec>, - aquire_idx: AtomicUsize, -} - -impl ConnectionPool { - pub async fn connect( - uri: String, - properties: ConnectionProperties, - timeout: Duration, - pool_size: usize, - ) -> Result { - let connections = Vec::with_capacity(pool_size); - let (tx, rx) = mpsc::channel(pool_size); - - let mut pool = Self { - uri, - properties, - timeout, - connections, - error_queue: tx, - error_queue_rx: Mutex::new(rx), - new_connection_waker: broadcast::channel(1).0, - aquire_idx: AtomicUsize::new(0), - }; - - for i in 0..pool_size { - let conn = pool.new_connection(i, None).await?; - pool.connections.push(ArcSwap::from(Arc::new(conn))); - } - - Ok(pool) - } - - pub async fn handle_reconnects(&self) -> Result<()> { - loop { - let idx = self - .error_queue_rx - .lock() - .await - .recv() - .await - .expect("error queue closed"); - let conn = async { - loop { - let conn = match self - .new_connection(idx, Some(self.connections[idx].load().topology())) - .await - { - Ok(conn) => conn, - Err(err) => { - tracing::error!("failed to reconnect: {}", err); - tokio::time::sleep(Duration::from_secs(1)).await; - continue; - } - }; - - tracing::info!("reconnected to rabbitmq"); - break conn; - } - } - .instrument(info_span!("reconnect rmq", idx)) - .timeout(self.timeout) - .await?; - - self.connections[idx].store(Arc::new(conn)); - self.new_connection_waker.send(()).ok(); - } - } - - pub async fn new_connection( - &self, - idx: usize, - topology: Option, - ) -> Result { - let conn = Connection::connect(&self.uri, self.properties.clone()) - .timeout(self.timeout) - .await??; - - if let Some(topology) = topology { - conn.restore(topology).await?; - } - - let sender = self.error_queue.clone(); - conn.on_error(move |e| { - tracing::error!("rabbitmq error: {:?}", e); - - if let Err(err) = sender.try_send(idx) { - tracing::error!("failed to reload connection: {}", err); - } - }); - - Ok(conn) - } - - pub fn basic_consume( - &self, - queue_name: impl ToString, - connection_name: impl ToString, - options: BasicConsumeOptions, - table: FieldTable, - ) -> impl Stream> + '_ { - let queue_name = queue_name.to_string(); - let connection_name = connection_name.to_string(); - - stream!({ - 'connection_loop: loop { - let channel = self.aquire().await?; - let mut consumer = channel - .basic_consume(&queue_name, &connection_name, options, table.clone()) - .await?; - loop { - let m = consumer.next().await; - match m { - Some(Ok(m)) => { - yield Ok(m); - } - Some(Err(e)) => match e { - lapin::Error::IOError(e) => { - if e.kind() == std::io::ErrorKind::ConnectionReset { - continue 'connection_loop; - } - } - _ => { - yield Err(anyhow!("failed to get message: {}", e)); - } - }, - None => { - continue 'connection_loop; - } - } - } - } - }) - } - - pub async fn aquire(&self) -> Result { - let mut done = false; - loop { - let mut conn = None; - let start_idx = self - .aquire_idx - .fetch_add(1, std::sync::atomic::Ordering::Relaxed) - % self.connections.len(); - for c in self.connections[start_idx..] - .iter() - .chain(self.connections[..start_idx].iter()) - { - let loaded = c.load(); - if loaded.status().connected() { - conn = Some(loaded.clone()); - break; - } - } - - if let Some(conn) = conn { - let channel = conn.create_channel().await?; - return Ok(channel); - } - - if done { - return Err(anyhow!("no connections available")); - } - - done = true; - self.new_connection_waker - .subscribe() - .recv() - .timeout(self.timeout) - .await??; - } - } -} diff --git a/config/config/Cargo.toml b/config/config/Cargo.toml index fea4e5f8..df2025b1 100644 --- a/config/config/Cargo.toml +++ b/config/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "config" -version = "0.1.0" +version = "0.0.1" edition = "2021" authors = ["Scuffle "] description = "Extensible config solution" @@ -11,23 +11,24 @@ keywords = ["config", "cli", "proc-macro"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -thiserror = "1.0.40" -serde = { version = "1", features = ["derive", "rc"] } -tracing = { version = "0" } -serde_ignored = "0" -serde-value = "0" -serde_path_to_error = "0" -humantime = "2" -num-order = "1" +thiserror = "1.0.44" +serde = { version = "1.0.183", features = ["derive", "rc"] } +tracing = { version = "0.1.37" } +serde_ignored = "0.1.9" +serde-value = "0.7.0" +serde_path_to_error = "0.1.14" +humantime = "2.1.0" +num-order = "1.0.4" +uuid = { version = "1.4.1", features = ["serde"] } # Parsing files -serde_json = "1" -serde_yaml = "0" -toml = "0" +serde_json = "1.0.104" +serde_yaml = "0.9.25" +toml = "0.7.6" # CLI -clap = { version = "4", features = ["cargo", "string"] } -convert_case = "0" +clap = { version = "4.3.21", features = ["cargo", "string"] } +convert_case = "0.6.0" # Derive macro config_derive = { path = "../config_derive" } diff --git a/config/config/src/key.rs b/config/config/src/key.rs index 8c26e4a6..586ae005 100644 --- a/config/config/src/key.rs +++ b/config/config/src/key.rs @@ -293,7 +293,7 @@ impl KeyGraphBuilder { // If this function is not called and the builder is dropped, then the Arc will be dropped, // Therefore the weak pointer will be empty and the graph will be rebuilt when the builder is created again. unsafe { - let graph_ptr = self.graph.as_ref() as *const KeyGraph as *mut KeyGraph; + let graph_ptr = Arc::as_ptr(&self.graph) as *mut KeyGraph; let _ = std::mem::replace(&mut *graph_ptr, graph); } diff --git a/config/config/src/primitives.rs b/config/config/src/primitives.rs index c20acbb6..045db3ab 100644 --- a/config/config/src/primitives.rs +++ b/config/config/src/primitives.rs @@ -87,6 +87,7 @@ macro_rules! out_of_bounds { use num_order::NumOrd; use serde::Deserialize; +use uuid::Uuid; macro_rules! bounds_check { ($value:ident, $path:ident => $enum:tt, $ty:ty) => { @@ -724,7 +725,7 @@ impl Config for SystemTime { } Value::Option(Some(value)) => ::transform(path, *value), r => { - let system_time = std::time::SystemTime::deserialize(r.clone()).map_err(|_| { + let system_time = Self::deserialize(r.clone()).map_err(|_| { ConfigError::new(ConfigErrorType::ValidationError(format!( "{:?} is not convertable into SystemTime", r @@ -744,6 +745,52 @@ impl Config for SystemTime { } } +impl Config for Uuid { + fn graph() -> Arc { + Arc::new(KeyGraph::String) + } + + fn transform(path: &KeyPath, value: Value) -> Result { + match value { + Value::String(s) => { + let uuid = s.parse::().map_err(|_| { + ConfigError::new(ConfigErrorType::ValidationError(format!( + "failed to convert {} into Uuid", + s + ))) + .with_path(path.clone()) + })?; + + Ok(serde_value::to_value(uuid).map_err(|_| { + ConfigError::new(ConfigErrorType::ValidationError(format!( + "{:?} is not convertable into Uuid", + s + ))) + .with_path(path.clone()) + })?) + } + Value::Option(Some(value)) => ::transform(path, *value), + r => { + let uuid = Self::deserialize(r.clone()).map_err(|_| { + ConfigError::new(ConfigErrorType::ValidationError(format!( + "{:?} is not convertable into Uuid", + r + ))) + .with_path(path.clone()) + })?; + + Ok(serde_value::to_value(uuid).map_err(|_| { + ConfigError::new(ConfigErrorType::ValidationError(format!( + "{:?} is not convertable into Uuid", + r + ))) + .with_path(path.clone()) + })?) + } + } + } +} + // Compound Types macro_rules! impl_slice { diff --git a/config/config_derive/Cargo.toml b/config/config_derive/Cargo.toml index 6fc4a9eb..b984fcde 100644 --- a/config/config_derive/Cargo.toml +++ b/config/config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "config_derive" -version = "0.1.0" +version = "0.0.1" edition = "2021" authors = ["Scuffle "] description = "Extensible config solution" @@ -14,6 +14,6 @@ keywords = ["config", "cli", "proc-macro"] proc-macro = true [dependencies] -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } +proc-macro2 = "1.0.66" +quote = "1.0.32" +syn = { version = "2.0.28", features = ["full"] } diff --git a/dev-stack/db.docker-compose.yml b/dev-stack/db.docker-compose.yml index 247518d4..3e7caa58 100644 --- a/dev-stack/db.docker-compose.yml +++ b/dev-stack/db.docker-compose.yml @@ -9,15 +9,14 @@ services: - "5432:26257" - "8080:8080" - rmq: - image: bitnami/rabbitmq:latest - environment: - RABBITMQ_USERNAME: rabbitmq - RABBITMQ_PASSWORD: rabbitmq - RABBITMQ_VHOSTS: scuffle + nats: + image: nats:latest ports: - - "5672:5672" - - "15672:15672" + - "4222:4222" + - "8222:8222" + - "6222:6222" + command: + - '-js' redis: image: redis:latest diff --git a/docker/api.Dockerfile b/docker/platform/api.Dockerfile similarity index 66% rename from docker/api.Dockerfile rename to docker/platform/api.Dockerfile index fbf9eb3b..6edde951 100644 --- a/docker/api.Dockerfile +++ b/docker/platform/api.Dockerfile @@ -1,12 +1,12 @@ FROM ubuntu:latest LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle -LABEL org.opencontainers.image.description="API Container for ScuffleTV" +LABEL org.opencontainers.image.description="Platform API Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause WORKDIR /app -RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/api,dst=/mount/api /cve.sh && \ +RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/platform-api,dst=/mount/api /cve.sh && \ cp /mount/api /app/api && \ chmod +x /app/api diff --git a/docker/website.Dockerfile b/docker/platform/website.Dockerfile similarity index 68% rename from docker/website.Dockerfile rename to docker/platform/website.Dockerfile index f2f8ceaf..aa1ba3c2 100644 --- a/docker/website.Dockerfile +++ b/docker/platform/website.Dockerfile @@ -1,13 +1,13 @@ FROM node:alpine LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle -LABEL org.opencontainers.image.description="Website Container for ScuffleTV" +LABEL org.opencontainers.image.description="Platform Website Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause RUN apk add --upgrade libcrypto3 libssl3 --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community -COPY frontend/website/build /app/build -COPY frontend/website/entry.js /app/index.js +COPY platform/website/build /app/build +COPY platform/website/entry.js /app/index.js RUN echo "{\"type\": \"module\"}" > /app/package.json && chown -R 1000:1000 /app @@ -15,6 +15,6 @@ WORKDIR /app STOPSIGNAL SIGTERM -# USER 1000 +USER 1000 CMD ["node", "."] diff --git a/docker/edge.Dockerfile b/docker/video/edge.Dockerfile similarity index 66% rename from docker/edge.Dockerfile rename to docker/video/edge.Dockerfile index 026bb69e..2782a81f 100644 --- a/docker/edge.Dockerfile +++ b/docker/video/edge.Dockerfile @@ -1,12 +1,12 @@ FROM ubuntu:latest LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle -LABEL org.opencontainers.image.description="Edge Container for ScuffleTV" +LABEL org.opencontainers.image.description="Video Edge Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause WORKDIR /app -RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/edge,dst=/mount/edge /cve.sh && \ +RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/video-edge,dst=/mount/edge /cve.sh && \ cp /mount/edge /app/edge && \ chmod +x /app/edge diff --git a/docker/ingest.Dockerfile b/docker/video/ingest.Dockerfile similarity index 66% rename from docker/ingest.Dockerfile rename to docker/video/ingest.Dockerfile index 743cc389..f972e21c 100644 --- a/docker/ingest.Dockerfile +++ b/docker/video/ingest.Dockerfile @@ -1,12 +1,12 @@ FROM ubuntu:latest LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle -LABEL org.opencontainers.image.description="Ingest Container for ScuffleTV" +LABEL org.opencontainers.image.description="Video Ingest Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause WORKDIR /app -RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/ingest,dst=/mount/ingest /cve.sh && \ +RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/video-ingest,dst=/mount/ingest /cve.sh && \ cp /mount/ingest /app/ingest && \ chmod +x /app/ingest diff --git a/docker/transcoder.Dockerfile b/docker/video/transcoder.Dockerfile similarity index 66% rename from docker/transcoder.Dockerfile rename to docker/video/transcoder.Dockerfile index a9656273..626c6b65 100644 --- a/docker/transcoder.Dockerfile +++ b/docker/video/transcoder.Dockerfile @@ -1,12 +1,12 @@ FROM ubuntu:latest LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle -LABEL org.opencontainers.image.description="Transcoder Container for ScuffleTV" +LABEL org.opencontainers.image.description="Video Transcoder Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause WORKDIR /app -RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/transcoder,dst=/mount/transcoder /cve.sh && \ +RUN --mount=type=bind,src=docker/cve.sh,dst=/cve.sh --mount=type=bind,src=target/x86_64-unknown-linux-gnu/release/video-transcoder,dst=/mount/transcoder /cve.sh && \ cp /mount/transcoder /app/transcoder && \ chmod +x /app/transcoder diff --git a/maskfile.md b/maskfile.md index 2c862599..c7a82045 100644 --- a/maskfile.md +++ b/maskfile.md @@ -52,7 +52,7 @@ fi if [ "$no_gql_prepare" != "true" ]; then $MASK gql prepare - export SCHEMA_URL=$(realpath frontend/website/schema.graphql) + export SCHEMA_URL=$(realpath platform/website/schema.graphql) fi if [ "$no_player" != "true" ]; then @@ -238,7 +238,6 @@ fi if [ "$no_rust" != "true" ]; then cargo audit - cd frontend/player && cargo audit && cd ../.. fi if [ "$no_js" != "true" ]; then @@ -272,7 +271,7 @@ if [[ "$verbose" == "true" ]]; then fi if [ "$no_rust" != "true" ]; then - cargo llvm-cov nextest --lcov --output-path lcov.info --ignore-filename-regex "(main\.rs|tests|.*\.nocov\.rs)" --workspace --fail-fast -r + cargo llvm-cov nextest --lcov --output-path lcov.info --ignore-filename-regex "(main\.rs|tests|.*\.nocov\.rs)" --workspace --fail-fast -r --exclude video-player fi if [ "$no_js" != "true" ]; then @@ -299,7 +298,7 @@ if [[ "$verbose" == "true" ]]; then fi sqlx database create -sqlx migrate run --source ./backend/migrations +sqlx migrate run --source ./platform/migrations ``` #### create (name) @@ -312,7 +311,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -sqlx migrate add "$name" --source ./backend/migrations -r +sqlx migrate add "$name" --source ./platform/migrations -r ``` ### rollback @@ -325,7 +324,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -sqlx migrate revert --source ./backend/migrations +sqlx migrate revert --source ./platform/migrations ``` ### prepare @@ -362,7 +361,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -sqlx database reset --source ./backend/migrations +sqlx database reset --source ./platform/migrations ``` ### up @@ -580,7 +579,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -cargo run --bin api-gql-generator | pnpm exec prettier --stdin-filepath schema.graphql > schema.graphql +cargo run --bin platform-api -- --export-gql | pnpm exec prettier --stdin-filepath schema.graphql > schema.graphql ``` ### check @@ -593,7 +592,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -cargo run --bin api-gql-generator | pnpm exec prettier --stdin-filepath schema.graphql | diff - schema.graphql || (echo "GraphQL schema is out of date. Run 'mask gql prepare' to update it." && exit 1) +cargo run --bin platform-api -- --export-gql | pnpm exec prettier --stdin-filepath schema.graphql | diff - schema.graphql || (echo "GraphQL schema is out of date. Run 'mask gql prepare' to update it." && exit 1) echo "GraphQL schema is up to date." ``` diff --git a/package.json b/package.json index ced2ecab..365584e9 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "private": true, "devDependencies": { - "@commitlint/cli": "^17.6.6", - "@commitlint/config-conventional": "^17.6.6", - "commitlint": "^17.6.6", + "@commitlint/cli": "^17.7.0", + "@commitlint/config-conventional": "^17.7.0", + "commitlint": "^17.7.0", "husky": "^8.0.3", - "prettier": "^3.0.0" + "prettier": "^3.0.1" }, "scripts": { "prepare": "husky install", diff --git a/platform/api/Cargo.toml b/platform/api/Cargo.toml index 98f22d5c..870387a6 100644 --- a/platform/api/Cargo.toml +++ b/platform/api/Cargo.toml @@ -1,65 +1,50 @@ [package] -name = "api" -version = "0.1.0" +name = "platform-api" +version = "0.0.1" edition = "2021" authors = ["Scuffle "] description = "Scuffle API server" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[[bin]] -name = "api" -path = "src/main.rs" - -[[bin]] -# This is a dummy binary to export the GraphQL schema for frontend type generation. -name = "api-gql-generator" -path = "src/gql.nocov.rs" -test = false -bench = false - [dependencies] -anyhow = "1" -tracing = "0" -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -hyper = { version = "0", features = ["full"] } -common = { path = "../../common" } -sqlx = { git="https://github.com/launchbadge/sqlx", branch="main", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } -routerify = "3" -serde_json = "1" -reqwest = { version = "0", features = ["json"] } -chrono = { version = "0", default-features = false, features = ["serde", "clock"] } -async-graphql = { version = "5", features = ["apollo_tracing", "apollo_persisted_queries", "tracing", "opentelemetry", "dataloader", "string_number", "uuid"] } -hyper-tungstenite = "0" -async-stream = "0" -futures = "0" -futures-util = "0" -arc-swap = "1" -jwt = "0" -hmac = "0" -sha2 = "0" -negative-impl = "0" -tonic = { version = "0", features = ["tls"] } -prost = "0" -uuid = "1" -bitmask-enum = "2" -argon2 = "0" -email_address = "0" -rand = "0" -lapin = { version = "2", features = ["native-tls"] } -tokio-stream = { version = "0", features = ["sync"] } -fred = { version = "6", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } -config = { path = "../../config/config" } +anyhow = "1.0.72" +tracing = "0.1.37" +tokio = { version = "1.29.1", features = ["full"] } +serde = { version = "1.0.183", features = ["derive"] } +hyper = { version = "0.14.27", features = ["full"] } +common = { workspace = true } +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +routerify = "3.0.0" +serde_json = "1.0.104" +reqwest = { version = "0.11.18", features = ["json"] } +chrono = { version = "0.4.26", default-features = false, features = ["serde", "clock"] } +async-graphql = { version = "6.0.1", features = ["apollo_tracing", "apollo_persisted_queries", "tracing", "opentelemetry", "dataloader", "string_number", "uuid"] } +hyper-tungstenite = "0.11.1" +async-stream = "0.3.5" +futures = "0.3.28" +futures-util = "0.3.28" +arc-swap = "1.6.0" +jwt = "0.16.0" +hmac = "0.12.1" +sha2 = "0.10.7" +negative-impl = "0.1.4" +tonic = { version = "0.9.2", features = ["tls"] } +prost = "0.11.9" +uuid = "1.4.1" +bitmask-enum = "2.2.1" +argon2 = "0.5.1" +email_address = "0.2.4" +rand = "0.8.5" +lapin = { version = "2.3.1", features = ["native-tls"] } +tokio-stream = { version = "0.1.14", features = ["sync"] } +fred = { version = "6.3.0", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } -[dev-dependencies] -tempfile = "3" -dotenvy = "0" -http = "0" -tokio-tungstenite = "0" -portpicker = "0" -serial_test = "2" +config = { workspace = true } +pb = { workspace = true } -[build-dependencies] -tonic-build = "0" -prost-build = "0" +[dev-dependencies] +tempfile = "3.7.1" +dotenvy = "0.15.1" +http = "0.2.9" +tokio-tungstenite = "0.20.0" +portpicker = "0.1.1" +serial_test = "2.0.0" diff --git a/platform/api/build.rs b/platform/api/build.rs deleted file mode 100644 index 346ada42..00000000 --- a/platform/api/build.rs +++ /dev/null @@ -1,21 +0,0 @@ -const PROTO_DIR: &str = "../../proto"; - -fn main() { - let mut config = prost_build::Config::new(); - - config.protoc_arg("--experimental_allow_proto3_optional"); - config.bytes(["."]); - - tonic_build::configure() - .compile_with_config( - config, - &[ - format!("{}/scuffle/events/ingest.proto", PROTO_DIR), - format!("{}/scuffle/events/api.proto", PROTO_DIR), - format!("{}/scuffle/backend/api.proto", PROTO_DIR), - format!("{}/scuffle/utils/health.proto", PROTO_DIR), - ], - &[PROTO_DIR], - ) - .unwrap(); -} diff --git a/platform/api/src/api/v1/gql/auth.rs b/platform/api/src/api/v1/gql/auth.rs index 47a66846..25611d72 100644 --- a/platform/api/src/api/v1/gql/auth.rs +++ b/platform/api/src/api/v1/gql/auth.rs @@ -61,44 +61,45 @@ impl AuthMutation { let login_duration = validity.unwrap_or(60 * 60 * 24 * 7); // 7 days let expires_at = Utc::now() + Duration::seconds(login_duration as i64); + todo!(); // TODO: maybe look to batch this - let session = sqlx::query_as!( - session::Model, - "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", - user.id, - expires_at, - ) - .fetch_one(&*global.db) - .await - .map_err_gql("Failed to create session")?; - - let jwt = JwtState::from(session.clone()); - - let token = jwt - .serialize(global) - .ok_or((GqlError::InternalServerError, "Failed to serialize JWT"))?; - - let permissions = global - .user_permisions_by_id_loader - .load_one(user.id) - .await - .map_err_gql("Failed to fetch user permissions")? - .unwrap_or_default(); - - // We need to update the request context with the new session - if update_context.unwrap_or(true) { - request_context.set_session(Some((session.clone(), permissions))); - } - - Ok(Session { - id: session.id, - token, - user_id: session.user_id, - expires_at: session.expires_at.into(), - last_used_at: session.last_used_at.into(), - created_at: session.created_at.into(), - _user: Some(user.into()), - }) + // let session = sqlx::query_as!( + // session::Model, + // "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", + // user.id, + // expires_at, + // ) + // .fetch_one(&*global.db) + // .await + // .map_err_gql("Failed to create session")?; + + // let jwt = JwtState::from(session.clone()); + + // let token = jwt + // .serialize(global) + // .ok_or((GqlError::InternalServerError, "Failed to serialize JWT"))?; + + // let permissions = global + // .user_permisions_by_id_loader + // .load_one(user.id) + // .await + // .map_err_gql("Failed to fetch user permissions")? + // .unwrap_or_default(); + + // // We need to update the request context with the new session + // if update_context.unwrap_or(true) { + // request_context.set_session(Some((session.clone(), permissions))); + // } + + // Ok(Session { + // id: session.id, + // token, + // user_id: session.user_id, + // expires_at: session.expires_at.into(), + // last_used_at: session.last_used_at.into(), + // created_at: session.created_at.into(), + // _user: Some(user.into()), + // }) } /// Login with a session token. If via websocket this will authenticate the websocket connection. @@ -120,46 +121,48 @@ impl AuthMutation { .with_field(vec!["sessionToken"]), )?; - // TODO: maybe look to batch this - let session = sqlx::query_as!( - session::Model, - "UPDATE sessions SET last_used_at = NOW() WHERE id = $1 RETURNING *", - jwt.session_id, - ) - .fetch_optional(&*global.db) - .await - .map_err_gql("failed to fetch session")? - .ok_or( - GqlError::InvalidInput - .with_message("Invalid session token") - .with_field(vec!["sessionToken"]), - )?; - - if !session.is_valid() { - return Err(GqlError::InvalidSession.with_message("Session token is no longer valid")); - } - - let permissions = global - .user_permisions_by_id_loader - .load_one(session.user_id) - .await - .map_err_gql("Failed to fetch user permissions")? - .unwrap_or_default(); - - // We need to update the request context with the new session - if update_context.unwrap_or(true) { - request_context.set_session(Some((session.clone(), permissions))); - } + todo!() - Ok(Session { - id: session.id, - token: session_token, - user_id: session.user_id, - expires_at: session.expires_at.into(), - last_used_at: session.last_used_at.into(), - created_at: session.created_at.into(), - _user: None, - }) + // TODO: maybe look to batch this + // let session = sqlx::query_as!( + // session::Model, + // "UPDATE sessions SET last_used_at = NOW() WHERE id = $1 RETURNING *", + // jwt.session_id, + // ) + // .fetch_optional(&*global.db) + // .await + // .map_err_gql("failed to fetch session")? + // .ok_or( + // GqlError::InvalidInput + // .with_message("Invalid session token") + // .with_field(vec!["sessionToken"]), + // )?; + + // if !session.is_valid() { + // return Err(GqlError::InvalidSession.with_message("Session token is no longer valid")); + // } + + // let permissions = global + // .user_permisions_by_id_loader + // .load_one(session.user_id) + // .await + // .map_err_gql("Failed to fetch user permissions")? + // .unwrap_or_default(); + + // // We need to update the request context with the new session + // if update_context.unwrap_or(true) { + // request_context.set_session(Some((session.clone(), permissions))); + // } + + // Ok(Session { + // id: session.id, + // token: session_token, + // user_id: session.user_id, + // expires_at: session.expires_at.into(), + // last_used_at: session.last_used_at.into(), + // created_at: session.created_at.into(), + // _user: None, + // }) } /// If successful will return a new session for the account which just got created. @@ -228,65 +231,67 @@ impl AuthMutation { .await .map_err_gql("Failed to create user")?; - // TODO: maybe look to batch this - let user = - sqlx::query_as!(user::Model, - "INSERT INTO users (username, display_name, password_hash, email, stream_key) VALUES ($1, $2, $3, $4, $5) RETURNING *", - username, - display_name, - user::hash_password(&password), - email, - user::generate_stream_key(), - ) - .fetch_one(&mut *tx) - .await - .map_err_gql("Failed to create user")?; - - let login_duration = validity.unwrap_or(60 * 60 * 24 * 7); // 7 days - let expires_at = Utc::now() + Duration::seconds(login_duration as i64); + todo!(); // TODO: maybe look to batch this - let session = sqlx::query_as!( - session::Model, - "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", - user.id, - expires_at, - ) - .fetch_one(&mut *tx) - .await - .map_err_gql("Failed to create session")?; - - let jwt = JwtState::from(session.clone()); - - let token = jwt - .serialize(global) - .ok_or((GqlError::InternalServerError, "Failed to serialize JWT"))?; - - tx.commit() - .await - .map_err_gql("Failed to commit transaction")?; - - let permissions = global - .user_permisions_by_id_loader - .load_one(user.id) - .await - .map_err_gql("Failed to fetch user permissions")? - .unwrap_or_default(); - - // We need to update the request context with the new session - if update_context.unwrap_or(true) { - request_context.set_session(Some((session.clone(), permissions))); - } - - Ok(Session { - id: session.id, - token, - user_id: session.user_id, - expires_at: session.expires_at.into(), - last_used_at: session.last_used_at.into(), - created_at: session.created_at.into(), - _user: Some(user.into()), - }) + // let user = + // sqlx::query_as!(user::Model, + // "INSERT INTO users (username, display_name, password_hash, email, stream_key) VALUES ($1, $2, $3, $4, $5) RETURNING *", + // username, + // display_name, + // user::hash_password(&password), + // email, + // user::generate_stream_key(), + // ) + // .fetch_one(&mut *tx) + // .await + // .map_err_gql("Failed to create user")?; + + // let login_duration = validity.unwrap_or(60 * 60 * 24 * 7); // 7 days + // let expires_at = Utc::now() + Duration::seconds(login_duration as i64); + + // // TODO: maybe look to batch this + // let session = sqlx::query_as!( + // session::Model, + // "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", + // user.id, + // expires_at, + // ) + // .fetch_one(&mut *tx) + // .await + // .map_err_gql("Failed to create session")?; + + // let jwt = JwtState::from(session.clone()); + + // let token = jwt + // .serialize(global) + // .ok_or((GqlError::InternalServerError, "Failed to serialize JWT"))?; + + // tx.commit() + // .await + // .map_err_gql("Failed to commit transaction")?; + + // let permissions = global + // .user_permisions_by_id_loader + // .load_one(user.id) + // .await + // .map_err_gql("Failed to fetch user permissions")? + // .unwrap_or_default(); + + // // We need to update the request context with the new session + // if update_context.unwrap_or(true) { + // request_context.set_session(Some((session.clone(), permissions))); + // } + + // Ok(Session { + // id: session.id, + // token, + // user_id: session.user_id, + // expires_at: session.expires_at.into(), + // last_used_at: session.last_used_at.into(), + // created_at: session.created_at.into(), + // _user: Some(user.into()), + // }) } /// Logout the user with the given session token. This will invalidate the session token. @@ -322,18 +327,20 @@ impl AuthMutation { } }; + todo!(); + // TODO: maybe look to batch this - sqlx::query!( - "UPDATE sessions SET invalidated_at = NOW() WHERE id = $1", - session_id - ) - .execute(&*global.db) - .await - .map_err_gql("Failed to update session")?; - - if jwt.is_none() { - request_context.set_session(None); - } + // sqlx::query!( + // "UPDATE sessions SET invalidated_at = NOW() WHERE id = $1", + // session_id + // ) + // .execute(&*global.db) + // .await + // .map_err_gql("Failed to update session")?; + + // if jwt.is_none() { + // request_context.set_session(None); + // } Ok(true) } diff --git a/platform/api/src/api/v1/gql/chat.rs b/platform/api/src/api/v1/gql/chat.rs index 86dbf16b..690176d1 100644 --- a/platform/api/src/api/v1/gql/chat.rs +++ b/platform/api/src/api/v1/gql/chat.rs @@ -1,6 +1,5 @@ use crate::api::v1::gql::error::ResultExt; use crate::database::chat_message; -use crate::pb; use prost::Message; use super::error::{GqlError, Result}; @@ -24,57 +23,58 @@ impl ChatMutation { #[graphql(desc = "ID of chat room where the message will be send.")] channel_id: Uuid, #[graphql(desc = "Message content that will be published.")] content: String, ) -> Result { - let global = ctx.get_global(); - let request_context = ctx.get_session(); + todo!() + // let global = ctx.get_global(); + // let request_context = ctx.get_session(); - if content.len() > MAX_MESSAGE_LENGTH { - return Err(GqlError::InvalidInput.with_message("Message too long")); - } + // if content.len() > MAX_MESSAGE_LENGTH { + // return Err(GqlError::InvalidInput.with_message("Message too long")); + // } - // TODO: check if user is banned from chat - let (session, _) = request_context - .get_session(global) - .await? - .ok_or_else(|| GqlError::Unauthorized.with_message("You need to be logged in"))?; + // // TODO: check if user is banned from chat + // let (session, _) = request_context + // .get_session(global) + // .await? + // .ok_or_else(|| GqlError::Unauthorized.with_message("You need to be logged in"))?; - // TODO: Check if the user is allowed to send messages in this chat - let channel = global - .user_by_id_loader - .load_one(channel_id) - .await - .map_err_gql("Failed to fetch channel")? - .ok_or_else(|| GqlError::InvalidInput.with_message("Channel not found"))?; + // // TODO: Check if the user is allowed to send messages in this chat + // let channel = global + // .user_by_id_loader + // .load_one(channel_id) + // .await + // .map_err_gql("Failed to fetch channel")? + // .ok_or_else(|| GqlError::InvalidInput.with_message("Channel not found"))?; - let chat_message = sqlx::query_as!( - chat_message::Model, - "INSERT INTO chat_messages (channel_id, author_id, content) VALUES ($1, $2, $3) RETURNING *", - channel.id, - session.user_id, - content, - ).fetch_one(&*global.db).await.map_err_gql("Failed to insert chat message")?; + // let chat_message = sqlx::query_as!( + // chat_message::Model, + // "INSERT INTO chat_messages (channel_id, author_id, content) VALUES ($1, $2, $3) RETURNING *", + // channel.id, + // session.user_id, + // content, + // ).fetch_one(&*global.db).await.map_err_gql("Failed to insert chat message")?; - match global - .redis - .publish( - format!("user:{}:chat:messages", channel.id), - pb::scuffle::events::ChatMessage { - id: chat_message.id.to_string(), - channel_id: chat_message.channel_id.to_string(), - author_id: chat_message.author_id.to_string(), - content: chat_message.content.clone(), - created_at: chat_message.created_at.timestamp(), - } - .encode_to_vec() - .as_slice(), - ) - .await - { - Ok(()) => {} - Err(_) => { - return Err(GqlError::InternalServerError.with_message("Failed to publish message")); - } - }; + // match global + // .redis + // .publish( + // format!("user:{}:chat:messages", channel.id), + // pb::scuffle::internal::platform::events::ChatMessage { + // id: chat_message.id.to_string(), + // channel_id: chat_message.channel_id.to_string(), + // author_id: chat_message.author_id.to_string(), + // content: chat_message.content.clone(), + // created_at: chat_message.created_at.timestamp(), + // } + // .encode_to_vec() + // .as_slice(), + // ) + // .await + // { + // Ok(()) => {} + // Err(_) => { + // return Err(GqlError::InternalServerError.with_message("Failed to publish message")); + // } + // }; - Ok(chat_message.into()) + // Ok(chat_message.into()) } } diff --git a/platform/api/src/api/v1/gql/mod.rs b/platform/api/src/api/v1/gql/mod.rs index 7f20c3e9..559547a5 100644 --- a/platform/api/src/api/v1/gql/mod.rs +++ b/platform/api/src/api/v1/gql/mod.rs @@ -104,7 +104,7 @@ pub fn schema() -> MySchema { } pub fn routes(_global: &Arc) -> Router { - let router = Router::builder() + Router::builder() .data(schema()) .any_method("/", handlers::graphql_handler) .get("/playground", move |_| async move { @@ -115,7 +115,5 @@ pub fn routes(_global: &Arc) -> Router { .expect("failed to build response")) }) .build() - .expect("failed to build router"); - - router + .expect("failed to build router") } diff --git a/platform/api/src/api/v1/gql/models/global_roles.rs b/platform/api/src/api/v1/gql/models/global_roles.rs index 66b5fec7..bdeb53cc 100644 --- a/platform/api/src/api/v1/gql/models/global_roles.rs +++ b/platform/api/src/api/v1/gql/models/global_roles.rs @@ -18,14 +18,15 @@ pub struct GlobalRole { impl From for GlobalRole { fn from(value: global_role::Model) -> Self { - Self { - id: value.id, - created_at: value.created_at.into(), - name: value.name, - description: value.description, - rank: value.rank as i32, - allowed_permissions: value.allowed_permissions.bits(), - denied_permissions: value.denied_permissions.bits(), - } + todo!() + // Self { + // id: value.id, + // created_at: value.created_at.into(), + // name: value.name, + // description: value.description, + // rank: value.rank as i32, + // allowed_permissions: value.allowed_permissions.bits(), + // denied_permissions: value.denied_permissions.bits(), + // } } } diff --git a/platform/api/src/api/v1/gql/subscription/chat.rs b/platform/api/src/api/v1/gql/subscription/chat.rs index 24f0461c..1fee7ad3 100644 --- a/platform/api/src/api/v1/gql/subscription/chat.rs +++ b/platform/api/src/api/v1/gql/subscription/chat.rs @@ -5,13 +5,10 @@ use futures_util::Stream; use prost::Message; use uuid::Uuid; -use crate::{ - api::v1::gql::{ - error::{GqlError, Result, ResultExt}, - ext::ContextExt, - models::chat_message::{ChatMessage, MessageType}, - }, - pb, +use crate::api::v1::gql::{ + error::{GqlError, Result, ResultExt}, + ext::ContextExt, + models::chat_message::{ChatMessage, MessageType}, }; #[derive(Default)] @@ -52,26 +49,27 @@ impl ChatSubscription { Ok(stream!({ yield Ok(welcome_message); while let Ok(message) = message_stream.recv().await { - let event = pb::scuffle::events::ChatMessage::decode( - message.as_bytes().map_err_gql("invalid redis value type")?, - ) - .map_err_gql("failed to decode chat message")?; + todo!() + // let event = pb::scuffle::internal::platform::events::ChatMessage::decode( + // message.as_bytes().map_err_gql("invalid redis value type")?, + // ) + // .map_err_gql("failed to decode chat message")?; - yield Ok(ChatMessage { - id: Uuid::parse_str(&event.id) - .map_err_gql("failed to parse chat message id")?, - author_id: Uuid::parse_str(&event.author_id) - .map_err_gql("failed to parse chat message author id")?, - channel_id: Uuid::parse_str(&event.channel_id) - .map_err_gql("failed to parse chat message channel id")?, - content: event.content, - created_at: Utc - .timestamp_opt(event.created_at, 0) - .single() - .map_err_gql("failed to parse chat message created at")? - .into(), - r#type: MessageType::User, - }); + // yield Ok(ChatMessage { + // id: Uuid::parse_str(&event.id) + // .map_err_gql("failed to parse chat message id")?, + // author_id: Uuid::parse_str(&event.author_id) + // .map_err_gql("failed to parse chat message author id")?, + // channel_id: Uuid::parse_str(&event.channel_id) + // .map_err_gql("failed to parse chat message channel id")?, + // content: event.content, + // created_at: Utc + // .timestamp_opt(event.created_at, 0) + // .single() + // .map_err_gql("failed to parse chat message created at")? + // .into(), + // r#type: MessageType::User, + // }); } })) } diff --git a/platform/api/src/api/v1/gql/subscription/user.rs b/platform/api/src/api/v1/gql/subscription/user.rs index 8d91e35e..5720bbe4 100644 --- a/platform/api/src/api/v1/gql/subscription/user.rs +++ b/platform/api/src/api/v1/gql/subscription/user.rs @@ -3,12 +3,9 @@ use futures_util::Stream; use prost::Message; use uuid::Uuid; -use crate::{ - api::v1::gql::{ - error::{GqlError, Result, ResultExt}, - ext::ContextExt, - }, - pb, +use crate::api::v1::gql::{ + error::{GqlError, Result, ResultExt}, + ext::ContextExt, }; #[derive(Default)] @@ -53,23 +50,24 @@ impl UserSubscription { }); while let Ok(message) = subscription.recv().await { - let event = pb::scuffle::events::UserDisplayName::decode( - message.as_bytes().map_err_gql("invalid redis value")?, - ) - .map_err_gql("failed to decode user display name")?; + todo!() + // let event = pb::scuffle::internal::platform::events::UserDisplayName::decode( + // message.as_bytes().map_err_gql("invalid redis value")?, + // ) + // .map_err_gql("failed to decode user display name")?; - if let Some(username) = event.username { - user.username = username; - } + // if let Some(username) = event.username { + // user.username = username; + // } - if let Some(display_name) = event.display_name { - user.display_name = display_name; - } + // if let Some(display_name) = event.display_name { + // user.display_name = display_name; + // } - yield Ok(DisplayNameStream { - display_name: user.display_name.clone(), - username: user.username.clone(), - }); + // yield Ok(DisplayNameStream { + // display_name: user.display_name.clone(), + // username: user.username.clone(), + // }); } })) } diff --git a/platform/api/src/config.rs b/platform/api/src/config.rs index 41d4c452..1ab17d95 100644 --- a/platform/api/src/config.rs +++ b/platform/api/src/config.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use anyhow::Result; -use common::config::{LoggingConfig, RedisConfig, RmqConfig, TlsConfig}; +use common::config::{LoggingConfig, RedisConfig, TlsConfig}; #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] @@ -13,6 +13,9 @@ pub struct AppConfig { /// Name of this instance pub name: String, + /// If we should export the GraphQL schema, if set to true, the schema will be exported to the stdout, and the program will exit. + pub export_gql: bool, + /// The logging config pub logging: LoggingConfig, @@ -31,9 +34,6 @@ pub struct AppConfig { /// GRPC Config pub grpc: GrpcConfig, - /// RMQ Config - pub rmq: RmqConfig, - /// Redis configuration pub redis: RedisConfig, } @@ -134,13 +134,13 @@ impl Default for AppConfig { Self { config_file: Some("config".to_string()), name: "scuffle-api".to_string(), + export_gql: false, logging: LoggingConfig::default(), api: ApiConfig::default(), database: DatabaseConfig::default(), grpc: GrpcConfig::default(), jwt: JwtConfig::default(), turnstile: TurnstileConfig::default(), - rmq: RmqConfig::default(), redis: RedisConfig::default(), } } diff --git a/platform/api/src/database/channel_role.rs b/platform/api/src/database/channel_role.rs index fe3416c0..d5028cf8 100644 --- a/platform/api/src/database/channel_role.rs +++ b/platform/api/src/database/channel_role.rs @@ -2,7 +2,7 @@ use bitmask_enum::bitmask; use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] /// A role that can be granted to a user in a channel. /// Roles can allow or deny permissions to a user. /// The rank indicates the order in which the role permissions are applied. diff --git a/platform/api/src/database/channel_role_grant.rs b/platform/api/src/database/channel_role_grant.rs index 1c9a1dda..4a025ba5 100644 --- a/platform/api/src/database/channel_role_grant.rs +++ b/platform/api/src/database/channel_role_grant.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] /// A grant of a channel role to a user. /// This allows for channel owners to grant roles to other users in their channel. /// See the `channel_role` table for more information. diff --git a/platform/api/src/database/chat_message.rs b/platform/api/src/database/chat_message.rs index 4d701e92..67e334ea 100644 --- a/platform/api/src/database/chat_message.rs +++ b/platform/api/src/database/chat_message.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] pub struct Model { /// The unique identifier for the chat message. pub id: Uuid, diff --git a/platform/api/src/database/global_role.rs b/platform/api/src/database/global_role.rs index aeec454b..78cae776 100644 --- a/platform/api/src/database/global_role.rs +++ b/platform/api/src/database/global_role.rs @@ -2,7 +2,7 @@ use bitmask_enum::bitmask; use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] /// A role that can be granted to a user globally. /// Roles can allow or deny permissions to a user. /// The rank indicates the order in which the role permissions are applied. @@ -17,9 +17,9 @@ pub struct Model { /// The rank of the role. (higher rank = priority) (-1 is default role) pub rank: i64, /// The permissions granted by this role. - pub allowed_permissions: Permission, + // pub allowed_permissions: Permission, /// The permissions denied by this role. - pub denied_permissions: Permission, + // pub denied_permissions: Permission, /// The time the role was created. pub created_at: DateTime, } diff --git a/platform/api/src/database/global_role_grant.rs b/platform/api/src/database/global_role_grant.rs index e5df3775..9f4d3295 100644 --- a/platform/api/src/database/global_role_grant.rs +++ b/platform/api/src/database/global_role_grant.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] /// A grant of a global role to a user. /// This allows for Admins to grant roles to other users. /// See the `global_role` table for more information. diff --git a/platform/api/src/database/mod.rs b/platform/api/src/database/mod.rs index f21466ac..14c45c5f 100644 --- a/platform/api/src/database/mod.rs +++ b/platform/api/src/database/mod.rs @@ -3,7 +3,6 @@ pub mod channel_role_grant; pub mod chat_message; pub mod global_role; pub mod global_role_grant; -pub mod protobuf; pub mod session; pub mod stream; pub mod stream_bitrate_update; diff --git a/platform/api/src/database/protobuf.rs b/platform/api/src/database/protobuf.rs deleted file mode 100644 index b004118d..00000000 --- a/platform/api/src/database/protobuf.rs +++ /dev/null @@ -1,64 +0,0 @@ -#[derive(Debug, Clone, Default)] -pub enum ProtobufValue { - #[default] - None, - Some(T), - Err(prost::DecodeError), -} - -impl ProtobufValue { - #[allow(dead_code)] - pub fn unwrap(self) -> Option { - match self { - Self::Some(data) => Some(data), - Self::None => None, - Self::Err(err) => panic!( - "called `ProtobufValue::unwrap()` on a `Err` value: {:?}", - err - ), - } - } -} - -impl From> for ProtobufValue -where - ProtobufValue: From, -{ - fn from(data: Option) -> Self { - match data { - Some(data) => Self::from(data), - None => Self::None, - } - } -} - -impl From> for ProtobufValue { - fn from(data: Vec) -> Self { - match T::decode(data.as_slice()) { - Ok(variants) => Self::Some(variants), - Err(e) => Self::Err(e), - } - } -} - -impl PartialEq for ProtobufValue { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::None, Self::None) => true, - (Self::Some(a), Self::Some(b)) => a == b, - _ => false, - } - } -} - -impl PartialEq> - for ProtobufValue -{ - fn eq(&self, other: &Option) -> bool { - match (self, other) { - (Self::None, None) => true, - (Self::Some(a), Some(b)) => a == b, - _ => false, - } - } -} diff --git a/platform/api/src/database/session.rs b/platform/api/src/database/session.rs index 1f8703e5..2de21ae8 100644 --- a/platform/api/src/database/session.rs +++ b/platform/api/src/database/session.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] pub struct Model { /// The unique identifier for the session. pub id: Uuid, diff --git a/platform/api/src/database/stream.rs b/platform/api/src/database/stream.rs index 5b3798ff..c618e111 100644 --- a/platform/api/src/database/stream.rs +++ b/platform/api/src/database/stream.rs @@ -1,8 +1,8 @@ -use crate::pb::scuffle::types::StreamState; use chrono::{DateTime, Utc}; +// use pb::scuffle::internal::video::types::StreamState; use uuid::Uuid; -use super::protobuf::ProtobufValue; +// use super::protobuf::ProtobufValue; #[derive(Debug, Clone, Default, Copy, Eq, PartialEq)] #[repr(i64)] @@ -66,7 +66,7 @@ pub struct Model { /// The connection which owns the stream. pub connection_id: Uuid, /// The Stream Variants - pub state: ProtobufValue, + // pub state: ProtobufValue, /// The time the stream was created. pub created_at: DateTime, /// The time the stream was last updated. diff --git a/platform/api/src/database/user.rs b/platform/api/src/database/user.rs index cb3a2b52..6d301fce 100644 --- a/platform/api/src/database/user.rs +++ b/platform/api/src/database/user.rs @@ -26,7 +26,7 @@ impl From for LiveState { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, sqlx::FromRow)] pub struct Model { /// The unique identifier for the user. pub id: Uuid, diff --git a/platform/api/src/dataloader/session.rs b/platform/api/src/dataloader/session.rs index ba8e1159..3696556e 100644 --- a/platform/api/src/dataloader/session.rs +++ b/platform/api/src/dataloader/session.rs @@ -22,17 +22,15 @@ impl Loader for SessionByIdLoader { type Error = Arc; async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { - let results = sqlx::query_as!( - session::Model, - "SELECT * FROM sessions WHERE id = ANY($1)", - &keys - ) - .fetch_all(&*self.db) - .await - .map_err(|e| { - tracing::error!("Failed to fetch sessions: {}", e); - Arc::new(e) - })?; + let results: Vec = + sqlx::query_as("SELECT * FROM sessions WHERE id = ANY($1)") + .bind(keys) + .fetch_all(&*self.db) + .await + .map_err(|e| { + tracing::error!("Failed to fetch sessions: {}", e); + Arc::new(e) + })?; let mut map = HashMap::new(); diff --git a/platform/api/src/dataloader/stream.rs b/platform/api/src/dataloader/stream.rs index df64b95f..cc24669c 100644 --- a/platform/api/src/dataloader/stream.rs +++ b/platform/api/src/dataloader/stream.rs @@ -22,25 +22,25 @@ impl Loader for StreamByIdLoader { type Error = Arc; async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { - let results = sqlx::query_as!( - stream::Model, - "SELECT * FROM streams WHERE id = ANY($1)", - &keys - ) - .fetch_all(&*self.db) - .await - .map_err(|e| { - tracing::error!("Failed to fetch streams: {}", e); - Arc::new(e) - })?; + // let results: Vec = sqlx::query_as( + // "SELECT * FROM streams WHERE id = ANY($1)", + // ) + // .bind(keys) + // .fetch_all(&*self.db) + // .await + // .map_err(|e| { + // tracing::error!("Failed to fetch streams: {}", e); + // Arc::new(e) + // })?; - let mut map = HashMap::new(); + // let mut map = HashMap::new(); - for result in results { - map.insert(result.id, result); - } + // for result in results { + // map.insert(result.id, result); + // } - Ok(map) + // Ok(map) + todo!() } } @@ -61,24 +61,25 @@ impl Loader for ActiveStreamsByUserIdLoader { type Error = Arc; async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { - let results = sqlx::query_as!( - stream::Model, - "SELECT * FROM streams WHERE channel_id = ANY($1) AND deleted = false AND ready_state = 1 ORDER BY created_at DESC", - &keys - ) - .fetch_all(&*self.db) - .await - .map_err(|e| { - tracing::error!("Failed to fetch streams: {}", e); - Arc::new(e) - })?; + // let results: Vec = sqlx::query_as( + // "SELECT * FROM streams WHERE channel_id = ANY($1) AND deleted = false AND ready_state = 1 ORDER BY created_at DESC", + // ) + // .bind(keys) + // .fetch_all(&*self.db) + // .await + // .map_err(|e| { + // tracing::error!("Failed to fetch streams: {}", e); + // Arc::new(e) + // })?; - let mut map = HashMap::new(); + // let mut map = HashMap::new(); - for result in results { - map.insert(result.channel_id, result); - } + // for result in results { + // map.insert(result.channel_id, result); + // } - Ok(map) + todo!() + + // Ok(map) } } diff --git a/platform/api/src/dataloader/user.rs b/platform/api/src/dataloader/user.rs index 37075705..cee12b13 100644 --- a/platform/api/src/dataloader/user.rs +++ b/platform/api/src/dataloader/user.rs @@ -22,21 +22,23 @@ impl Loader for UserByUsernameLoader { type Error = Arc; async fn load(&self, keys: &[String]) -> Result, Self::Error> { - let results = sqlx::query_as!( - user::Model, - "SELECT * FROM users WHERE username = ANY($1)", - &keys - ) - .fetch_all(&*self.db) - .await?; + // let results = sqlx::query_as!( + // user::Model, + // "SELECT * FROM users WHERE username = ANY($1)", + // &keys + // ) + // .fetch_all(&*self.db) + // .await?; - let mut map = HashMap::new(); + // let mut map = HashMap::new(); - for result in results { - map.insert(result.username.clone(), result); - } + // for result in results { + // map.insert(result.username.clone(), result); + // } - Ok(map) + todo!() + + // Ok(map) } } @@ -56,20 +58,20 @@ impl Loader for UserByIdLoader { type Error = Arc; async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { - let results = sqlx::query_as!(user::Model, "SELECT * FROM users WHERE id = ANY($1)", &keys) - .fetch_all(&*self.db) - .await - .map_err(|e| { - tracing::error!("Failed to fetch users: {}", e); - Arc::new(e) - })?; - - let mut map = HashMap::new(); + // let results = sqlx::query_as!(user::Model, "SELECT * FROM users WHERE id = ANY($1)", &keys) + // .fetch_all(&*self.db) + // .await + // .map_err(|e| { + // tracing::error!("Failed to fetch users: {}", e); + // Arc::new(e) + // })?; - for result in results { - map.insert(result.id, result); - } + // let mut map = HashMap::new(); - Ok(map) + // for result in results { + // map.insert(result.id, result); + // } + todo!() + // Ok(map) } } diff --git a/platform/api/src/dataloader/user_permissions.rs b/platform/api/src/dataloader/user_permissions.rs index c8683ca6..9e20416a 100644 --- a/platform/api/src/dataloader/user_permissions.rs +++ b/platform/api/src/dataloader/user_permissions.rs @@ -29,76 +29,17 @@ impl Loader for UserPermissionsByIdLoader { type Error = Arc; async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { - let default_role = sqlx::query_as!( - global_role::Model, - "SELECT * FROM global_roles WHERE rank = -1", - ) - .fetch_optional(&*self.db) - .await - .map_err(|e| { - tracing::error!("Failed to fetch default role: {}", e); - Arc::new(e) - })?; - - let results = sqlx::query!( - "SELECT rg.user_id, r.* FROM global_role_grants rg JOIN global_roles r ON rg.global_role_id = r.id WHERE rg.user_id = ANY($1) ORDER BY rg.user_id, r.rank ASC", - &keys - ) - .fetch_all(&*self.db) - .await.map_err(|e| { - tracing::error!("Failed to fetch user permissions: {}", e); - Arc::new(e) - })?; - - let mut map = HashMap::new(); - - // We only care about the allowed_permissions, because the denied permissions only work on previous roles. - // Since this is the first role, there are no previous roles, so the denied permissions are irrelevant. - if let Some(default_role) = default_role { - for key in keys { - map.insert( - *key, - UserPermission { - user_id: *key, - permissions: default_role.allowed_permissions, - roles: vec![default_role.clone()], - }, - ); - } - } else { - for key in keys { - map.insert( - *key, - UserPermission { - user_id: *key, - permissions: global_role::Permission::default(), - roles: Vec::new(), - }, - ); - } - } - - for result in results { - let current_user = map.entry(result.user_id).or_insert_with(|| UserPermission { - user_id: result.user_id, - permissions: global_role::Permission::default(), - roles: Vec::new(), - }); - - current_user.permissions |= global_role::Permission::from(result.allowed_permissions); - current_user.permissions &= !global_role::Permission::from(result.denied_permissions); - - current_user.roles.push(global_role::Model { - id: result.id, - name: result.name, - description: result.description, - allowed_permissions: result.allowed_permissions.into(), - denied_permissions: result.denied_permissions.into(), - created_at: result.created_at, - rank: result.rank, - }); - } - - Ok(map) + let default_role: Option = + sqlx::query_as("SELECT * FROM global_roles WHERE rank = -1") + .fetch_optional(&*self.db) + .await + .map_err(|e| { + tracing::error!("Failed to fetch default role: {}", e); + Arc::new(e) + })?; + + todo!("xd"); + + Ok(HashMap::new()) } } diff --git a/platform/api/src/global/mod.rs b/platform/api/src/global/mod.rs index b00cfa94..eed91271 100644 --- a/platform/api/src/global/mod.rs +++ b/platform/api/src/global/mod.rs @@ -31,18 +31,11 @@ pub struct GlobalState { pub stream_by_id_loader: DataLoader, pub active_streams_by_user_id_loader: DataLoader, pub subscription_manager: SubscriptionManager, - pub rmq: common::rmq::ConnectionPool, pub redis: RedisPool, } impl GlobalState { - pub fn new( - config: AppConfig, - db: Arc, - rmq: common::rmq::ConnectionPool, - redis: RedisPool, - ctx: Context, - ) -> Self { + pub fn new(config: AppConfig, db: Arc, redis: RedisPool, ctx: Context) -> Self { Self { config, ctx, @@ -54,7 +47,6 @@ impl GlobalState { active_streams_by_user_id_loader: ActiveStreamsByUserIdLoader::new(db.clone()), subscription_manager: SubscriptionManager::default(), db, - rmq, redis, } } diff --git a/platform/api/src/gql.nocov.rs b/platform/api/src/gql.nocov.rs deleted file mode 100644 index 25d66ab0..00000000 --- a/platform/api/src/gql.nocov.rs +++ /dev/null @@ -1,30 +0,0 @@ -#![allow(dead_code)] -#![allow(unused_imports)] -#![allow(unused_variables)] - -mod api; -mod config; -mod database; -mod dataloader; -mod global; -mod pb; -mod subscription; - -use api::v1::gql::schema; -use async_graphql::SDLExportOptions; - -fn main() { - let schema = schema(); - - println!( - "{}", - schema.sdl_with_options( - SDLExportOptions::default() - .federation() - .include_specified_by() - .sorted_arguments() - .sorted_enum_items() - .sorted_fields() - ) - ); -} diff --git a/platform/api/src/grpc/api.rs b/platform/api/src/grpc/api.rs index 82c03e62..1050e9b2 100644 --- a/platform/api/src/grpc/api.rs +++ b/platform/api/src/grpc/api.rs @@ -11,7 +11,7 @@ use prost::Message; use tonic::{async_trait, Request, Response, Status}; use uuid::Uuid; -use crate::pb::scuffle::backend::{ +use pb::scuffle::internal::video::rpc::{ api_server, update_live_stream_request::{event::Level, update::Update}, AuthenticateLiveStreamRequest, AuthenticateLiveStreamResponse, NewLiveStreamRequest, diff --git a/platform/api/src/grpc/health.rs b/platform/api/src/grpc/health.rs index dcfde825..a333d98a 100644 --- a/platform/api/src/grpc/health.rs +++ b/platform/api/src/grpc/health.rs @@ -8,7 +8,7 @@ use async_stream::try_stream; use futures_util::Stream; use tonic::{async_trait, Request, Response, Status}; -use crate::pb::health::{ +use pb::grpc::health::v1::{ health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, }; diff --git a/platform/api/src/main.rs b/platform/api/src/main.rs index 74b631a3..001159a2 100644 --- a/platform/api/src/main.rs +++ b/platform/api/src/main.rs @@ -1,6 +1,8 @@ use std::{str::FromStr, sync::Arc, time::Duration}; +use crate::api::v1::gql::schema; use anyhow::{Context as _, Result}; +use async_graphql::SDLExportOptions; use common::{context::Context, logging, prelude::FutureTimeout, signal}; use fred::types::ReconnectPolicy; use sqlx::{postgres::PgConnectOptions, ConnectOptions}; @@ -11,8 +13,7 @@ mod config; mod database; mod dataloader; mod global; -mod grpc; -mod pb; +// mod grpc; mod subscription; #[cfg(test)] @@ -21,6 +22,25 @@ mod tests; #[tokio::main] async fn main() -> Result<()> { let config = config::AppConfig::parse()?; + + if config.export_gql { + let schema = schema(); + + println!( + "{}", + schema.sdl_with_options( + SDLExportOptions::default() + .federation() + .include_specified_by() + .sorted_arguments() + .sorted_enum_items() + .sorted_fields() + ) + ); + + return Ok(()); + } + logging::init(&config.logging.level, config.logging.mode)?; if let Some(file) = &config.config_file { @@ -40,16 +60,16 @@ async fn main() -> Result<()> { let (ctx, handler) = Context::new(); - let rmq = common::rmq::ConnectionPool::connect( - config.rmq.uri.clone(), - lapin::ConnectionProperties::default(), - Duration::from_secs(30), - 1, - ) - .timeout(Duration::from_secs(5)) - .await - .context("failed to connect to rabbitmq, timedout")? - .context("failed to connect to rabbitmq")?; + // let rmq = common::rmq::ConnectionPool::connect( + // config.rmq.uri.clone(), + // lapin::ConnectionProperties::default(), + // Duration::from_secs(30), + // 1, + // ) + // .timeout(Duration::from_secs(5)) + // .await + // .context("failed to connect to rabbitmq, timedout")? + // .context("failed to connect to rabbitmq")?; let redis = global::setup_redis(&config).await; let subscription_redis = @@ -57,10 +77,10 @@ async fn main() -> Result<()> { tracing::info!("connected to redis"); - let global = Arc::new(global::GlobalState::new(config, db, rmq, redis, ctx)); + let global = Arc::new(global::GlobalState::new(config, db, redis, ctx)); let api_future = tokio::spawn(api::run(global.clone())); - let grpc_future = tokio::spawn(grpc::run(global.clone())); + // let grpc_future = tokio::spawn(grpc::run(global.clone())); // Listen on both sigint and sigterm and cancel the context when either is received let mut signal_handler = signal::SignalHandler::new() @@ -69,8 +89,7 @@ async fn main() -> Result<()> { select! { r = api_future => tracing::error!("api stopped unexpectedly: {:?}", r), - r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), - r = global.rmq.handle_reconnects() => tracing::error!("rmq stopped unexpectedly: {:?}", r), + // r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), r = global.subscription_manager.run(global.ctx.clone(), subscription_redis) => tracing::error!("subscription manager stopped unexpectedly: {:?}", r), _ = signal_handler.recv() => tracing::info!("shutting down"), } diff --git a/platform/api/src/pb.rs b/platform/api/src/pb.rs deleted file mode 100644 index 16034e4e..00000000 --- a/platform/api/src/pb.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod scuffle { - pub mod backend { - tonic::include_proto!("scuffle.backend"); - } - - pub mod types { - tonic::include_proto!("scuffle.types"); - } - - pub mod events { - tonic::include_proto!("scuffle.events"); - } -} - -pub mod health { - tonic::include_proto!("grpc.health.v1"); -} diff --git a/platform/api/src/tests/api/v1/gql/chat.rs b/platform/api/src/tests/api/v1/gql/chat.rs index 9a128711..1c763e47 100644 --- a/platform/api/src/tests/api/v1/gql/chat.rs +++ b/platform/api/src/tests/api/v1/gql/chat.rs @@ -1,7 +1,6 @@ use crate::{ api::v1::gql::ext::RequestExt, database::{chat_message, session, user}, - pb, }; use async_graphql::{Name, Request, Variables}; use chrono::Utc; @@ -257,7 +256,9 @@ async fn test_serial_send_message_success() { .await .unwrap() .unwrap(); - let message = pb::scuffle::events::ChatMessage::decode(message.as_bytes().unwrap()).unwrap(); + let message = + pb::scuffle::internal::platform::events::ChatMessage::decode(message.as_bytes().unwrap()) + .unwrap(); assert_eq!(message.author_id, user.id.to_string()); assert_eq!(message.channel_id, channel.id.to_string()); diff --git a/platform/api/src/tests/api/v1/gql/subscription.rs b/platform/api/src/tests/api/v1/gql/subscription.rs index 461c4884..88399810 100644 --- a/platform/api/src/tests/api/v1/gql/subscription.rs +++ b/platform/api/src/tests/api/v1/gql/subscription.rs @@ -3,7 +3,6 @@ use std::{sync::Arc, time::Duration}; use crate::{ api::v1::gql::{ext::RequestExt, request_context::RequestContext, schema}, database::{session, user}, - pb, tests::global::mock_global_state, }; use async_graphql::Value; @@ -83,7 +82,7 @@ async fn test_serial_user_display_name_subscription() { .redis .publish( format!("user:{}:display_name", user.id), - pb::scuffle::events::UserDisplayName { + pb::scuffle::internal::platform::events::UserDisplayName { display_name: Some("Admin".to_string()), username: None, } @@ -122,7 +121,7 @@ async fn test_serial_user_display_name_subscription() { .redis .publish( format!("user:{}:display_name", user.id), - pb::scuffle::events::UserDisplayName { + pb::scuffle::internal::platform::events::UserDisplayName { display_name: Some("Admin".to_string()), username: None, } @@ -245,7 +244,7 @@ async fn test_serial_chat_subscribe() { .redis .publish( format!("user:{}:chat:messages", user.id), - pb::scuffle::events::ChatMessage { + pb::scuffle::internal::platform::events::ChatMessage { author_id: user.id.to_string(), channel_id: user.id.to_string(), content: "Hello world!".to_string(), @@ -369,7 +368,7 @@ async fn test_serial_chat_subscribe() { .redis .publish( format!("user:{}:chat:messages", user.id), - pb::scuffle::events::ChatMessage { + pb::scuffle::internal::platform::events::ChatMessage { author_id: user.id.to_string(), channel_id: user.id.to_string(), content: "Hello world!".to_string(), diff --git a/platform/api/src/tests/grpc/api.rs b/platform/api/src/tests/grpc/api.rs index 023feda7..71da3cd5 100644 --- a/platform/api/src/tests/grpc/api.rs +++ b/platform/api/src/tests/grpc/api.rs @@ -2,15 +2,14 @@ use crate::config::{AppConfig, GrpcConfig}; use crate::database::{global_role::Permission, user}; use crate::database::{stream, stream_bitrate_update, stream_event}; use crate::grpc::run; -use crate::pb; -use crate::pb::scuffle::backend::{ - update_live_stream_request, NewLiveStreamRequest, StreamReadyState, -}; -use crate::pb::scuffle::types::{stream_state, StreamState}; use crate::tests::global::mock_global_state; use chrono::Utc; use common::grpc::make_channel; use common::prelude::FutureTimeout; +use pb::scuffle::internal::video::rpc::{ + update_live_stream_request, NewLiveStreamRequest, StreamReadyState, +}; +use pb::scuffle::internal::video::types::{stream_state, StreamState}; use serial_test::serial; use std::time::Duration; use uuid::Uuid; @@ -39,15 +38,17 @@ async fn test_serial_grpc_authenticate_invalid_stream_key() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); let err = client - .authenticate_live_stream(pb::scuffle::backend::AuthenticateLiveStreamRequest { - app_name: "test".to_string(), - stream_key: "test".to_string(), - ip_address: "127.0.0.1".to_string(), - ingest_address: "127.0.0.1:1234".to_string(), - connection_id: Uuid::new_v4().to_string(), - }) + .authenticate_live_stream( + pb::scuffle::internal::video::rpc::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: "test".to_string(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }, + ) .await .unwrap_err(); @@ -123,15 +124,17 @@ async fn test_serial_grpc_authenticate_valid_stream_key() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); let resp = client - .authenticate_live_stream(pb::scuffle::backend::AuthenticateLiveStreamRequest { - app_name: "test".to_string(), - stream_key: user.get_stream_key(), - ip_address: "127.0.0.1".to_string(), - ingest_address: "127.0.0.1:1234".to_string(), - connection_id: Uuid::new_v4().to_string(), - }) + .authenticate_live_stream( + pb::scuffle::internal::video::rpc::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }, + ) .await .unwrap_err(); @@ -148,13 +151,15 @@ async fn test_serial_grpc_authenticate_valid_stream_key() { .unwrap(); let resp = client - .authenticate_live_stream(pb::scuffle::backend::AuthenticateLiveStreamRequest { - app_name: "test".to_string(), - stream_key: user.get_stream_key(), - ip_address: "127.0.0.1".to_string(), - ingest_address: "127.0.0.1:1234".to_string(), - connection_id: Uuid::new_v4().to_string(), - }) + .authenticate_live_stream( + pb::scuffle::internal::video::rpc::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }, + ) .await .unwrap() .into_inner(); @@ -240,16 +245,18 @@ async fn test_serial_grpc_authenticate_valid_stream_key_ext() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); let resp = client - .authenticate_live_stream(pb::scuffle::backend::AuthenticateLiveStreamRequest { - app_name: "test".to_string(), - stream_key: user.get_stream_key(), - ip_address: "127.0.0.1".to_string(), - ingest_address: "127.0.0.1:1234".to_string(), - connection_id: Uuid::new_v4().to_string(), - }) + .authenticate_live_stream( + pb::scuffle::internal::video::rpc::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }, + ) .await .unwrap() .into_inner(); @@ -334,16 +341,18 @@ async fn test_serial_grpc_authenticate_valid_stream_key_ext_2() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); let resp = client - .authenticate_live_stream(pb::scuffle::backend::AuthenticateLiveStreamRequest { - app_name: "test".to_string(), - stream_key: user.get_stream_key(), - ip_address: "127.0.0.1".to_string(), - ingest_address: "127.0.0.1:1234".to_string(), - connection_id: Uuid::new_v4().to_string(), - }) + .authenticate_live_stream( + pb::scuffle::internal::video::rpc::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }, + ) .await .unwrap() .into_inner(); @@ -418,13 +427,13 @@ async fn test_serial_grpc_update_live_stream_state() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); { let timestamp = Utc::now().timestamp() as u64; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -450,7 +459,7 @@ async fn test_serial_grpc_update_live_stream_state() { let timestamp = Utc::now().timestamp() as u64; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -476,7 +485,7 @@ async fn test_serial_grpc_update_live_stream_state() { let timestamp = Utc::now().timestamp() as u64; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -503,7 +512,7 @@ async fn test_serial_grpc_update_live_stream_state() { let timestamp = Utc::now().timestamp() as u64; let res = client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -542,7 +551,7 @@ async fn test_serial_grpc_update_live_stream_state() { let timestamp = Utc::now().timestamp() as u64; let res = client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -643,13 +652,13 @@ async fn test_serial_grpc_update_live_stream_bitrate() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); { let timestamp = Utc::now().timestamp() as u64; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -747,13 +756,13 @@ async fn test_serial_grpc_update_live_stream_event() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); { let timestamp = Utc::now().timestamp() as u64; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -851,7 +860,7 @@ async fn test_serial_grpc_update_live_stream_variants() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); { let timestamp = Utc::now().timestamp() as u64; @@ -912,7 +921,7 @@ async fn test_serial_grpc_update_live_stream_variants() { }; assert!(client - .update_live_stream(pb::scuffle::backend::UpdateLiveStreamRequest { + .update_live_stream(pb::scuffle::internal::video::rpc::UpdateLiveStreamRequest { connection_id: conn_id.to_string(), stream_id: s.id.to_string(), updates: vec![update_live_stream_request::Update { @@ -999,7 +1008,7 @@ async fn test_serial_grpc_new_live_stream() { ) .unwrap(); - let mut client = pb::scuffle::backend::api_client::ApiClient::new(channel); + let mut client = pb::scuffle::internal::video::rpc::api_client::ApiClient::new(channel); let source_id = Uuid::new_v4().to_string(); let audio_id = Uuid::new_v4().to_string(); diff --git a/platform/api/src/tests/grpc/health.rs b/platform/api/src/tests/grpc/health.rs index 8bb2aa8c..53d965be 100644 --- a/platform/api/src/tests/grpc/health.rs +++ b/platform/api/src/tests/grpc/health.rs @@ -4,7 +4,6 @@ use std::time::Duration; use crate::config::{AppConfig, GrpcConfig}; use crate::grpc::run; -use crate::pb; use crate::tests::global::mock_global_state; #[tokio::test] @@ -28,14 +27,14 @@ async fn test_grpc_health_check() { ) .unwrap(); - let mut client = pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -71,10 +70,10 @@ async fn test_grpc_health_watch() { ) .unwrap(); - let mut client = pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .watch(pb::health::HealthCheckRequest::default()) + .watch(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); @@ -82,7 +81,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); let cancel = handler.cancel(); @@ -90,7 +89,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - pb::health::health_check_response::ServingStatus::NotServing as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::NotServing as i32 ); cancel diff --git a/platform/api/src/tests/grpc/tls.rs b/platform/api/src/tests/grpc/tls.rs index 3255f60f..2651b2fa 100644 --- a/platform/api/src/tests/grpc/tls.rs +++ b/platform/api/src/tests/grpc/tls.rs @@ -7,7 +7,6 @@ use tonic::transport::{Certificate, Identity}; use crate::config::{AppConfig, GrpcConfig}; use crate::grpc::run; -use crate::pb; use crate::tests::global::mock_global_state; #[tokio::test] @@ -57,15 +56,15 @@ async fn test_grpc_tls_rsa() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -127,15 +126,15 @@ async fn test_grpc_tls_ec() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() diff --git a/platform/website/package.json b/platform/website/package.json index 85971861..daa1ea74 100644 --- a/platform/website/package.json +++ b/platform/website/package.json @@ -18,53 +18,56 @@ }, "devDependencies": { "@fortawesome/free-regular-svg-icons": "^6.4.2", - "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.2", "@graphql-codegen/cli": "4.0.1", - "@graphql-codegen/client-preset": "^4.0.1", + "@graphql-codegen/client-preset": "^4.1.0", "@graphql-codegen/introspection": "^4.0.0", "@graphql-codegen/typescript": "4.0.1", "@graphql-codegen/typescript-document-nodes": "^4.0.1", "@graphql-typed-document-node/core": "^3.2.0", - "@playwright/test": "^1.36.1", + "@playwright/test": "^1.36.2", "@rollup/plugin-commonjs": "^25.0.3", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.1.0", "@rollup/plugin-replace": "^5.0.2", "@scuffle/player": "workspace:*", "@sveltejs/adapter-node": "^1.3.1", - "@sveltejs/kit": "^1.22.3", + "@sveltejs/kit": "^1.22.4", "@types/fs-extra": "^11.0.1", - "@types/node": "^20.4.2", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + "@types/node": "^20.4.9", + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", "concurrently": "^8.2.0", - "eslint": "^8.45.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-svelte": "^2.32.2", + "eslint": "^8.46.0", + "eslint-config-prettier": "^8.10.0", + "eslint-plugin-svelte": "^2.32.4", "espree": "^9.6.1", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.1", "fs-extra": "^11.1.1", - "prettier": "^3.0.0", - "prettier-plugin-svelte": "^3.0.0", - "rollup": "^3.26.2", - "sass": "^1.63.6", - "svelte": "^4.0.5", + "prettier": "^3.0.1", + "prettier-plugin-svelte": "^3.0.3", + "rollup": "^3.28.0", + "sass": "^1.64.2", + "svelte": "^4.1.2", "svelte-check": "^3.4.6", "svelte-fa": "^3.0.4", "svelte-turnstile": "^0.5.0", "svelte2tsx": "^0.6.19", - "tslib": "^2.6.0", + "tslib": "^2.6.1", "typescript": "^5.1.6", - "vite": "^4.4.4", + "vite": "^4.4.9", "vitest": "^0.33.0" }, "type": "module", "dependencies": { - "@urql/svelte": "^4.0.3", + "@fontsource/be-vietnam-pro": "^5.0.8", + "@fontsource/comfortaa": "^5.0.8", + "@sveltejs/adapter-node": "^1.3.1", + "@urql/svelte": "^4.0.4", "graphql": "^16.7.1", "graphql-ws": "^5.14.0", - "urql": "^4.0.4", - "wonka": "^6.3.2", + "urql": "^4.0.5", + "wonka": "^6.3.4", "zod": "^3.21.4" } } diff --git a/platform/website/pnpm-lock.yaml b/platform/website/pnpm-lock.yaml deleted file mode 100644 index b66bd747..00000000 --- a/platform/website/pnpm-lock.yaml +++ /dev/null @@ -1,5555 +0,0 @@ -lockfileVersion: '6.0' - -dependencies: - '@fontsource/be-vietnam-pro': - specifier: ^5.0.5 - version: 5.0.5 - '@fontsource/comfortaa': - specifier: ^5.0.5 - version: 5.0.5 - '@urql/svelte': - specifier: ^4.0.3 - version: 4.0.3(graphql@16.7.1)(svelte@4.0.5) - graphql: - specifier: ^16.7.1 - version: 16.7.1 - graphql-ws: - specifier: ^5.14.0 - version: 5.14.0(graphql@16.7.1) - urql: - specifier: ^4.0.4 - version: 4.0.4(graphql@16.7.1)(react@18.2.0) - wonka: - specifier: ^6.3.2 - version: 6.3.2 - zod: - specifier: ^3.21.4 - version: 3.21.4 - -devDependencies: - '@fortawesome/free-solid-svg-icons': - specifier: ^6.4.0 - version: 6.4.0 - '@graphql-codegen/cli': - specifier: 4.0.1 - version: 4.0.1(@babel/core@7.22.9)(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-codegen/client-preset': - specifier: ^4.0.1 - version: 4.0.1(graphql@16.7.1) - '@graphql-codegen/introspection': - specifier: ^4.0.0 - version: 4.0.0(graphql@16.7.1) - '@graphql-codegen/typescript': - specifier: 4.0.1 - version: 4.0.1(graphql@16.7.1) - '@graphql-codegen/typescript-document-nodes': - specifier: ^4.0.1 - version: 4.0.1(graphql@16.7.1) - '@graphql-typed-document-node/core': - specifier: ^3.2.0 - version: 3.2.0(graphql@16.7.1) - '@playwright/test': - specifier: ^1.36.1 - version: 1.36.1 - '@rollup/plugin-commonjs': - specifier: ^25.0.3 - version: 25.0.3(rollup@3.26.2) - '@rollup/plugin-json': - specifier: ^6.0.0 - version: 6.0.0(rollup@3.26.2) - '@rollup/plugin-node-resolve': - specifier: ^15.1.0 - version: 15.1.0(rollup@3.26.2) - '@rollup/plugin-replace': - specifier: ^5.0.2 - version: 5.0.2(rollup@3.26.2) - '@scuffle/player': - specifier: workspace:* - version: link:../player - '@sveltejs/adapter-node': - specifier: ^1.3.1 - version: 1.3.1(@sveltejs/kit@1.22.3) - '@sveltejs/kit': - specifier: ^1.22.3 - version: 1.22.3(svelte@4.0.5)(vite@4.4.4) - '@types/fs-extra': - specifier: ^11.0.1 - version: 11.0.1 - '@types/node': - specifier: ^20.4.2 - version: 20.4.2 - '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.0.0(eslint@8.45.0)(typescript@5.1.6) - concurrently: - specifier: ^8.2.0 - version: 8.2.0 - eslint: - specifier: ^8.45.0 - version: 8.45.0 - eslint-config-prettier: - specifier: ^8.8.0 - version: 8.8.0(eslint@8.45.0) - eslint-plugin-svelte: - specifier: ^2.32.2 - version: 2.32.2(eslint@8.45.0)(svelte@4.0.5) - espree: - specifier: ^9.6.1 - version: 9.6.1 - fast-glob: - specifier: ^3.3.0 - version: 3.3.0 - fs-extra: - specifier: ^11.1.1 - version: 11.1.1 - prettier: - specifier: ^3.0.0 - version: 3.0.0 - prettier-plugin-svelte: - specifier: ^3.0.0 - version: 3.0.0(prettier@3.0.0)(svelte@4.0.5) - rollup: - specifier: ^3.26.2 - version: 3.26.2 - sass: - specifier: ^1.63.6 - version: 1.63.6 - svelte: - specifier: ^4.0.5 - version: 4.0.5 - svelte-check: - specifier: ^3.4.6 - version: 3.4.6(@babel/core@7.22.9)(postcss@8.4.26)(sass@1.63.6)(svelte@4.0.5) - svelte-fa: - specifier: ^3.0.4 - version: 3.0.4 - svelte-turnstile: - specifier: ^0.5.0 - version: 0.5.0(svelte@4.0.5) - svelte2tsx: - specifier: ^0.6.19 - version: 0.6.19(svelte@4.0.5)(typescript@5.1.6) - tslib: - specifier: ^2.6.0 - version: 2.6.0 - typescript: - specifier: ^5.1.6 - version: 5.1.6 - vite: - specifier: ^4.4.4 - version: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vitest: - specifier: ^0.33.0 - version: 0.33.0(sass@1.63.6) - -packages: - - /@0no-co/graphql.web@1.0.4(graphql@16.7.1): - resolution: {integrity: sha512-W3ezhHGfO0MS1PtGloaTpg0PbaT8aZSmmaerL7idtU5F7oCI+uu25k+MsMS31BVFlp4aMkHSrNRxiD72IlK8TA==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - peerDependenciesMeta: - graphql: - optional: true - dependencies: - graphql: 16.7.1 - dev: false - - /@aashutoshrathi/word-wrap@1.2.6: - resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} - engines: {node: '>=0.10.0'} - dev: true - - /@ampproject/remapping@2.2.1: - resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - - /@ardatan/relay-compiler@12.0.0(graphql@16.7.1): - resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} - hasBin: true - peerDependencies: - graphql: '*' - dependencies: - '@babel/core': 7.22.9 - '@babel/generator': 7.22.9 - '@babel/parser': 7.22.7 - '@babel/runtime': 7.22.6 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - babel-preset-fbjs: 3.4.0(@babel/core@7.22.9) - chalk: 4.1.2 - fb-watchman: 2.0.2 - fbjs: 3.0.5 - glob: 7.2.3 - graphql: 16.7.1 - immutable: 3.7.6 - invariant: 2.2.4 - nullthrows: 1.1.1 - relay-runtime: 12.0.0 - signedsource: 1.0.0 - yargs: 15.4.1 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@ardatan/sync-fetch@0.0.1: - resolution: {integrity: sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA==} - engines: {node: '>=14'} - dependencies: - node-fetch: 2.6.12 - transitivePeerDependencies: - - encoding - dev: true - - /@babel/code-frame@7.22.5: - resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/highlight': 7.22.5 - dev: true - - /@babel/compat-data@7.22.9: - resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/core@7.22.9: - resolution: {integrity: sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==} - engines: {node: '>=6.9.0'} - dependencies: - '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) - '@babel/helpers': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - convert-source-map: 1.9.0 - debug: 4.3.4 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/generator@7.22.9: - resolution: {integrity: sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - jsesc: 2.5.2 - dev: true - - /@babel/helper-annotate-as-pure@7.22.5: - resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-compilation-targets@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/compat-data': 7.22.9 - '@babel/core': 7.22.9 - '@babel/helper-validator-option': 7.22.5 - browserslist: 4.21.9 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: true - - /@babel/helper-create-class-features-plugin@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-Pwyi89uO4YrGKxL/eNJ8lfEH55DnRloGPOseaA8NFNL6jAUnn+KccaISiFazCj5IolPPDjGSdzQzXVzODVRqUQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-member-expression-to-functions': 7.22.5 - '@babel/helper-optimise-call-expression': 7.22.5 - '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) - '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - semver: 6.3.1 - dev: true - - /@babel/helper-environment-visitor@7.22.5: - resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-function-name@7.22.5: - resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-hoist-variables@7.22.5: - resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-member-expression-to-functions@7.22.5: - resolution: {integrity: sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-module-imports@7.22.5: - resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-module-transforms@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-module-imports': 7.22.5 - '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.5 - dev: true - - /@babel/helper-optimise-call-expression@7.22.5: - resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-plugin-utils@7.22.5: - resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-replace-supers@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-member-expression-to-functions': 7.22.5 - '@babel/helper-optimise-call-expression': 7.22.5 - dev: true - - /@babel/helper-simple-access@7.22.5: - resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-skip-transparent-expression-wrappers@7.22.5: - resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-split-export-declaration@7.22.6: - resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/helper-string-parser@7.22.5: - resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helper-validator-option@7.22.5: - resolution: {integrity: sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helpers@7.22.6: - resolution: {integrity: sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/highlight@7.22.5: - resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.22.5 - chalk: 2.4.2 - js-tokens: 4.0.0 - dev: true - - /@babel/parser@7.22.7: - resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.22.5 - dev: true - - /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.22.9): - resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.22.9): - resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/compat-data': 7.22.9 - '@babel/core': 7.22.9 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.9) - '@babel/plugin-transform-parameters': 7.22.5(@babel/core@7.22.9) - dev: true - - /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.22.9): - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-syntax-flow@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-syntax-import-assertions@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.22.9): - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-arrow-functions@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-block-scoped-functions@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-block-scoping@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-EcACl1i5fSQ6bt+YGuU/XGCeZKStLmyVGytWkpyhCLeQVA0eu6Wtiw92V+I1T/hnezUv7j74dA/Ro69gWcU+hg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-classes@7.22.6(@babel/core@7.22.9): - resolution: {integrity: sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-optimise-call-expression': 7.22.5 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) - '@babel/helper-split-export-declaration': 7.22.6 - globals: 11.12.0 - dev: true - - /@babel/plugin-transform-computed-properties@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/template': 7.22.5 - dev: true - - /@babel/plugin-transform-destructuring@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-GfqcFuGW8vnEqTUBM7UtPd5A4q797LTvvwKxXTgRsFjoqaJiEg9deBG6kWeQYkVEL569NpnmpC0Pkr/8BLKGnQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-flow-strip-types@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-tujNbZdxdG0/54g/oua8ISToaXTFBf8EnSb5PgQSciIXWOWKX3S4+JR7ZE9ol8FZwf9kxitzkGQ+QWeov/mCiA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-flow': 7.22.5(@babel/core@7.22.9) - dev: true - - /@babel/plugin-transform-for-of@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-function-name@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-function-name': 7.22.5 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-literals@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-member-expression-literals@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-modules-commonjs@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-simple-access': 7.22.5 - dev: true - - /@babel/plugin-transform-object-super@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) - dev: true - - /@babel/plugin-transform-parameters@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-property-literals@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-react-display-name@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-PVk3WPYudRF5z4GKMEYUrLjPl38fJSKNaEOkFuoprioowGuWN6w2RKznuFNSlJx7pzzXXStPUnNSOEO0jL5EVw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-react-jsx@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-rog5gZaVbUip5iWDMTYbVM15XQq+RkUKhET/IHR6oizR+JEoN6CAfTTuHcK4vwUyzca30qqHqEpzBOnaRMWYMA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-module-imports': 7.22.5 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.9) - '@babel/types': 7.22.5 - dev: true - - /@babel/plugin-transform-shorthand-properties@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-spread@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - dev: true - - /@babel/plugin-transform-template-literals@7.22.5(@babel/core@7.22.9): - resolution: {integrity: sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/runtime@7.22.6: - resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} - engines: {node: '>=6.9.0'} - dependencies: - regenerator-runtime: 0.13.11 - dev: true - - /@babel/template@7.22.5: - resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.22.5 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 - dev: true - - /@babel/traverse@7.22.8: - resolution: {integrity: sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-hoist-variables': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 - debug: 4.3.4 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/types@7.22.5: - resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.5 - to-fast-properties: 2.0.0 - dev: true - - /@esbuild/android-arm64@0.18.13: - resolution: {integrity: sha512-j7NhycJUoUAG5kAzGf4fPWfd17N6SM3o1X6MlXVqfHvs2buFraCJzos9vbeWjLxOyBKHyPOnuCuipbhvbYtTAg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm@0.18.13: - resolution: {integrity: sha512-KwqFhxRFMKZINHzCqf8eKxE0XqWlAVPRxwy6rc7CbVFxzUWB2sA/s3hbMZeemPdhN3fKBkqOaFhTbS8xJXYIWQ==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-x64@0.18.13: - resolution: {integrity: sha512-M2eZkRxR6WnWfVELHmv6MUoHbOqnzoTVSIxgtsyhm/NsgmL+uTmag/VVzdXvmahak1I6sOb1K/2movco5ikDJg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-arm64@0.18.13: - resolution: {integrity: sha512-f5goG30YgR1GU+fxtaBRdSW3SBG9pZW834Mmhxa6terzcboz7P2R0k4lDxlkP7NYRIIdBbWp+VgwQbmMH4yV7w==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-x64@0.18.13: - resolution: {integrity: sha512-RIrxoKH5Eo+yE5BtaAIMZaiKutPhZjw+j0OCh8WdvKEKJQteacq0myZvBDLU+hOzQOZWJeDnuQ2xgSScKf1Ovw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-arm64@0.18.13: - resolution: {integrity: sha512-AfRPhHWmj9jGyLgW/2FkYERKmYR+IjYxf2rtSLmhOrPGFh0KCETFzSjx/JX/HJnvIqHt/DRQD/KAaVsUKoI3Xg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-x64@0.18.13: - resolution: {integrity: sha512-pGzWWZJBInhIgdEwzn8VHUBang8UvFKsvjDkeJ2oyY5gZtAM6BaxK0QLCuZY+qoj/nx/lIaItH425rm/hloETA==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64@0.18.13: - resolution: {integrity: sha512-hCzZbVJEHV7QM77fHPv2qgBcWxgglGFGCxk6KfQx6PsVIdi1u09X7IvgE9QKqm38OpkzaAkPnnPqwRsltvLkIQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm@0.18.13: - resolution: {integrity: sha512-4iMxLRMCxGyk7lEvkkvrxw4aJeC93YIIrfbBlUJ062kilUUnAiMb81eEkVvCVoh3ON283ans7+OQkuy1uHW+Hw==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ia32@0.18.13: - resolution: {integrity: sha512-I3OKGbynl3AAIO6onXNrup/ttToE6Rv2XYfFgLK/wnr2J+1g+7k4asLrE+n7VMhaqX+BUnyWkCu27rl+62Adug==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-loong64@0.18.13: - resolution: {integrity: sha512-8pcKDApAsKc6WW51ZEVidSGwGbebYw2qKnO1VyD8xd6JN0RN6EUXfhXmDk9Vc4/U3Y4AoFTexQewQDJGsBXBpg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-mips64el@0.18.13: - resolution: {integrity: sha512-6GU+J1PLiVqWx8yoCK4Z0GnfKyCGIH5L2KQipxOtbNPBs+qNDcMJr9euxnyJ6FkRPyMwaSkjejzPSISD9hb+gg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ppc64@0.18.13: - resolution: {integrity: sha512-pfn/OGZ8tyR8YCV7MlLl5hAit2cmS+j/ZZg9DdH0uxdCoJpV7+5DbuXrR+es4ayRVKIcfS9TTMCs60vqQDmh+w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-riscv64@0.18.13: - resolution: {integrity: sha512-aIbhU3LPg0lOSCfVeGHbmGYIqOtW6+yzO+Nfv57YblEK01oj0mFMtvDJlOaeAZ6z0FZ9D13oahi5aIl9JFphGg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-s390x@0.18.13: - resolution: {integrity: sha512-Pct1QwF2sp+5LVi4Iu5Y+6JsGaV2Z2vm4O9Dd7XZ5tKYxEHjFtb140fiMcl5HM1iuv6xXO8O1Vrb1iJxHlv8UA==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-x64@0.18.13: - resolution: {integrity: sha512-zTrIP0KzYP7O0+3ZnmzvUKgGtUvf4+piY8PIO3V8/GfmVd3ZyHJGz7Ht0np3P1wz+I8qJ4rjwJKqqEAbIEPngA==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-x64@0.18.13: - resolution: {integrity: sha512-I6zs10TZeaHDYoGxENuksxE1sxqZpCp+agYeW039yqFwh3MgVvdmXL5NMveImOC6AtpLvE4xG5ujVic4NWFIDQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-x64@0.18.13: - resolution: {integrity: sha512-W5C5nczhrt1y1xPG5bV+0M12p2vetOGlvs43LH8SopQ3z2AseIROu09VgRqydx5qFN7y9qCbpgHLx0kb0TcW7g==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/sunos-x64@0.18.13: - resolution: {integrity: sha512-X/xzuw4Hzpo/yq3YsfBbIsipNgmsm8mE/QeWbdGdTTeZ77fjxI2K0KP3AlhZ6gU3zKTw1bKoZTuKLnqcJ537qw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-arm64@0.18.13: - resolution: {integrity: sha512-4CGYdRQT/ILd+yLLE5i4VApMPfGE0RPc/wFQhlluDQCK09+b4JDbxzzjpgQqTPrdnP7r5KUtGVGZYclYiPuHrw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-ia32@0.18.13: - resolution: {integrity: sha512-D+wKZaRhQI+MUGMH+DbEr4owC2D7XnF+uyGiZk38QbgzLcofFqIOwFs7ELmIeU45CQgfHNy9Q+LKW3cE8g37Kg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-x64@0.18.13: - resolution: {integrity: sha512-iVl6lehAfJS+VmpF3exKpNQ8b0eucf5VWfzR8S7xFve64NBNz2jPUgx1X93/kfnkfgP737O+i1k54SVQS7uVZA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@eslint-community/eslint-utils@4.4.0(eslint@8.45.0): - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.45.0 - eslint-visitor-keys: 3.4.1 - dev: true - - /@eslint-community/regexpp@4.5.1: - resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true - - /@eslint/eslintrc@2.1.0: - resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.3.4 - espree: 9.6.1 - globals: 13.20.0 - ignore: 5.2.4 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - - /@eslint/js@8.44.0: - resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - - /@fontsource/be-vietnam-pro@5.0.5: - resolution: {integrity: sha512-zdk9c5IGpoq3PTI2I0TVkvRwc4s9IRLg6XW+mWSDEo5XCLSOpJM0z+BCP3Lq9ax68xk+KJeQpqFWFUEjPMxhwg==} - dev: false - - /@fontsource/comfortaa@5.0.5: - resolution: {integrity: sha512-LisAgeZLOkCMCa7w6vxmJmYFpVYboELp37Xn4zgOYg+3QBTf3vC6cewBC1ovGsynhCOxzlUp30T5T5k3gmxLPw==} - dev: false - - /@fortawesome/fontawesome-common-types@6.4.0: - resolution: {integrity: sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==} - engines: {node: '>=6'} - requiresBuild: true - dev: true - - /@fortawesome/free-solid-svg-icons@6.4.0: - resolution: {integrity: sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==} - engines: {node: '>=6'} - requiresBuild: true - dependencies: - '@fortawesome/fontawesome-common-types': 6.4.0 - dev: true - - /@graphql-codegen/add@5.0.0(graphql@16.7.1): - resolution: {integrity: sha512-ynWDOsK2yxtFHwcJTB9shoSkUd7YXd6ZE57f0nk7W5cu/nAgxZZpEsnTPEpZB/Mjf14YRGe2uJHQ7AfElHjqUQ==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.5.3 - dev: true - - /@graphql-codegen/cli@4.0.1(@babel/core@7.22.9)(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-/H4imnGOl3hoPXLKmIiGUnXpmBmeIClSZie/YHDzD5N59cZlGGJlIOOrUlOTDpJx5JNU1MTQcRjyTToOYM5IfA==} - hasBin: true - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@babel/generator': 7.22.9 - '@babel/template': 7.22.5 - '@babel/types': 7.22.5 - '@graphql-codegen/core': 4.0.0(graphql@16.7.1) - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-tools/apollo-engine-loader': 8.0.0(graphql@16.7.1) - '@graphql-tools/code-file-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.7.1) - '@graphql-tools/git-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.7.1) - '@graphql-tools/github-loader': 8.0.0(@babel/core@7.22.9)(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.7.1) - '@graphql-tools/json-file-loader': 8.0.0(graphql@16.7.1) - '@graphql-tools/load': 8.0.0(graphql@16.7.1) - '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@parcel/watcher': 2.2.0 - '@whatwg-node/fetch': 0.8.8 - chalk: 4.1.2 - cosmiconfig: 8.2.0 - debounce: 1.2.1 - detect-indent: 6.1.0 - graphql: 16.7.1 - graphql-config: 5.0.2(@types/node@20.4.2)(graphql@16.7.1) - inquirer: 8.2.5 - is-glob: 4.0.3 - jiti: 1.19.1 - json-to-pretty-yaml: 1.2.2 - listr2: 4.0.5 - log-symbols: 4.1.0 - micromatch: 4.0.5 - shell-quote: 1.8.1 - string-env-interpolation: 1.0.1 - ts-log: 2.2.5 - tslib: 2.6.0 - yaml: 1.10.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - bufferutil - - cosmiconfig-toml-loader - - encoding - - enquirer - - supports-color - - utf-8-validate - dev: true - - /@graphql-codegen/client-preset@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-8kt8z1JK4CGbBb+oedSCyHENNxh8UHdEFU8sBCtN4QpKsfmsEXhHHeJCTRPVbQKtEZyfVuBqf89DzuSNLs0DFw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@babel/helper-plugin-utils': 7.22.5 - '@babel/template': 7.22.5 - '@graphql-codegen/add': 5.0.0(graphql@16.7.1) - '@graphql-codegen/gql-tag-operations': 4.0.1(graphql@16.7.1) - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/typed-document-node': 5.0.1(graphql@16.7.1) - '@graphql-codegen/typescript': 4.0.1(graphql@16.7.1) - '@graphql-codegen/typescript-operations': 4.0.1(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - '@graphql-tools/documents': 1.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/core@4.0.0(graphql@16.7.1): - resolution: {integrity: sha512-JAGRn49lEtSsZVxeIlFVIRxts2lWObR+OQo7V2LHDJ7ohYYw3ilv7nJ8pf8P4GTg/w6ptcYdSdVVdkI8kUHB/Q==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-tools/schema': 10.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.5.3 - dev: true - - /@graphql-codegen/gql-tag-operations@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-qF6wIbBzW8BNT+wiVsBxrYOs2oYcsxQ7mRvCpfEI3HnNZMAST/uX76W8MqFEJvj4mw7NIDv7xYJAcAZIWM5LWw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - auto-bind: 4.0.0 - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/introspection@4.0.0(graphql@16.7.1): - resolution: {integrity: sha512-t9g3AkK99dfHblMWtG4ynUM9+A7JrWq5110zSpNV2wlSnv0+bRKagDW8gozwgXfR5i1IIG8QDjJZ6VgXQVqCZw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/plugin-helpers@5.0.0(graphql@16.7.1): - resolution: {integrity: sha512-suL2ZMkBAU2a4YbBHaZvUPsV1z0q3cW6S96Z/eYYfkRIsJoe2vN+wNZ9Xdzmqx0JLmeeFCBSoBGC0imFyXlkDQ==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - change-case-all: 1.0.15 - common-tags: 1.8.2 - graphql: 16.7.1 - import-from: 4.0.0 - lodash: 4.17.21 - tslib: 2.5.3 - dev: true - - /@graphql-codegen/schema-ast@4.0.0(graphql@16.7.1): - resolution: {integrity: sha512-WIzkJFa9Gz28FITAPILbt+7A8+yzOyd1NxgwFh7ie+EmO9a5zQK6UQ3U/BviirguXCYnn+AR4dXsoDrSrtRA1g==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.5.3 - dev: true - - /@graphql-codegen/typed-document-node@5.0.1(graphql@16.7.1): - resolution: {integrity: sha512-VFkhCuJnkgtbbgzoCAwTdJe2G1H6sd3LfCrDqWUrQe53y2ukfSb5Ov1PhAIkCBStKCMQBUY9YgGz9GKR40qQ8g==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - auto-bind: 4.0.0 - change-case-all: 1.0.15 - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/typescript-document-nodes@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-Q+0xER6T5/qVY3XYdT+H5Dnn4kWIQefV8IlV2KyxRfCxQVgJT4h5wA4NU+n2qd4B6Kn39gBA+mle9x8Lx2acrw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - auto-bind: 4.0.0 - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/typescript-operations@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-GpUWWdBVUec/Zqo23aFLBMrXYxN2irypHqDcKjN78JclDPdreasAEPcIpMfqf4MClvpmvDLy4ql+djVAwmkjbw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/typescript': 4.0.1(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - auto-bind: 4.0.0 - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/typescript@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-3YziQ21dCVdnHb+Us1uDb3pA6eG5Chjv0uTK+bt9dXeMlwYBU8MbtzvQTo4qvzWVC1AxSOKj0rgfNu1xCXqJyA==} - peerDependencies: - graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-codegen/schema-ast': 4.0.0(graphql@16.7.1) - '@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@16.7.1) - auto-bind: 4.0.0 - graphql: 16.7.1 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-codegen/visitor-plugin-common@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-Bi/1z0nHg4QMsAqAJhds+ForyLtk7A3HQOlkrZNm3xEkY7lcBzPtiOTLBtvziwopBsXUxqeSwVjOOFPLS5Yw1Q==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.7.1) - '@graphql-tools/optimize': 2.0.0(graphql@16.7.1) - '@graphql-tools/relay-operation-optimizer': 7.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - auto-bind: 4.0.0 - change-case-all: 1.0.15 - dependency-graph: 0.11.0 - graphql: 16.7.1 - graphql-tag: 2.12.6(graphql@16.7.1) - parse-filepath: 1.0.2 - tslib: 2.5.3 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-tools/apollo-engine-loader@8.0.0(graphql@16.7.1): - resolution: {integrity: sha512-axQTbN5+Yxs1rJ6cWQBOfw3AEeC+fvIuZSfJLPLLvFJLj4pUm9fhxey/g6oQZAAQJqKPfw+tLDUQvnfvRK8Kmg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@whatwg-node/fetch': 0.9.9 - graphql: 16.7.1 - tslib: 2.6.0 - transitivePeerDependencies: - - encoding - dev: true - - /@graphql-tools/batch-execute@9.0.0(graphql@16.7.1): - resolution: {integrity: sha512-lT9/1XmPSYzBcEybXPLsuA6C5E0t8438PVUELABcqdvwHgZ3VOOx29MLBEqhr2oewOlDChH6PXNkfxoOoAuzRg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - dataloader: 2.2.2 - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - dev: true - - /@graphql-tools/code-file-loader@8.0.1(@babel/core@7.22.9)(graphql@16.7.1): - resolution: {integrity: sha512-pmg81lsIXGW3uW+nFSCIG0lFQIxWVbgDjeBkSWlnP8CZsrHTQEkB53DT7t4BHLryoxDS4G4cPxM52yNINDSL8w==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.22.9)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - globby: 11.1.0 - graphql: 16.7.1 - tslib: 2.6.0 - unixify: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: true - - /@graphql-tools/delegate@10.0.0(graphql@16.7.1): - resolution: {integrity: sha512-ZW5/7Q0JqUM+guwn8/cM/1Hz16Zvj6WR6r3gnOwoPO7a9bCbe8QTCk4itT/EO+RiGT8RLUPYaunWR9jxfNqqOA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/batch-execute': 9.0.0(graphql@16.7.1) - '@graphql-tools/executor': 1.1.0(graphql@16.7.1) - '@graphql-tools/schema': 10.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - dataloader: 2.2.2 - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - dev: true - - /@graphql-tools/documents@1.0.0(graphql@16.7.1): - resolution: {integrity: sha512-rHGjX1vg/nZ2DKqRGfDPNC55CWZBMldEVcH+91BThRa6JeT80NqXknffLLEZLRUxyikCfkwMsk6xR3UNMqG0Rg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - graphql: 16.7.1 - lodash.sortby: 4.7.0 - tslib: 2.6.0 - dev: true - - /@graphql-tools/executor-graphql-ws@1.1.0(graphql@16.7.1): - resolution: {integrity: sha512-yM67SzwE8rYRpm4z4AuGtABlOp9mXXVy6sxXnTJRoYIdZrmDbKVfIY+CpZUJCqS0FX3xf2+GoHlsj7Qswaxgcg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@types/ws': 8.5.5 - graphql: 16.7.1 - graphql-ws: 5.14.0(graphql@16.7.1) - isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 - ws: 8.13.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dev: true - - /@graphql-tools/executor-http@1.0.1(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-36D2oxVuv7NboFdPPS9MDOICvsg08P1K9xkqcQTB4UQogkUn58ZFfWM+4cZ9rwfNCIPTIzH4quoj7Xo09xbzmw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@repeaterjs/repeater': 3.0.4 - '@whatwg-node/fetch': 0.9.9 - extract-files: 11.0.0 - graphql: 16.7.1 - meros: 1.3.0(@types/node@20.4.2) - tslib: 2.6.0 - value-or-promise: 1.0.12 - transitivePeerDependencies: - - '@types/node' - dev: true - - /@graphql-tools/executor-legacy-ws@1.0.1(graphql@16.7.1): - resolution: {integrity: sha512-PQrTJ+ncHMEQspBARc2lhwiQFfRAX/z/CsOdZTFjIljOHgRWGAA1DAx7pEN0j6PflbLCfZ3NensNq2jCBwF46w==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@types/ws': 8.5.5 - graphql: 16.7.1 - isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 - ws: 8.13.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dev: true - - /@graphql-tools/executor@1.1.0(graphql@16.7.1): - resolution: {integrity: sha512-+1wmnaUHETSYxiK/ELsT60x584Rw3QKBB7F/7fJ83HKPnLifmE2Dm/K9Eyt6L0Ppekf1jNUbWBpmBGb8P5hAeg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) - '@repeaterjs/repeater': 3.0.4 - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - dev: true - - /@graphql-tools/git-loader@8.0.1(@babel/core@7.22.9)(graphql@16.7.1): - resolution: {integrity: sha512-ivNtxD+iEfpPONYKip0kbpZMRdMCNR3HrIui8NCURmUdvBYGaGcbB3VrGMhxwZuzc+ybhs2ralPt1F8Oxq2jLA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.22.9)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - is-glob: 4.0.3 - micromatch: 4.0.5 - tslib: 2.6.0 - unixify: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: true - - /@graphql-tools/github-loader@8.0.0(@babel/core@7.22.9)(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-VuroArWKcG4yaOWzV0r19ElVIV6iH6UKDQn1MXemND0xu5TzrFme0kf3U9o0YwNo0kUYEk9CyFM0BYg4he17FA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/executor-http': 1.0.1(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.22.9)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@whatwg-node/fetch': 0.9.9 - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - encoding - - supports-color - dev: true - - /@graphql-tools/graphql-file-loader@8.0.0(graphql@16.7.1): - resolution: {integrity: sha512-wRXj9Z1IFL3+zJG1HWEY0S4TXal7+s1vVhbZva96MSp0kbb/3JBF7j0cnJ44Eq0ClccMgGCDFqPFXty4JlpaPg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/import': 7.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - globby: 11.1.0 - graphql: 16.7.1 - tslib: 2.6.0 - unixify: 1.0.0 - dev: true - - /@graphql-tools/graphql-tag-pluck@8.0.1(@babel/core@7.22.9)(graphql@16.7.1): - resolution: {integrity: sha512-4sfBJSoXxVB4rRCCp2GTFhAYsUJgAPSKxSV+E3Voc600mK52JO+KsHCCTnPgCeyJFMNR9l94J6+tqxVKmlqKvw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@babel/parser': 7.22.7 - '@babel/plugin-syntax-import-assertions': 7.22.5(@babel/core@7.22.9) - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.6.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: true - - /@graphql-tools/import@7.0.0(graphql@16.7.1): - resolution: {integrity: sha512-NVZiTO8o1GZs6OXzNfjB+5CtQtqsZZpQOq+Uu0w57kdUkT4RlQKlwhT8T81arEsbV55KpzkpFsOZP7J1wdmhBw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - resolve-from: 5.0.0 - tslib: 2.6.0 - dev: true - - /@graphql-tools/json-file-loader@8.0.0(graphql@16.7.1): - resolution: {integrity: sha512-ki6EF/mobBWJjAAC84xNrFMhNfnUFD6Y0rQMGXekrUgY0NdeYXHU0ZUgHzC9O5+55FslqUmAUHABePDHTyZsLg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - globby: 11.1.0 - graphql: 16.7.1 - tslib: 2.6.0 - unixify: 1.0.0 - dev: true - - /@graphql-tools/load@8.0.0(graphql@16.7.1): - resolution: {integrity: sha512-Cy874bQJH0FP2Az7ELPM49iDzOljQmK1PPH6IuxsWzLSTxwTqd8dXA09dcVZrI7/LsN26heTY2R8q2aiiv0GxQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/schema': 10.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - p-limit: 3.1.0 - tslib: 2.6.0 - dev: true - - /@graphql-tools/merge@9.0.0(graphql@16.7.1): - resolution: {integrity: sha512-J7/xqjkGTTwOJmaJQJ2C+VDBDOWJL3lKrHJN4yMaRLAJH3PosB7GiPRaSDZdErs0+F77sH2MKs2haMMkywzx7Q==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.6.0 - dev: true - - /@graphql-tools/optimize@2.0.0(graphql@16.7.1): - resolution: {integrity: sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - graphql: 16.7.1 - tslib: 2.6.0 - dev: true - - /@graphql-tools/prisma-loader@8.0.1(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-bl6e5sAYe35Z6fEbgKXNrqRhXlCJYeWKBkarohgYA338/SD9eEhXtg3Cedj7fut3WyRLoQFpHzfiwxKs7XrgXg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@types/js-yaml': 4.0.5 - '@types/json-stable-stringify': 1.0.34 - '@whatwg-node/fetch': 0.9.9 - chalk: 4.1.2 - debug: 4.3.4 - dotenv: 16.3.1 - graphql: 16.7.1 - graphql-request: 6.1.0(graphql@16.7.1) - http-proxy-agent: 7.0.0 - https-proxy-agent: 7.0.1 - jose: 4.14.4 - js-yaml: 4.1.0 - json-stable-stringify: 1.0.2 - lodash: 4.17.21 - scuid: 1.1.0 - tslib: 2.6.0 - yaml-ast-parser: 0.0.43 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - supports-color - - utf-8-validate - dev: true - - /@graphql-tools/relay-operation-optimizer@7.0.0(graphql@16.7.1): - resolution: {integrity: sha512-UNlJi5y3JylhVWU4MBpL0Hun4Q7IoJwv9xYtmAz+CgRa066szzY7dcuPfxrA7cIGgG/Q6TVsKsYaiF4OHPs1Fw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/relay-compiler': 12.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.6.0 - transitivePeerDependencies: - - encoding - - supports-color - dev: true - - /@graphql-tools/schema@10.0.0(graphql@16.7.1): - resolution: {integrity: sha512-kf3qOXMFcMs2f/S8Y3A8fm/2w+GaHAkfr3Gnhh2LOug/JgpY/ywgFVxO3jOeSpSEdoYcDKLcXVjMigNbY4AdQg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/merge': 9.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - dev: true - - /@graphql-tools/url-loader@8.0.0(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-rPc9oDzMnycvz+X+wrN3PLrhMBQkG4+sd8EzaFN6dypcssiefgWKToXtRKI8HHK68n2xEq1PyrOpkjHFJB+GwA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/delegate': 10.0.0(graphql@16.7.1) - '@graphql-tools/executor-graphql-ws': 1.1.0(graphql@16.7.1) - '@graphql-tools/executor-http': 1.0.1(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/executor-legacy-ws': 1.0.1(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - '@graphql-tools/wrap': 10.0.0(graphql@16.7.1) - '@types/ws': 8.5.5 - '@whatwg-node/fetch': 0.9.9 - graphql: 16.7.1 - isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 - value-or-promise: 1.0.12 - ws: 8.13.0 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - utf-8-validate - dev: true - - /@graphql-tools/utils@10.0.3(graphql@16.7.1): - resolution: {integrity: sha512-6uO41urAEIs4sXQT2+CYGsUTkHkVo/2MpM/QjoHj6D6xoEF2woXHBpdAVi0HKIInDwZqWgEYOwIFez0pERxa1Q==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) - dset: 3.1.2 - graphql: 16.7.1 - tslib: 2.6.0 - dev: true - - /@graphql-tools/wrap@10.0.0(graphql@16.7.1): - resolution: {integrity: sha512-HDOeUUh6UhpiH0WPJUQl44ODt1x5pnMUbOJZ7GjTdGQ7LK0AgVt3ftaAQ9duxLkiAtYJmu5YkULirfZGj4HzDg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/delegate': 10.0.0(graphql@16.7.1) - '@graphql-tools/schema': 10.0.0(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - graphql: 16.7.1 - tslib: 2.6.0 - value-or-promise: 1.0.12 - dev: true - - /@graphql-typed-document-node/core@3.2.0(graphql@16.7.1): - resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - graphql: 16.7.1 - dev: true - - /@humanwhocodes/config-array@0.11.10: - resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} - engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@humanwhocodes/module-importer@1.0.1: - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - dev: true - - /@humanwhocodes/object-schema@1.2.1: - resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} - dev: true - - /@jest/schemas@29.6.0: - resolution: {integrity: sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@sinclair/typebox': 0.27.8 - dev: true - - /@jridgewell/gen-mapping@0.3.3: - resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.18 - - /@jridgewell/resolve-uri@3.1.0: - resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} - engines: {node: '>=6.0.0'} - - /@jridgewell/set-array@1.1.2: - resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} - engines: {node: '>=6.0.0'} - - /@jridgewell/sourcemap-codec@1.4.14: - resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} - - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - - /@jridgewell/trace-mapping@0.3.18: - resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} - dependencies: - '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.14 - - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: true - - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 - dev: true - - /@parcel/watcher-android-arm64@2.2.0: - resolution: {integrity: sha512-nU2wh00CTQT9rr1TIKTjdQ9lAGYpmz6XuKw0nAwAN+S2A5YiD55BK1u+E5WMCT8YOIDe/n6gaj4o/Bi9294SSQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-darwin-arm64@2.2.0: - resolution: {integrity: sha512-cJl0UZDcodciy3TDMomoK/Huxpjlkkim3SyMgWzjovHGOZKNce9guLz2dzuFwfObBFCjfznbFMIvAZ5syXotYw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-darwin-x64@2.2.0: - resolution: {integrity: sha512-QI77zxaGrCV1StKcoRYfsUfmUmvPMPfQrubkBBy5XujV2fwaLgZivQOTQMBgp5K2+E19u1ufpspKXAPqSzpbyg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-linux-arm-glibc@2.2.0: - resolution: {integrity: sha512-I2GPBcAXazPzabCmfsa3HRRW+MGlqxYd8g8RIueJU+a4o5nyNZDz0CR1cu0INT0QSQXEZV7w6UE8Hz9CF8u3Pg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-linux-arm64-glibc@2.2.0: - resolution: {integrity: sha512-St5mlfp+2lS9AmgixUqfwJa/DwVmTCJxC1HcOubUTz6YFOKIlkHCeUa1Bxi4E/tR/HSez8+heXHL8HQkJ4Bd8g==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-linux-arm64-musl@2.2.0: - resolution: {integrity: sha512-jS+qfhhoOBVWwMLP65MaG8xdInMK30pPW8wqTCg2AAuVJh5xepMbzkhHJ4zURqHiyY3EiIRuYu4ONJKCxt8iqA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-linux-x64-glibc@2.2.0: - resolution: {integrity: sha512-xJvJ7R2wJdi47WZBFS691RDOWvP1j/IAs3EXaWVhDI8FFITbWrWaln7KoNcR0Y3T+ZwimFY/cfb0PNht1q895g==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-linux-x64-musl@2.2.0: - resolution: {integrity: sha512-D+NMpgr23a+RI5mu8ZPKWy7AqjBOkURFDgP5iIXXEf/K3hm0jJ3ogzi0Ed2237B/CdYREimCgXyeiAlE/FtwyA==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-win32-arm64@2.2.0: - resolution: {integrity: sha512-z225cPn3aygJsyVUOWwfyW+fY0Tvk7N3XCOl66qUPFxpbuXeZuiuuJemmtm8vxyqa3Ur7peU/qJxrpC64aeI7Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher-win32-x64@2.2.0: - resolution: {integrity: sha512-JqGW0RJ61BkKx+yYzIURt9s53P7xMVbv0uxYPzAXLBINGaFmkIKSuUPyBVfy8TMbvp93lvF4SPBNDzVRJfvgOw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@parcel/watcher@2.2.0: - resolution: {integrity: sha512-71S4TF+IMyAn24PK4KSkdKtqJDR3zRzb0HE3yXpacItqTM7XfF2f5q9NEGLEVl0dAaBAGfNwDCjH120y25F6Tg==} - engines: {node: '>= 10.0.0'} - requiresBuild: true - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.5 - node-addon-api: 7.0.0 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.2.0 - '@parcel/watcher-darwin-arm64': 2.2.0 - '@parcel/watcher-darwin-x64': 2.2.0 - '@parcel/watcher-linux-arm-glibc': 2.2.0 - '@parcel/watcher-linux-arm64-glibc': 2.2.0 - '@parcel/watcher-linux-arm64-musl': 2.2.0 - '@parcel/watcher-linux-x64-glibc': 2.2.0 - '@parcel/watcher-linux-x64-musl': 2.2.0 - '@parcel/watcher-win32-arm64': 2.2.0 - '@parcel/watcher-win32-x64': 2.2.0 - dev: true - - /@peculiar/asn1-schema@2.3.6: - resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==} - dependencies: - asn1js: 3.0.5 - pvtsutils: 1.3.2 - tslib: 2.6.0 - dev: true - - /@peculiar/json-schema@1.1.12: - resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} - engines: {node: '>=8.0.0'} - dependencies: - tslib: 2.6.0 - dev: true - - /@peculiar/webcrypto@1.4.3: - resolution: {integrity: sha512-VtaY4spKTdN5LjJ04im/d/joXuvLbQdgy5Z4DXF4MFZhQ+MTrejbNMkfZBp1Bs3O5+bFqnJgyGdPuZQflvIa5A==} - engines: {node: '>=10.12.0'} - dependencies: - '@peculiar/asn1-schema': 2.3.6 - '@peculiar/json-schema': 1.1.12 - pvtsutils: 1.3.2 - tslib: 2.6.0 - webcrypto-core: 1.7.7 - dev: true - - /@playwright/test@1.36.1: - resolution: {integrity: sha512-YK7yGWK0N3C2QInPU6iaf/L3N95dlGdbsezLya4n0ZCh3IL7VgPGxC6Gnznh9ApWdOmkJeleT2kMTcWPRZvzqg==} - engines: {node: '>=16'} - hasBin: true - dependencies: - '@types/node': 20.4.2 - playwright-core: 1.36.1 - optionalDependencies: - fsevents: 2.3.2 - dev: true - - /@polka/url@1.0.0-next.21: - resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} - dev: true - - /@repeaterjs/repeater@3.0.4: - resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} - dev: true - - /@rollup/plugin-commonjs@25.0.3(rollup@3.26.2): - resolution: {integrity: sha512-uBdtWr/H3BVcgm97MUdq2oJmqBR23ny1hOrWe2PKo9FTbjsGqg32jfasJUKYAI5ouqacjRnj65mBB/S79F+GQA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.68.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) - commondir: 1.0.1 - estree-walker: 2.0.2 - glob: 8.1.0 - is-reference: 1.2.1 - magic-string: 0.27.0 - rollup: 3.26.2 - dev: true - - /@rollup/plugin-json@6.0.0(rollup@3.26.2): - resolution: {integrity: sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) - rollup: 3.26.2 - dev: true - - /@rollup/plugin-node-resolve@15.1.0(rollup@3.26.2): - resolution: {integrity: sha512-xeZHCgsiZ9pzYVgAo9580eCGqwh/XCEUM9q6iQfGNocjgkufHAqC3exA+45URvhiYV8sBF9RlBai650eNs7AsA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-builtin-module: 3.2.1 - is-module: 1.0.0 - resolve: 1.22.2 - rollup: 3.26.2 - dev: true - - /@rollup/plugin-replace@5.0.2(rollup@3.26.2): - resolution: {integrity: sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) - magic-string: 0.27.0 - rollup: 3.26.2 - dev: true - - /@rollup/pluginutils@5.0.2(rollup@3.26.2): - resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - dependencies: - '@types/estree': 1.0.1 - estree-walker: 2.0.2 - picomatch: 2.3.1 - rollup: 3.26.2 - dev: true - - /@sinclair/typebox@0.27.8: - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - dev: true - - /@sveltejs/adapter-node@1.3.1(@sveltejs/kit@1.22.3): - resolution: {integrity: sha512-A0VgRQDCDPzdLNoiAbcOxGw4zT1Mc+n1LwT1OmO350R7WxrEqdMUChPPOd1iMfIDWlP4ie6E2d/WQf5es2d4Zw==} - peerDependencies: - '@sveltejs/kit': ^1.0.0 - dependencies: - '@rollup/plugin-commonjs': 25.0.3(rollup@3.26.2) - '@rollup/plugin-json': 6.0.0(rollup@3.26.2) - '@rollup/plugin-node-resolve': 15.1.0(rollup@3.26.2) - '@sveltejs/kit': 1.22.3(svelte@4.0.5)(vite@4.4.4) - rollup: 3.26.2 - dev: true - - /@sveltejs/kit@1.22.3(svelte@4.0.5)(vite@4.4.4): - resolution: {integrity: sha512-IpHD5wvuoOIHYaHQUBJ1zERD2Iz+fB/rBXhXjl8InKw6X4VKE9BSus+ttHhE7Ke+Ie9ecfilzX8BnWE3FeQyng==} - engines: {node: ^16.14 || >=18} - hasBin: true - requiresBuild: true - peerDependencies: - svelte: ^3.54.0 || ^4.0.0-next.0 - vite: ^4.0.0 - dependencies: - '@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.5)(vite@4.4.4) - '@types/cookie': 0.5.1 - cookie: 0.5.0 - devalue: 4.3.2 - esm-env: 1.0.0 - kleur: 4.1.5 - magic-string: 0.30.1 - mime: 3.0.0 - sade: 1.8.1 - set-cookie-parser: 2.6.0 - sirv: 2.0.3 - svelte: 4.0.5 - undici: 5.22.1 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - transitivePeerDependencies: - - supports-color - dev: true - - /@sveltejs/vite-plugin-svelte-inspector@1.0.3(@sveltejs/vite-plugin-svelte@2.4.2)(svelte@4.0.5)(vite@4.4.4): - resolution: {integrity: sha512-Khdl5jmmPN6SUsVuqSXatKpQTMIifoQPDanaxC84m9JxIibWvSABJyHpyys0Z+1yYrxY5TTEQm+6elh0XCMaOA==} - engines: {node: ^14.18.0 || >= 16} - peerDependencies: - '@sveltejs/vite-plugin-svelte': ^2.2.0 - svelte: ^3.54.0 || ^4.0.0 - vite: ^4.0.0 - dependencies: - '@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.5)(vite@4.4.4) - debug: 4.3.4 - svelte: 4.0.5 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - transitivePeerDependencies: - - supports-color - dev: true - - /@sveltejs/vite-plugin-svelte@2.4.2(svelte@4.0.5)(vite@4.4.4): - resolution: {integrity: sha512-ePfcC48ftMKhkT0OFGdOyycYKnnkT6i/buzey+vHRTR/JpQvuPzzhf1PtKqCDQfJRgoPSN2vscXs6gLigx/zGw==} - engines: {node: ^14.18.0 || >= 16} - peerDependencies: - svelte: ^3.54.0 || ^4.0.0 - vite: ^4.0.0 - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 1.0.3(@sveltejs/vite-plugin-svelte@2.4.2)(svelte@4.0.5)(vite@4.4.4) - debug: 4.3.4 - deepmerge: 4.3.1 - kleur: 4.1.5 - magic-string: 0.30.1 - svelte: 4.0.5 - svelte-hmr: 0.15.2(svelte@4.0.5) - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vitefu: 0.2.4(vite@4.4.4) - transitivePeerDependencies: - - supports-color - dev: true - - /@types/chai-subset@1.3.3: - resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} - dependencies: - '@types/chai': 4.3.5 - dev: true - - /@types/chai@4.3.5: - resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} - dev: true - - /@types/cookie@0.5.1: - resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} - dev: true - - /@types/estree@1.0.1: - resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} - - /@types/fs-extra@11.0.1: - resolution: {integrity: sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==} - dependencies: - '@types/jsonfile': 6.1.1 - '@types/node': 20.4.2 - dev: true - - /@types/js-yaml@4.0.5: - resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} - dev: true - - /@types/json-schema@7.0.12: - resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} - dev: true - - /@types/json-stable-stringify@1.0.34: - resolution: {integrity: sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==} - dev: true - - /@types/jsonfile@6.1.1: - resolution: {integrity: sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==} - dependencies: - '@types/node': 20.4.2 - dev: true - - /@types/node@20.4.2: - resolution: {integrity: sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==} - dev: true - - /@types/pug@2.0.6: - resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} - dev: true - - /@types/resolve@1.20.2: - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - dev: true - - /@types/semver@7.5.0: - resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} - dev: true - - /@types/ws@8.5.5: - resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} - dependencies: - '@types/node': 20.4.2 - dev: true - - /@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 - debug: 4.3.4 - eslint: 8.45.0 - grapheme-splitter: 1.0.4 - graphemer: 1.4.0 - ignore: 5.2.4 - natural-compare: 1.4.0 - natural-compare-lite: 1.4.0 - semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.1.6) - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 - debug: 4.3.4 - eslint: 8.45.0 - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/scope-manager@6.0.0: - resolution: {integrity: sha512-o4q0KHlgCZTqjuaZ25nw5W57NeykZT9LiMEG4do/ovwvOcPnDO1BI5BQdCsUkjxFyrCL0cSzLjvIMfR9uo7cWg==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 - dev: true - - /@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - debug: 4.3.4 - eslint: 8.45.0 - ts-api-utils: 1.0.1(typescript@5.1.6) - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/types@6.0.0: - resolution: {integrity: sha512-Zk9KDggyZM6tj0AJWYYKgF0yQyrcnievdhG0g5FqyU3Y2DRxJn4yWY21sJC0QKBckbsdKKjYDV2yVrrEvuTgxg==} - engines: {node: ^16.0.0 || >=18.0.0} - dev: true - - /@typescript-eslint/typescript-estree@6.0.0(typescript@5.1.6): - resolution: {integrity: sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 - debug: 4.3.4 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.1.6) - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@types/json-schema': 7.0.12 - '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - eslint: 8.45.0 - eslint-scope: 5.1.1 - semver: 7.5.4 - transitivePeerDependencies: - - supports-color - - typescript - dev: true - - /@typescript-eslint/visitor-keys@6.0.0: - resolution: {integrity: sha512-cvJ63l8c0yXdeT5POHpL0Q1cZoRcmRKFCtSjNGJxPkcP571EfZMcNbzWAc7oK3D1dRzm/V5EwtkANTZxqvuuUA==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.0.0 - eslint-visitor-keys: 3.4.1 - dev: true - - /@urql/core@4.0.11(graphql@16.7.1): - resolution: {integrity: sha512-FFdY97vF5xnUrElcGw9erOLvtu+KGMLfwrLNDfv4IPgdp2IBsiGe+Kb7Aypfd3kH//BETewVSLm3+y2sSzjX6A==} - dependencies: - '@0no-co/graphql.web': 1.0.4(graphql@16.7.1) - wonka: 6.3.2 - transitivePeerDependencies: - - graphql - dev: false - - /@urql/svelte@4.0.3(graphql@16.7.1)(svelte@4.0.5): - resolution: {integrity: sha512-snb0qW3fhvVPkiTzkeQ5tu9IIjyfeH7cJMvu8a9wzdG7PALoy4DPn0E71N3HoFLjxpEa1g782bgt3ac05vepCw==} - peerDependencies: - svelte: ^3.0.0 || ^4.0.0 - dependencies: - '@urql/core': 4.0.11(graphql@16.7.1) - svelte: 4.0.5 - wonka: 6.3.2 - transitivePeerDependencies: - - graphql - dev: false - - /@vitest/expect@0.33.0: - resolution: {integrity: sha512-sVNf+Gla3mhTCxNJx+wJLDPp/WcstOe0Ksqz4Vec51MmgMth/ia0MGFEkIZmVGeTL5HtjYR4Wl/ZxBxBXZJTzQ==} - dependencies: - '@vitest/spy': 0.33.0 - '@vitest/utils': 0.33.0 - chai: 4.3.7 - dev: true - - /@vitest/runner@0.33.0: - resolution: {integrity: sha512-UPfACnmCB6HKRHTlcgCoBh6ppl6fDn+J/xR8dTufWiKt/74Y9bHci5CKB8tESSV82zKYtkBJo9whU3mNvfaisg==} - dependencies: - '@vitest/utils': 0.33.0 - p-limit: 4.0.0 - pathe: 1.1.1 - dev: true - - /@vitest/snapshot@0.33.0: - resolution: {integrity: sha512-tJjrl//qAHbyHajpFvr8Wsk8DIOODEebTu7pgBrP07iOepR5jYkLFiqLq2Ltxv+r0uptUb4izv1J8XBOwKkVYA==} - dependencies: - magic-string: 0.30.1 - pathe: 1.1.1 - pretty-format: 29.6.1 - dev: true - - /@vitest/spy@0.33.0: - resolution: {integrity: sha512-Kv+yZ4hnH1WdiAkPUQTpRxW8kGtH8VRTnus7ZTGovFYM1ZezJpvGtb9nPIjPnptHbsyIAxYZsEpVPYgtpjGnrg==} - dependencies: - tinyspy: 2.1.1 - dev: true - - /@vitest/utils@0.33.0: - resolution: {integrity: sha512-pF1w22ic965sv+EN6uoePkAOTkAPWM03Ri/jXNyMIKBb/XHLDPfhLvf/Fa9g0YECevAIz56oVYXhodLvLQ/awA==} - dependencies: - diff-sequences: 29.4.3 - loupe: 2.3.6 - pretty-format: 29.6.1 - dev: true - - /@whatwg-node/events@0.0.3: - resolution: {integrity: sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==} - dev: true - - /@whatwg-node/events@0.1.1: - resolution: {integrity: sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==} - engines: {node: '>=16.0.0'} - dev: true - - /@whatwg-node/fetch@0.8.8: - resolution: {integrity: sha512-CdcjGC2vdKhc13KKxgsc6/616BQ7ooDIgPeTuAiE8qfCnS0mGzcfCOoZXypQSz73nxI+GWc7ZReIAVhxoE1KCg==} - dependencies: - '@peculiar/webcrypto': 1.4.3 - '@whatwg-node/node-fetch': 0.3.6 - busboy: 1.6.0 - urlpattern-polyfill: 8.0.2 - web-streams-polyfill: 3.2.1 - dev: true - - /@whatwg-node/fetch@0.9.9: - resolution: {integrity: sha512-OTVoDm039CNyAWSRc2WBimMl/N9J4Fk2le21Xzcf+3OiWPNNSIbMnpWKBUyraPh2d9SAEgoBdQxTfVNihXgiUw==} - engines: {node: '>=16.0.0'} - dependencies: - '@whatwg-node/node-fetch': 0.4.8 - urlpattern-polyfill: 9.0.0 - dev: true - - /@whatwg-node/node-fetch@0.3.6: - resolution: {integrity: sha512-w9wKgDO4C95qnXZRwZTfCmLWqyRnooGjcIwG0wADWjw9/HN0p7dtvtgSvItZtUyNteEvgTrd8QojNEqV6DAGTA==} - dependencies: - '@whatwg-node/events': 0.0.3 - busboy: 1.6.0 - fast-querystring: 1.1.2 - fast-url-parser: 1.1.3 - tslib: 2.6.0 - dev: true - - /@whatwg-node/node-fetch@0.4.8: - resolution: {integrity: sha512-9r/UE3rSjpiUv+FM3SnL8ib3VT/fk02iIAQOU0SJN57b7zJXeyQ1BEf2Q7glQj4E/Bw5pSRbdOLHV8n4ZdcLLQ==} - engines: {node: '>=16.0.0'} - dependencies: - '@whatwg-node/events': 0.1.1 - busboy: 1.6.0 - fast-querystring: 1.1.2 - fast-url-parser: 1.1.3 - tslib: 2.6.0 - dev: true - - /acorn-jsx@5.3.2(acorn@8.10.0): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.10.0 - dev: true - - /acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - dev: true - - /acorn@8.10.0: - resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} - engines: {node: '>=0.4.0'} - hasBin: true - - /agent-base@7.1.0: - resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} - engines: {node: '>= 14'} - dependencies: - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: true - - /aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - dev: true - - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - dev: true - - /ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.21.3 - dev: true - - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - dev: true - - /ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - dependencies: - color-convert: 1.9.3 - dev: true - - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - - /ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - dev: true - - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - - /aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - dependencies: - dequal: 2.0.3 - - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true - - /asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - dev: true - - /asn1js@3.0.5: - resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} - engines: {node: '>=12.0.0'} - dependencies: - pvtsutils: 1.3.2 - pvutils: 1.1.3 - tslib: 2.6.0 - dev: true - - /assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - dev: true - - /astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - dev: true - - /auto-bind@4.0.0: - resolution: {integrity: sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==} - engines: {node: '>=8'} - dev: true - - /axobject-query@3.2.1: - resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} - dependencies: - dequal: 2.0.3 - - /babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: - resolution: {integrity: sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==} - dev: true - - /babel-preset-fbjs@3.4.0(@babel/core@7.22.9): - resolution: {integrity: sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.22.9) - '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.22.9) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.22.9) - '@babel/plugin-syntax-flow': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.9) - '@babel/plugin-transform-arrow-functions': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-block-scoped-functions': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-block-scoping': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-classes': 7.22.6(@babel/core@7.22.9) - '@babel/plugin-transform-computed-properties': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-destructuring': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-flow-strip-types': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-for-of': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-function-name': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-literals': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-member-expression-literals': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-modules-commonjs': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-object-super': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-parameters': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-property-literals': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-react-display-name': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-react-jsx': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-shorthand-properties': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-spread': 7.22.5(@babel/core@7.22.9) - '@babel/plugin-transform-template-literals': 7.22.5(@babel/core@7.22.9) - babel-plugin-syntax-trailing-function-commas: 7.0.0-beta.0 - dev: true - - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true - - /base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true - - /binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - dev: true - - /bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - dev: true - - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true - - /brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - dependencies: - balanced-match: 1.0.2 - dev: true - - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.0.1 - dev: true - - /browserslist@4.21.9: - resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - caniuse-lite: 1.0.30001516 - electron-to-chromium: 1.4.461 - node-releases: 2.0.13 - update-browserslist-db: 1.0.11(browserslist@4.21.9) - dev: true - - /bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - dependencies: - node-int64: 0.4.0 - dev: true - - /buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - dev: true - - /buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - dev: true - - /builtin-modules@3.3.0: - resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} - engines: {node: '>=6'} - dev: true - - /busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - dependencies: - streamsearch: 1.1.0 - dev: true - - /cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - dev: true - - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true - - /camel-case@4.1.2: - resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} - dependencies: - pascal-case: 3.1.2 - tslib: 2.6.0 - dev: true - - /camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - dev: true - - /caniuse-lite@1.0.30001516: - resolution: {integrity: sha512-Wmec9pCBY8CWbmI4HsjBeQLqDTqV91nFVR83DnZpYyRnPI1wePDsTg0bGLPC5VU/3OIZV1fmxEea1b+tFKe86g==} - dev: true - - /capital-case@1.0.4: - resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} - dependencies: - no-case: 3.0.4 - tslib: 2.6.0 - upper-case-first: 2.0.2 - dev: true - - /chai@4.3.7: - resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} - engines: {node: '>=4'} - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.2 - deep-eql: 4.1.3 - get-func-name: 2.0.0 - loupe: 2.3.6 - pathval: 1.1.1 - type-detect: 4.0.8 - dev: true - - /chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - dev: true - - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - dev: true - - /change-case-all@1.0.15: - resolution: {integrity: sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==} - dependencies: - change-case: 4.1.2 - is-lower-case: 2.0.2 - is-upper-case: 2.0.2 - lower-case: 2.0.2 - lower-case-first: 2.0.2 - sponge-case: 1.0.1 - swap-case: 2.0.2 - title-case: 3.0.3 - upper-case: 2.0.2 - upper-case-first: 2.0.2 - dev: true - - /change-case@4.1.2: - resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} - dependencies: - camel-case: 4.1.2 - capital-case: 1.0.4 - constant-case: 3.0.4 - dot-case: 3.0.4 - header-case: 2.0.4 - no-case: 3.0.4 - param-case: 3.0.4 - pascal-case: 3.1.2 - path-case: 3.0.4 - sentence-case: 3.0.4 - snake-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - dev: true - - /check-error@1.0.2: - resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} - dev: true - - /chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.3 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.2 - dev: true - - /clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - dev: true - - /cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - dependencies: - restore-cursor: 3.1.0 - dev: true - - /cli-spinners@2.9.0: - resolution: {integrity: sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==} - engines: {node: '>=6'} - dev: true - - /cli-truncate@2.1.0: - resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} - engines: {node: '>=8'} - dependencies: - slice-ansi: 3.0.0 - string-width: 4.2.3 - dev: true - - /cli-width@3.0.0: - resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} - engines: {node: '>= 10'} - dev: true - - /cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - dev: true - - /cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - dev: true - - /clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - dev: true - - /code-red@1.0.3: - resolution: {integrity: sha512-kVwJELqiILQyG5aeuyKFbdsI1fmQy1Cmf7dQ8eGmVuJoaRVdwey7WaMknr2ZFeVSYSKT0rExsa8EGw0aoI/1QQ==} - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - '@types/estree': 1.0.1 - acorn: 8.10.0 - estree-walker: 3.0.3 - periscopic: 3.1.0 - - /color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - dependencies: - color-name: 1.1.3 - dev: true - - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - - /color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true - - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true - - /colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - dev: true - - /common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - dev: true - - /commondir@1.0.1: - resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - dev: true - - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true - - /concurrently@8.2.0: - resolution: {integrity: sha512-nnLMxO2LU492mTUj9qX/az/lESonSZu81UznYDoXtz1IQf996ixVqPAgHXwvHiHCAef/7S8HIK+fTFK7Ifk8YA==} - engines: {node: ^14.13.0 || >=16.0.0} - hasBin: true - dependencies: - chalk: 4.1.2 - date-fns: 2.30.0 - lodash: 4.17.21 - rxjs: 7.8.1 - shell-quote: 1.8.1 - spawn-command: 0.0.2 - supports-color: 8.1.1 - tree-kill: 1.2.2 - yargs: 17.7.2 - dev: true - - /constant-case@3.0.4: - resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} - dependencies: - no-case: 3.0.4 - tslib: 2.6.0 - upper-case: 2.0.2 - dev: true - - /convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - dev: true - - /cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} - engines: {node: '>= 0.6'} - dev: true - - /cosmiconfig@8.2.0: - resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} - engines: {node: '>=14'} - dependencies: - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - path-type: 4.0.0 - dev: true - - /cross-fetch@3.1.8: - resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} - dependencies: - node-fetch: 2.6.12 - transitivePeerDependencies: - - encoding - dev: true - - /cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - dev: true - - /css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.0.2 - - /cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - dev: true - - /dataloader@2.2.2: - resolution: {integrity: sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==} - dev: true - - /date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - dependencies: - '@babel/runtime': 7.22.6 - dev: true - - /debounce@1.2.1: - resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} - dev: true - - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - dev: true - - /decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - dev: true - - /dedent-js@1.0.1: - resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} - dev: true - - /deep-eql@4.1.3: - resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} - engines: {node: '>=6'} - dependencies: - type-detect: 4.0.8 - dev: true - - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true - - /deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - dev: true - - /defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - dependencies: - clone: 1.0.4 - dev: true - - /dependency-graph@0.11.0: - resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} - engines: {node: '>= 0.6.0'} - dev: true - - /dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - /detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} - engines: {node: '>=8'} - dev: true - - /detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - dev: true - - /devalue@4.3.2: - resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} - dev: true - - /diff-sequences@29.4.3: - resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dependencies: - path-type: 4.0.0 - dev: true - - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dependencies: - esutils: 2.0.3 - dev: true - - /dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} - dependencies: - no-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /dotenv@16.3.1: - resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} - engines: {node: '>=12'} - dev: true - - /dset@3.1.2: - resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==} - engines: {node: '>=4'} - dev: true - - /electron-to-chromium@1.4.461: - resolution: {integrity: sha512-1JkvV2sgEGTDXjdsaQCeSwYYuhLRphRpc+g6EHTFELJXEiznLt3/0pZ9JuAOQ5p2rI3YxKTbivtvajirIfhrEQ==} - dev: true - - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true - - /error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - dependencies: - is-arrayish: 0.2.1 - dev: true - - /es6-promise@3.3.1: - resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} - dev: true - - /esbuild@0.18.13: - resolution: {integrity: sha512-vhg/WR/Oiu4oUIkVhmfcc23G6/zWuEQKFS+yiosSHe4aN6+DQRXIfeloYGibIfVhkr4wyfuVsGNLr+sQU1rWWw==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/android-arm': 0.18.13 - '@esbuild/android-arm64': 0.18.13 - '@esbuild/android-x64': 0.18.13 - '@esbuild/darwin-arm64': 0.18.13 - '@esbuild/darwin-x64': 0.18.13 - '@esbuild/freebsd-arm64': 0.18.13 - '@esbuild/freebsd-x64': 0.18.13 - '@esbuild/linux-arm': 0.18.13 - '@esbuild/linux-arm64': 0.18.13 - '@esbuild/linux-ia32': 0.18.13 - '@esbuild/linux-loong64': 0.18.13 - '@esbuild/linux-mips64el': 0.18.13 - '@esbuild/linux-ppc64': 0.18.13 - '@esbuild/linux-riscv64': 0.18.13 - '@esbuild/linux-s390x': 0.18.13 - '@esbuild/linux-x64': 0.18.13 - '@esbuild/netbsd-x64': 0.18.13 - '@esbuild/openbsd-x64': 0.18.13 - '@esbuild/sunos-x64': 0.18.13 - '@esbuild/win32-arm64': 0.18.13 - '@esbuild/win32-ia32': 0.18.13 - '@esbuild/win32-x64': 0.18.13 - dev: true - - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - dev: true - - /escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - dev: true - - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: true - - /eslint-config-prettier@8.8.0(eslint@8.45.0): - resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - dependencies: - eslint: 8.45.0 - dev: true - - /eslint-plugin-svelte@2.32.2(eslint@8.45.0)(svelte@4.0.5): - resolution: {integrity: sha512-Jgbop2fNZsoxxkklZAIbDNhwAPynvnCtUXLsEC6O2qax7N/pfe2cNqT0ZoBbubXKJitQQDEyVDQ1rZs4ZWcrTA==} - engines: {node: ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0-0 - svelte: ^3.37.0 || ^4.0.0 - peerDependenciesMeta: - svelte: - optional: true - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@jridgewell/sourcemap-codec': 1.4.15 - debug: 4.3.4 - eslint: 8.45.0 - esutils: 2.0.3 - known-css-properties: 0.27.0 - postcss: 8.4.26 - postcss-load-config: 3.1.4(postcss@8.4.26) - postcss-safe-parser: 6.0.0(postcss@8.4.26) - postcss-selector-parser: 6.0.13 - semver: 7.5.4 - svelte: 4.0.5 - svelte-eslint-parser: 0.32.1(svelte@4.0.5) - transitivePeerDependencies: - - supports-color - - ts-node - dev: true - - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: true - - /eslint-scope@7.2.1: - resolution: {integrity: sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - dev: true - - /eslint-visitor-keys@3.4.1: - resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - - /eslint@8.45.0: - resolution: {integrity: sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - hasBin: true - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@eslint-community/regexpp': 4.5.1 - '@eslint/eslintrc': 2.1.0 - '@eslint/js': 8.44.0 - '@humanwhocodes/config-array': 0.11.10 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.4 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.1 - eslint-visitor-keys: 3.4.1 - espree: 9.6.1 - esquery: 1.5.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.20.0 - graphemer: 1.4.0 - ignore: 5.2.4 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.3 - strip-ansi: 6.0.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - dev: true - - /esm-env@1.0.0: - resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} - dev: true - - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - acorn: 8.10.0 - acorn-jsx: 5.3.2(acorn@8.10.0) - eslint-visitor-keys: 3.4.1 - dev: true - - /esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} - engines: {node: '>=0.10'} - dependencies: - estraverse: 5.3.0 - dev: true - - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - dependencies: - estraverse: 5.3.0 - dev: true - - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true - - /estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - dev: true - - /estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - dependencies: - '@types/estree': 1.0.1 - - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true - - /external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - dev: true - - /extract-files@11.0.0: - resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} - engines: {node: ^12.20 || >= 14.13} - dev: true - - /fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - dev: true - - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true - - /fast-glob@3.3.0: - resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: true - - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true - - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true - - /fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - dependencies: - fast-decode-uri-component: 1.0.1 - dev: true - - /fast-url-parser@1.1.3: - resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} - dependencies: - punycode: 1.4.1 - dev: true - - /fastq@1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} - dependencies: - reusify: 1.0.4 - dev: true - - /fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - dependencies: - bser: 2.1.1 - dev: true - - /fbjs-css-vars@1.0.2: - resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} - dev: true - - /fbjs@3.0.5: - resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - dependencies: - cross-fetch: 3.1.8 - fbjs-css-vars: 1.0.2 - loose-envify: 1.4.0 - object-assign: 4.1.1 - promise: 7.3.1 - setimmediate: 1.0.5 - ua-parser-js: 1.0.35 - transitivePeerDependencies: - - encoding - dev: true - - /figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} - dependencies: - escape-string-regexp: 1.0.5 - dev: true - - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flat-cache: 3.0.4 - dev: true - - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: true - - /find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - dev: true - - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - dev: true - - /flat-cache@3.0.4: - resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flatted: 3.2.7 - rimraf: 3.0.2 - dev: true - - /flatted@3.2.7: - resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} - dev: true - - /fs-extra@11.1.1: - resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} - engines: {node: '>=14.14'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.0 - dev: true - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true - - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /function-bind@1.1.1: - resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - dev: true - - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: true - - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: true - - /get-func-name@2.0.0: - resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} - dev: true - - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: true - - /glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: true - - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: true - - /glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.6 - once: 1.4.0 - dev: true - - /globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - dev: true - - /globals@13.20.0: - resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - dev: true - - /globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.0 - ignore: 5.2.4 - merge2: 1.4.1 - slash: 3.0.0 - dev: true - - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true - - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: true - - /graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - dev: true - - /graphql-config@5.0.2(@types/node@20.4.2)(graphql@16.7.1): - resolution: {integrity: sha512-7TPxOrlbiG0JplSZYCyxn2XQtqVhXomEjXUmWJVSS5ET1nPhOJSsIb/WTwqWhcYX6G0RlHXSj9PLtGTKmxLNGg==} - engines: {node: '>= 16.0.0'} - peerDependencies: - cosmiconfig-toml-loader: ^1.0.0 - graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - peerDependenciesMeta: - cosmiconfig-toml-loader: - optional: true - dependencies: - '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.7.1) - '@graphql-tools/json-file-loader': 8.0.0(graphql@16.7.1) - '@graphql-tools/load': 8.0.0(graphql@16.7.1) - '@graphql-tools/merge': 9.0.0(graphql@16.7.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/utils': 10.0.3(graphql@16.7.1) - cosmiconfig: 8.2.0 - graphql: 16.7.1 - jiti: 1.19.1 - minimatch: 4.2.3 - string-env-interpolation: 1.0.1 - tslib: 2.6.0 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - utf-8-validate - dev: true - - /graphql-request@6.1.0(graphql@16.7.1): - resolution: {integrity: sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==} - peerDependencies: - graphql: 14 - 16 - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) - cross-fetch: 3.1.8 - graphql: 16.7.1 - transitivePeerDependencies: - - encoding - dev: true - - /graphql-tag@2.12.6(graphql@16.7.1): - resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} - engines: {node: '>=10'} - peerDependencies: - graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - graphql: 16.7.1 - tslib: 2.6.0 - dev: true - - /graphql-ws@5.14.0(graphql@16.7.1): - resolution: {integrity: sha512-itrUTQZP/TgswR4GSSYuwWUzrE/w5GhbwM2GX3ic2U7aw33jgEsayfIlvaj7/GcIvZgNMzsPTrE5hqPuFUiE5g==} - engines: {node: '>=10'} - peerDependencies: - graphql: '>=0.11 <=16' - dependencies: - graphql: 16.7.1 - - /graphql@16.7.1: - resolution: {integrity: sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - - /has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - dev: true - - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - dev: true - - /has@1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - dependencies: - function-bind: 1.1.1 - dev: true - - /header-case@2.0.4: - resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} - dependencies: - capital-case: 1.0.4 - tslib: 2.6.0 - dev: true - - /http-proxy-agent@7.0.0: - resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: true - - /https-proxy-agent@7.0.1: - resolution: {integrity: sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: true - - /iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - dependencies: - safer-buffer: 2.1.2 - dev: true - - /ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true - - /ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} - engines: {node: '>= 4'} - dev: true - - /immutable@3.7.6: - resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} - engines: {node: '>=0.8.0'} - dev: true - - /immutable@4.3.1: - resolution: {integrity: sha512-lj9cnmB/kVS0QHsJnYKD1uo3o39nrbKxszjnqS9Fr6NB7bZzW45U6WSGBPKXDL/CvDKqDNPA4r3DoDQ8GTxo2A==} - dev: true - - /import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - dev: true - - /import-from@4.0.0: - resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} - engines: {node: '>=12.2'} - dev: true - - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - dev: true - - /indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - dev: true - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: true - - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true - - /inquirer@8.2.5: - resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} - engines: {node: '>=12.0.0'} - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.17.21 - mute-stream: 0.0.8 - ora: 5.4.1 - run-async: 2.4.1 - rxjs: 7.8.1 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - wrap-ansi: 7.0.0 - dev: true - - /invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - dependencies: - loose-envify: 1.4.0 - dev: true - - /is-absolute@1.0.0: - resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} - engines: {node: '>=0.10.0'} - dependencies: - is-relative: 1.0.0 - is-windows: 1.0.2 - dev: true - - /is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - dev: true - - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - dependencies: - binary-extensions: 2.2.0 - dev: true - - /is-builtin-module@3.2.1: - resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} - engines: {node: '>=6'} - dependencies: - builtin-modules: 3.3.0 - dev: true - - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} - dependencies: - has: 1.0.3 - dev: true - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: true - - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - dev: true - - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - - /is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - dev: true - - /is-lower-case@2.0.2: - resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} - dependencies: - tslib: 2.6.0 - dev: true - - /is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - dev: true - - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: true - - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - dev: true - - /is-reference@1.2.1: - resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - dependencies: - '@types/estree': 1.0.1 - dev: true - - /is-reference@3.0.1: - resolution: {integrity: sha512-baJJdQLiYaJdvFbJqXrcGv3WU3QCzBlUcI5QhbesIm6/xPsvmO+2CDoi/GMOFBQEQm+PXkwOPrp9KK5ozZsp2w==} - dependencies: - '@types/estree': 1.0.1 - - /is-relative@1.0.0: - resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} - engines: {node: '>=0.10.0'} - dependencies: - is-unc-path: 1.0.0 - dev: true - - /is-unc-path@1.0.0: - resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} - engines: {node: '>=0.10.0'} - dependencies: - unc-path-regex: 0.1.2 - dev: true - - /is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - dev: true - - /is-upper-case@2.0.2: - resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} - dependencies: - tslib: 2.6.0 - dev: true - - /is-windows@1.0.2: - resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} - engines: {node: '>=0.10.0'} - dev: true - - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true - - /isomorphic-ws@5.0.0(ws@8.13.0): - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - dependencies: - ws: 8.13.0 - dev: true - - /jiti@1.19.1: - resolution: {integrity: sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==} - hasBin: true - dev: true - - /jose@4.14.4: - resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==} - dev: true - - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - - /jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - dev: true - - /json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - dev: true - - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true - - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true - - /json-stable-stringify@1.0.2: - resolution: {integrity: sha512-eunSSaEnxV12z+Z73y/j5N37/In40GK4GmsSy+tEHJMxknvqnA7/djeYtAgW0GsWHUfg+847WJjKaEylk2y09g==} - dependencies: - jsonify: 0.0.1 - dev: true - - /json-to-pretty-yaml@1.2.2: - resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} - engines: {node: '>= 0.2.0'} - dependencies: - remedial: 1.0.8 - remove-trailing-spaces: 1.0.8 - dev: true - - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - dev: true - - /jsonc-parser@3.2.0: - resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} - dev: true - - /jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - dependencies: - universalify: 2.0.0 - optionalDependencies: - graceful-fs: 4.2.11 - dev: true - - /jsonify@0.0.1: - resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - dev: true - - /kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - dev: true - - /known-css-properties@0.27.0: - resolution: {integrity: sha512-uMCj6+hZYDoffuvAJjFAPz56E9uoowFHmTkqRtRq5WyC5Q6Cu/fTZKNQpX/RbzChBYLLl3lo8CjFZBAZXq9qFg==} - dev: true - - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - dev: true - - /lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - dev: true - - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true - - /listr2@4.0.5: - resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} - engines: {node: '>=12'} - peerDependencies: - enquirer: '>= 2.3.0 < 3' - peerDependenciesMeta: - enquirer: - optional: true - dependencies: - cli-truncate: 2.1.0 - colorette: 2.0.20 - log-update: 4.0.0 - p-map: 4.0.0 - rfdc: 1.3.0 - rxjs: 7.8.1 - through: 2.3.8 - wrap-ansi: 7.0.0 - dev: true - - /local-pkg@0.4.3: - resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} - engines: {node: '>=14'} - dev: true - - /locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - /locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - dependencies: - p-locate: 4.1.0 - dev: true - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true - - /lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - dev: true - - /lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true - - /log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - dev: true - - /log-update@4.0.0: - resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} - engines: {node: '>=10'} - dependencies: - ansi-escapes: 4.3.2 - cli-cursor: 3.1.0 - slice-ansi: 4.0.0 - wrap-ansi: 6.2.0 - dev: true - - /loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - dependencies: - js-tokens: 4.0.0 - - /loupe@2.3.6: - resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} - dependencies: - get-func-name: 2.0.0 - dev: true - - /lower-case-first@2.0.2: - resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} - dependencies: - tslib: 2.6.0 - dev: true - - /lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - dependencies: - tslib: 2.6.0 - dev: true - - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - dependencies: - yallist: 3.1.1 - dev: true - - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - dependencies: - yallist: 4.0.0 - dev: true - - /magic-string@0.27.0: - resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} - engines: {node: '>=12'} - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - - /magic-string@0.30.1: - resolution: {integrity: sha512-mbVKXPmS0z0G4XqFDCTllmDQ6coZzn94aMlb0o/A4HEHJCKcanlDZwYJgwnkmgD3jyWhUgj9VsPrfd972yPffA==} - engines: {node: '>=12'} - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - - /map-cache@0.2.2: - resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} - engines: {node: '>=0.10.0'} - dev: true - - /mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: true - - /meros@1.3.0(@types/node@20.4.2): - resolution: {integrity: sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==} - engines: {node: '>=13'} - peerDependencies: - '@types/node': '>=13' - peerDependenciesMeta: - '@types/node': - optional: true - dependencies: - '@types/node': 20.4.2 - dev: true - - /micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.2 - picomatch: 2.3.1 - dev: true - - /mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - dev: true - - /mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - dev: true - - /min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - dev: true - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - dev: true - - /minimatch@4.2.3: - resolution: {integrity: sha512-lIUdtK5hdofgCTu3aT0sOaHsYR37viUuIc0rwnnDXImbwFRcumyLMeZaM0t0I/fgxS6s6JMfu0rLD1Wz9pv1ng==} - engines: {node: '>=10'} - dependencies: - brace-expansion: 1.1.11 - dev: true - - /minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - dependencies: - brace-expansion: 2.0.1 - dev: true - - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true - - /mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - dependencies: - minimist: 1.2.8 - dev: true - - /mlly@1.4.0: - resolution: {integrity: sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==} - dependencies: - acorn: 8.10.0 - pathe: 1.1.1 - pkg-types: 1.0.3 - ufo: 1.1.2 - dev: true - - /mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - dev: true - - /mrmime@1.0.1: - resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} - engines: {node: '>=10'} - dev: true - - /ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true - - /mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - dev: true - - /nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true - - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: true - - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true - - /no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - dependencies: - lower-case: 2.0.2 - tslib: 2.6.0 - dev: true - - /node-addon-api@7.0.0: - resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} - dev: true - - /node-fetch@2.6.12: - resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - dependencies: - whatwg-url: 5.0.0 - dev: true - - /node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - dev: true - - /node-releases@2.0.13: - resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} - dev: true - - /normalize-path@2.1.1: - resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} - engines: {node: '>=0.10.0'} - dependencies: - remove-trailing-separator: 1.1.0 - dev: true - - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: true - - /nullthrows@1.1.1: - resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} - dev: true - - /object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - dev: true - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - dev: true - - /onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - dependencies: - mimic-fn: 2.1.0 - dev: true - - /optionator@0.9.3: - resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} - engines: {node: '>= 0.8.0'} - dependencies: - '@aashutoshrathi/word-wrap': 1.2.6 - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - dev: true - - /ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.0 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - dev: true - - /os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - dev: true - - /p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - dependencies: - p-try: 2.2.0 - dev: true - - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - - /p-limit@4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - yocto-queue: 1.0.0 - dev: true - - /p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - dependencies: - p-limit: 2.3.0 - dev: true - - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: true - - /p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - dependencies: - aggregate-error: 3.1.0 - dev: true - - /p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - dev: true - - /param-case@3.0.4: - resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} - dependencies: - dot-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - - /parse-filepath@1.0.2: - resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} - engines: {node: '>=0.8'} - dependencies: - is-absolute: 1.0.0 - map-cache: 0.2.2 - path-root: 0.1.1 - dev: true - - /parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - dependencies: - '@babel/code-frame': 7.22.5 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - dev: true - - /pascal-case@3.1.2: - resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} - dependencies: - no-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /path-case@3.0.4: - resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} - dependencies: - dot-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - dev: true - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true - - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true - - /path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true - - /path-root-regex@0.1.2: - resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} - engines: {node: '>=0.10.0'} - dev: true - - /path-root@0.1.1: - resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} - engines: {node: '>=0.10.0'} - dependencies: - path-root-regex: 0.1.2 - dev: true - - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - dev: true - - /pathe@1.1.1: - resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==} - dev: true - - /pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - dev: true - - /periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - dependencies: - '@types/estree': 1.0.1 - estree-walker: 3.0.3 - is-reference: 3.0.1 - - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true - - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: true - - /pkg-types@1.0.3: - resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} - dependencies: - jsonc-parser: 3.2.0 - mlly: 1.4.0 - pathe: 1.1.1 - dev: true - - /playwright-core@1.36.1: - resolution: {integrity: sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==} - engines: {node: '>=16'} - hasBin: true - dev: true - - /postcss-load-config@3.1.4(postcss@8.4.26): - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 2.1.0 - postcss: 8.4.26 - yaml: 1.10.2 - dev: true - - /postcss-safe-parser@6.0.0(postcss@8.4.26): - resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.3.3 - dependencies: - postcss: 8.4.26 - dev: true - - /postcss-scss@4.0.6(postcss@8.4.26): - resolution: {integrity: sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.4.19 - dependencies: - postcss: 8.4.26 - dev: true - - /postcss-selector-parser@6.0.13: - resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} - engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - dev: true - - /postcss@8.4.26: - resolution: {integrity: sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: true - - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - dev: true - - /prettier-plugin-svelte@3.0.0(prettier@3.0.0)(svelte@4.0.5): - resolution: {integrity: sha512-l3RQcPty2UBCoRh3yb9c5XCAmxkrc4BptAnbd5acO1gmSJtChOWkiEjnOvh7hvmtT4V80S8gXCOKAq8RNeIzSw==} - peerDependencies: - prettier: ^3.0.0 - svelte: ^3.2.0 || ^4.0.0-next.0 - dependencies: - prettier: 3.0.0 - svelte: 4.0.5 - dev: true - - /prettier@3.0.0: - resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} - engines: {node: '>=14'} - hasBin: true - dev: true - - /pretty-format@29.6.1: - resolution: {integrity: sha512-7jRj+yXO0W7e4/tSJKoR7HRIHLPPjtNaUGG2xxKQnGvPNRkgWcQ0AZX6P4KBRJN4FcTBWb3sa7DVUJmocYuoog==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.6.0 - ansi-styles: 5.2.0 - react-is: 18.2.0 - dev: true - - /promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - dependencies: - asap: 2.0.6 - dev: true - - /punycode@1.4.1: - resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} - dev: true - - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} - engines: {node: '>=6'} - dev: true - - /pvtsutils@1.3.2: - resolution: {integrity: sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==} - dependencies: - tslib: 2.6.0 - dev: true - - /pvutils@1.1.3: - resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} - engines: {node: '>=6.0.0'} - dev: true - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - - /react-is@18.2.0: - resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} - dev: true - - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} - engines: {node: '>=0.10.0'} - dependencies: - loose-envify: 1.4.0 - dev: false - - /readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - dev: true - - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: true - - /regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - dev: true - - /relay-runtime@12.0.0: - resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==} - dependencies: - '@babel/runtime': 7.22.6 - fbjs: 3.0.5 - invariant: 2.2.4 - transitivePeerDependencies: - - encoding - dev: true - - /remedial@1.0.8: - resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} - dev: true - - /remove-trailing-separator@1.1.0: - resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} - dev: true - - /remove-trailing-spaces@1.0.8: - resolution: {integrity: sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==} - dev: true - - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: true - - /require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - dev: true - - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true - - /resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - dev: true - - /resolve@1.22.2: - resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} - hasBin: true - dependencies: - is-core-module: 2.12.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: true - - /restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - dev: true - - /reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true - - /rfdc@1.3.0: - resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} - dev: true - - /rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: true - - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: true - - /rollup@3.26.2: - resolution: {integrity: sha512-6umBIGVz93er97pMgQO08LuH3m6PUb3jlDUUGFsNJB6VgTCUaDFpupf5JfU30529m/UKOgmiX+uY6Sx8cOYpLA==} - engines: {node: '>=14.18.0', npm: '>=8.0.0'} - hasBin: true - optionalDependencies: - fsevents: 2.3.2 - dev: true - - /run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} - engines: {node: '>=0.12.0'} - dev: true - - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: true - - /rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - dependencies: - tslib: 2.6.0 - dev: true - - /sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - dependencies: - mri: 1.2.0 - dev: true - - /safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: true - - /safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - dev: true - - /sander@0.5.1: - resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} - dependencies: - es6-promise: 3.3.1 - graceful-fs: 4.2.11 - mkdirp: 0.5.6 - rimraf: 2.7.1 - dev: true - - /sass@1.63.6: - resolution: {integrity: sha512-MJuxGMHzaOW7ipp+1KdELtqKbfAWbH7OLIdoSMnVe3EXPMTmxTmlaZDCTsgIpPCs3w99lLo9/zDKkOrJuT5byw==} - engines: {node: '>=14.0.0'} - hasBin: true - dependencies: - chokidar: 3.5.3 - immutable: 4.3.1 - source-map-js: 1.0.2 - dev: true - - /scuid@1.1.0: - resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} - dev: true - - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - dev: true - - /semver@7.5.4: - resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} - engines: {node: '>=10'} - hasBin: true - dependencies: - lru-cache: 6.0.0 - dev: true - - /sentence-case@3.0.4: - resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} - dependencies: - no-case: 3.0.4 - tslib: 2.6.0 - upper-case-first: 2.0.2 - dev: true - - /set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - dev: true - - /set-cookie-parser@2.6.0: - resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} - dev: true - - /setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - dev: true - - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: true - - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: true - - /shell-quote@1.8.1: - resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} - dev: true - - /siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - dev: true - - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: true - - /signedsource@1.0.0: - resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} - dev: true - - /sirv@2.0.3: - resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==} - engines: {node: '>= 10'} - dependencies: - '@polka/url': 1.0.0-next.21 - mrmime: 1.0.1 - totalist: 3.0.1 - dev: true - - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true - - /slice-ansi@3.0.0: - resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} - engines: {node: '>=8'} - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - dev: true - - /slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - dev: true - - /snake-case@3.0.4: - resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - dependencies: - dot-case: 3.0.4 - tslib: 2.6.0 - dev: true - - /sorcery@0.11.0: - resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} - hasBin: true - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - buffer-crc32: 0.2.13 - minimist: 1.2.8 - sander: 0.5.1 - dev: true - - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - - /spawn-command@0.0.2: - resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} - dev: true - - /sponge-case@1.0.1: - resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} - dependencies: - tslib: 2.6.0 - dev: true - - /stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - dev: true - - /std-env@3.3.3: - resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} - dev: true - - /streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - dev: true - - /string-env-interpolation@1.0.1: - resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} - dev: true - - /string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - dev: true - - /string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - dependencies: - safe-buffer: 5.2.1 - dev: true - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - dev: true - - /strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - dependencies: - min-indent: 1.0.1 - dev: true - - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - dev: true - - /strip-literal@1.0.1: - resolution: {integrity: sha512-QZTsipNpa2Ppr6v1AmJHESqJ3Uz247MUS0OjrnnZjFAvEoWqxuyFuXn2xLgMtRnijJShAa1HL0gtJyUs7u7n3Q==} - dependencies: - acorn: 8.10.0 - dev: true - - /supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - dependencies: - has-flag: 3.0.0 - dev: true - - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - dev: true - - /supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - dependencies: - has-flag: 4.0.0 - dev: true - - /supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - dev: true - - /svelte-check@3.4.6(@babel/core@7.22.9)(postcss@8.4.26)(sass@1.63.6)(svelte@4.0.5): - resolution: {integrity: sha512-OBlY8866Zh1zHQTkBMPS6psPi7o2umTUyj6JWm4SacnIHXpWFm658pG32m3dKvKFL49V4ntAkfFHKo4ztH07og==} - hasBin: true - peerDependencies: - svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 - dependencies: - '@jridgewell/trace-mapping': 0.3.18 - chokidar: 3.5.3 - fast-glob: 3.3.0 - import-fresh: 3.3.0 - picocolors: 1.0.0 - sade: 1.8.1 - svelte: 4.0.5 - svelte-preprocess: 5.0.4(@babel/core@7.22.9)(postcss@8.4.26)(sass@1.63.6)(svelte@4.0.5)(typescript@5.1.6) - typescript: 5.1.6 - transitivePeerDependencies: - - '@babel/core' - - coffeescript - - less - - postcss - - postcss-load-config - - pug - - sass - - stylus - - sugarss - dev: true - - /svelte-eslint-parser@0.32.1(svelte@4.0.5): - resolution: {integrity: sha512-GCSfeIzdgk53CaOzK+s/+l2igfTno3mWGkwoDYAwPes/rD9Al2fc7ksfopjx5UL87S7dw1eL73F6wNYiiuhzIA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - svelte: ^3.37.0 || ^4.0.0 - peerDependenciesMeta: - svelte: - optional: true - dependencies: - eslint-scope: 7.2.1 - eslint-visitor-keys: 3.4.1 - espree: 9.6.1 - postcss: 8.4.26 - postcss-scss: 4.0.6(postcss@8.4.26) - svelte: 4.0.5 - dev: true - - /svelte-fa@3.0.4: - resolution: {integrity: sha512-y04vEuAoV1wwVDItSCzPW7lzT6v1bj/y1p+W1V+NtIMpQ+8hj8MBkx7JFD7JHSnalPU1QiI8BVfguqheEA3nPg==} - dev: true - - /svelte-hmr@0.15.2(svelte@4.0.5): - resolution: {integrity: sha512-q/bAruCvFLwvNbeE1x3n37TYFb3mTBJ6TrCq6p2CoFbSTNhDE9oAtEfpy+wmc9So8AG0Tja+X0/mJzX9tSfvIg==} - engines: {node: ^12.20 || ^14.13.1 || >= 16} - peerDependencies: - svelte: ^3.19.0 || ^4.0.0-next.0 - dependencies: - svelte: 4.0.5 - dev: true - - /svelte-preprocess@5.0.4(@babel/core@7.22.9)(postcss@8.4.26)(sass@1.63.6)(svelte@4.0.5)(typescript@5.1.6): - resolution: {integrity: sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==} - engines: {node: '>= 14.10.0'} - requiresBuild: true - peerDependencies: - '@babel/core': ^7.10.2 - coffeescript: ^2.5.1 - less: ^3.11.3 || ^4.0.0 - postcss: ^7 || ^8 - postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 - pug: ^3.0.0 - sass: ^1.26.8 - stylus: ^0.55.0 - sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 - svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 - typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' - peerDependenciesMeta: - '@babel/core': - optional: true - coffeescript: - optional: true - less: - optional: true - postcss: - optional: true - postcss-load-config: - optional: true - pug: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - typescript: - optional: true - dependencies: - '@babel/core': 7.22.9 - '@types/pug': 2.0.6 - detect-indent: 6.1.0 - magic-string: 0.27.0 - postcss: 8.4.26 - sass: 1.63.6 - sorcery: 0.11.0 - strip-indent: 3.0.0 - svelte: 4.0.5 - typescript: 5.1.6 - dev: true - - /svelte-turnstile@0.5.0(svelte@4.0.5): - resolution: {integrity: sha512-FD/XOfyN2gOr7csfThLyrS/sSsRd2zgVfm5pwE1KvpWQHJYqx1HL/PQ92WEw8peL+SPqoKYx2619aZ65uPUsxg==} - peerDependencies: - svelte: ^3.58.0 || ^4.0.0 - dependencies: - svelte: 4.0.5 - turnstile-types: 1.1.2 - dev: true - - /svelte2tsx@0.6.19(svelte@4.0.5)(typescript@5.1.6): - resolution: {integrity: sha512-h3b5OtcO8zyVL/RiB2zsDwCopeo/UH+887uyhgb2mjnewOFwiTxu+4IGuVwrrlyuh2onM2ktfUemNrNmQwXONQ==} - peerDependencies: - svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 - typescript: ^4.9.4 || ^5.0.0 - dependencies: - dedent-js: 1.0.1 - pascal-case: 3.1.2 - svelte: 4.0.5 - typescript: 5.1.6 - dev: true - - /svelte@4.0.5: - resolution: {integrity: sha512-PHKPWP1wiWHBtsE57nCb8xiWB3Ht7/3Kvi3jac0XIxUM2rep8alO7YoAtgWeGD7++tFy46krilOrPW0mG3Dx+A==} - engines: {node: '>=16'} - dependencies: - '@ampproject/remapping': 2.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.18 - acorn: 8.10.0 - aria-query: 5.3.0 - axobject-query: 3.2.1 - code-red: 1.0.3 - css-tree: 2.3.1 - estree-walker: 3.0.3 - is-reference: 3.0.1 - locate-character: 3.0.0 - magic-string: 0.30.1 - periscopic: 3.1.0 - - /swap-case@2.0.2: - resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} - dependencies: - tslib: 2.6.0 - dev: true - - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true - - /through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - dev: true - - /tinybench@2.5.0: - resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} - dev: true - - /tinypool@0.6.0: - resolution: {integrity: sha512-FdswUUo5SxRizcBc6b1GSuLpLjisa8N8qMyYoP3rl+bym+QauhtJP5bvZY1ytt8krKGmMLYIRl36HBZfeAoqhQ==} - engines: {node: '>=14.0.0'} - dev: true - - /tinyspy@2.1.1: - resolution: {integrity: sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==} - engines: {node: '>=14.0.0'} - dev: true - - /title-case@3.0.3: - resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} - dependencies: - tslib: 2.6.0 - dev: true - - /tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - dependencies: - os-tmpdir: 1.0.2 - dev: true - - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: true - - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: true - - /totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - dev: true - - /tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - dev: true - - /tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - dev: true - - /ts-api-utils@1.0.1(typescript@5.1.6): - resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} - engines: {node: '>=16.13.0'} - peerDependencies: - typescript: '>=4.2.0' - dependencies: - typescript: 5.1.6 - dev: true - - /ts-log@2.2.5: - resolution: {integrity: sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==} - dev: true - - /tslib@2.5.3: - resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} - dev: true - - /tslib@2.6.0: - resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==} - dev: true - - /turnstile-types@1.1.2: - resolution: {integrity: sha512-MJXbAeRPpORqPViZbHrpu8LWOHv1dp9i5GYSfoedu7GUmkUZpHWlAVLCFAhWlpRA5PQLBRworOGCEu4o6Ks7Zw==} - dev: true - - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - dev: true - - /type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - dev: true - - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - dev: true - - /type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - dev: true - - /typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - - /ua-parser-js@1.0.35: - resolution: {integrity: sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==} - dev: true - - /ufo@1.1.2: - resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} - dev: true - - /unc-path-regex@0.1.2: - resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} - engines: {node: '>=0.10.0'} - dev: true - - /undici@5.22.1: - resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} - engines: {node: '>=14.0'} - dependencies: - busboy: 1.6.0 - dev: true - - /universalify@2.0.0: - resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} - engines: {node: '>= 10.0.0'} - dev: true - - /unixify@1.0.0: - resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} - engines: {node: '>=0.10.0'} - dependencies: - normalize-path: 2.1.1 - dev: true - - /update-browserslist-db@1.0.11(browserslist@4.21.9): - resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.21.9 - escalade: 3.1.1 - picocolors: 1.0.0 - dev: true - - /upper-case-first@2.0.2: - resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} - dependencies: - tslib: 2.6.0 - dev: true - - /upper-case@2.0.2: - resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} - dependencies: - tslib: 2.6.0 - dev: true - - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - dependencies: - punycode: 2.3.0 - dev: true - - /urlpattern-polyfill@8.0.2: - resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} - dev: true - - /urlpattern-polyfill@9.0.0: - resolution: {integrity: sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==} - dev: true - - /urql@4.0.4(graphql@16.7.1)(react@18.2.0): - resolution: {integrity: sha512-C5P4BMnAsk+rbytCWglit5ijXbIKXsa9wofSGPbuMyJKsDdL+9GfipS362Nff/Caag+eYOK5W+sox8fwEILT6Q==} - peerDependencies: - react: '>= 16.8.0' - dependencies: - '@urql/core': 4.0.11(graphql@16.7.1) - react: 18.2.0 - wonka: 6.3.2 - transitivePeerDependencies: - - graphql - dev: false - - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true - - /value-or-promise@1.0.12: - resolution: {integrity: sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==} - engines: {node: '>=12'} - dev: true - - /vite-node@0.33.0(@types/node@20.4.2)(sass@1.63.6): - resolution: {integrity: sha512-19FpHYbwWWxDr73ruNahC+vtEdza52kA90Qb3La98yZ0xULqV8A5JLNPUff0f5zID4984tW7l3DH2przTJUZSw==} - engines: {node: '>=v14.18.0'} - hasBin: true - dependencies: - cac: 6.7.14 - debug: 4.3.4 - mlly: 1.4.0 - pathe: 1.1.1 - picocolors: 1.0.0 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - stylus - - sugarss - - supports-color - - terser - dev: true - - /vite@4.4.4(@types/node@20.4.2)(sass@1.63.6): - resolution: {integrity: sha512-4mvsTxjkveWrKDJI70QmelfVqTm+ihFAb6+xf4sjEU2TmUCTlVX87tmg/QooPEMQb/lM9qGHT99ebqPziEd3wg==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - '@types/node': 20.4.2 - esbuild: 0.18.13 - postcss: 8.4.26 - rollup: 3.26.2 - sass: 1.63.6 - optionalDependencies: - fsevents: 2.3.2 - dev: true - - /vitefu@0.2.4(vite@4.4.4): - resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 - peerDependenciesMeta: - vite: - optional: true - dependencies: - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - dev: true - - /vitest@0.33.0(sass@1.63.6): - resolution: {integrity: sha512-1CxaugJ50xskkQ0e969R/hW47za4YXDUfWJDxip1hwbnhUjYolpfUn2AMOulqG/Dtd9WYAtkHmM/m3yKVrEejQ==} - engines: {node: '>=v14.18.0'} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@vitest/browser': '*' - '@vitest/ui': '*' - happy-dom: '*' - jsdom: '*' - playwright: '*' - safaridriver: '*' - webdriverio: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true - dependencies: - '@types/chai': 4.3.5 - '@types/chai-subset': 1.3.3 - '@types/node': 20.4.2 - '@vitest/expect': 0.33.0 - '@vitest/runner': 0.33.0 - '@vitest/snapshot': 0.33.0 - '@vitest/spy': 0.33.0 - '@vitest/utils': 0.33.0 - acorn: 8.10.0 - acorn-walk: 8.2.0 - cac: 6.7.14 - chai: 4.3.7 - debug: 4.3.4 - local-pkg: 0.4.3 - magic-string: 0.30.1 - pathe: 1.1.1 - picocolors: 1.0.0 - std-env: 3.3.3 - strip-literal: 1.0.1 - tinybench: 2.5.0 - tinypool: 0.6.0 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vite-node: 0.33.0(@types/node@20.4.2)(sass@1.63.6) - why-is-node-running: 2.2.2 - transitivePeerDependencies: - - less - - lightningcss - - sass - - stylus - - sugarss - - supports-color - - terser - dev: true - - /wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - dependencies: - defaults: 1.0.4 - dev: true - - /web-streams-polyfill@3.2.1: - resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} - engines: {node: '>= 8'} - dev: true - - /webcrypto-core@1.7.7: - resolution: {integrity: sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==} - dependencies: - '@peculiar/asn1-schema': 2.3.6 - '@peculiar/json-schema': 1.1.12 - asn1js: 3.0.5 - pvtsutils: 1.3.2 - tslib: 2.6.0 - dev: true - - /webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - dev: true - - /whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - dev: true - - /which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - dev: true - - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - dependencies: - isexe: 2.0.0 - dev: true - - /why-is-node-running@2.2.2: - resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} - engines: {node: '>=8'} - hasBin: true - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - dev: true - - /wonka@6.3.2: - resolution: {integrity: sha512-2xXbQ1LnwNS7egVm1HPhW2FyKrekolzhpM3mCwXdQr55gO+tAiY76rhb32OL9kKsW8taj++iP7C6hxlVzbnvrw==} - dev: false - - /wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - dev: true - - /wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - dev: true - - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true - - /ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - - /y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - dev: true - - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: true - - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: true - - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true - - /yaml-ast-parser@0.0.43: - resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} - dev: true - - /yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - dev: true - - /yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - dev: true - - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: true - - /yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - dev: true - - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - 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.8 - yargs-parser: 21.1.1 - dev: true - - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - dev: true - - /yocto-queue@1.0.0: - resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} - engines: {node: '>=12.20'} - dev: true - - /zod@3.21.4: - resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} - dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e6fb5e1..78cc7814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + overrides: semver@<7.5.2: '>=7.5.2' @@ -8,59 +12,35 @@ importers: .: devDependencies: '@commitlint/cli': - specifier: ^17.6.6 - version: 17.6.6 + specifier: ^17.7.0 + version: 17.7.0 '@commitlint/config-conventional': - specifier: ^17.6.6 - version: 17.6.6 + specifier: ^17.7.0 + version: 17.7.0 commitlint: - specifier: ^17.6.6 - version: 17.6.6 + specifier: ^17.7.0 + version: 17.7.0 husky: specifier: ^8.0.3 version: 8.0.3 prettier: - specifier: ^3.0.0 - version: 3.0.0 - - frontend/player: - devDependencies: - '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.0.0(eslint@8.45.0)(typescript@5.1.6) - astring: - specifier: ^1.8.6 - version: 1.8.6 - eslint: - specifier: ^8.45.0 - version: 8.45.0 - eslint-config-prettier: - specifier: ^8.8.0 - version: 8.8.0(eslint@8.45.0) - prettier: - specifier: ^3.0.0 - version: 3.0.0 - rimraf: - specifier: ^5.0.1 - version: 5.0.1 - typescript: - specifier: ^5.1.6 - version: 5.1.6 - vite: - specifier: ^4.4.4 - version: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vite-plugin-dts: - specifier: ^3.3.0 - version: 3.3.0(@types/node@20.4.2)(typescript@5.1.6)(vite@4.4.4) + specifier: ^3.0.1 + version: 3.0.1 - frontend/website: + platform/website: dependencies: + '@fontsource/be-vietnam-pro': + specifier: ^5.0.8 + version: 5.0.8 + '@fontsource/comfortaa': + specifier: ^5.0.8 + version: 5.0.8 + '@sveltejs/adapter-node': + specifier: ^1.3.1 + version: 1.3.1(@sveltejs/kit@1.22.4) '@urql/svelte': - specifier: ^4.0.3 - version: 4.0.3(graphql@16.7.1)(svelte@4.0.5) + specifier: ^4.0.4 + version: 4.0.4(graphql@16.7.1)(svelte@4.1.2) graphql: specifier: ^16.7.1 version: 16.7.1 @@ -68,11 +48,11 @@ importers: specifier: ^5.14.0 version: 5.14.0(graphql@16.7.1) urql: - specifier: ^4.0.4 - version: 4.0.4(graphql@16.7.1)(react@18.2.0) + specifier: ^4.0.5 + version: 4.0.5(graphql@16.7.1)(react@18.2.0) wonka: - specifier: ^6.3.2 - version: 6.3.2 + specifier: ^6.3.4 + version: 6.3.4 zod: specifier: ^3.21.4 version: 3.21.4 @@ -81,14 +61,14 @@ importers: specifier: ^6.4.2 version: 6.4.2 '@fortawesome/free-solid-svg-icons': - specifier: ^6.4.0 - version: 6.4.0 + specifier: ^6.4.2 + version: 6.4.2 '@graphql-codegen/cli': specifier: 4.0.1 - version: 4.0.1(@types/node@20.4.2)(graphql@16.7.1) + version: 4.0.1(@types/node@20.4.9)(graphql@16.7.1) '@graphql-codegen/client-preset': - specifier: ^4.0.1 - version: 4.0.1(graphql@16.7.1) + specifier: ^4.1.0 + version: 4.1.0(graphql@16.7.1) '@graphql-codegen/introspection': specifier: ^4.0.0 version: 4.0.0(graphql@16.7.1) @@ -102,101 +82,131 @@ importers: specifier: ^3.2.0 version: 3.2.0(graphql@16.7.1) '@playwright/test': - specifier: ^1.36.1 - version: 1.36.1 + specifier: ^1.36.2 + version: 1.36.2 '@rollup/plugin-commonjs': specifier: ^25.0.3 - version: 25.0.3(rollup@3.26.2) + version: 25.0.3(rollup@3.28.0) '@rollup/plugin-json': specifier: ^6.0.0 - version: 6.0.0(rollup@3.26.2) + version: 6.0.0(rollup@3.28.0) '@rollup/plugin-node-resolve': specifier: ^15.1.0 - version: 15.1.0(rollup@3.26.2) + version: 15.1.0(rollup@3.28.0) '@rollup/plugin-replace': specifier: ^5.0.2 - version: 5.0.2(rollup@3.26.2) + version: 5.0.2(rollup@3.28.0) '@scuffle/player': specifier: workspace:* - version: link:../player - '@sveltejs/adapter-node': - specifier: ^1.3.1 - version: 1.3.1(@sveltejs/kit@1.22.3) + version: link:../../video/player '@sveltejs/kit': - specifier: ^1.22.3 - version: 1.22.3(svelte@4.0.5)(vite@4.4.4) + specifier: ^1.22.4 + version: 1.22.4(svelte@4.1.2)(vite@4.4.9) '@types/fs-extra': specifier: ^11.0.1 version: 11.0.1 '@types/node': - specifier: ^20.4.2 - version: 20.4.2 + specifier: ^20.4.9 + version: 20.4.9 '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6) + specifier: ^6.3.0 + version: 6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.46.0)(typescript@5.1.6) '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.0.0(eslint@8.45.0)(typescript@5.1.6) + specifier: ^6.3.0 + version: 6.3.0(eslint@8.46.0)(typescript@5.1.6) concurrently: specifier: ^8.2.0 version: 8.2.0 eslint: - specifier: ^8.45.0 - version: 8.45.0 + specifier: ^8.46.0 + version: 8.46.0 eslint-config-prettier: - specifier: ^8.8.0 - version: 8.8.0(eslint@8.45.0) + specifier: ^8.10.0 + version: 8.10.0(eslint@8.46.0) eslint-plugin-svelte: - specifier: ^2.32.2 - version: 2.32.2(eslint@8.45.0)(svelte@4.0.5) + specifier: ^2.32.4 + version: 2.32.4(eslint@8.46.0)(svelte@4.1.2)(ts-node@10.9.1) espree: specifier: ^9.6.1 version: 9.6.1 fast-glob: - specifier: ^3.3.0 - version: 3.3.0 + specifier: ^3.3.1 + version: 3.3.1 fs-extra: specifier: ^11.1.1 version: 11.1.1 prettier: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.0.1 + version: 3.0.1 prettier-plugin-svelte: - specifier: ^3.0.0 - version: 3.0.0(prettier@3.0.0)(svelte@4.0.5) + specifier: ^3.0.3 + version: 3.0.3(prettier@3.0.1)(svelte@4.1.2) rollup: - specifier: ^3.26.2 - version: 3.26.2 + specifier: ^3.28.0 + version: 3.28.0 sass: - specifier: ^1.63.6 - version: 1.63.6 + specifier: ^1.64.2 + version: 1.64.2 svelte: - specifier: ^4.0.5 - version: 4.0.5 + specifier: ^4.1.2 + version: 4.1.2 svelte-check: specifier: ^3.4.6 - version: 3.4.6(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.63.6)(svelte@4.0.5) + version: 3.4.6(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.64.2)(svelte@4.1.2) svelte-fa: specifier: ^3.0.4 version: 3.0.4 svelte-turnstile: specifier: ^0.5.0 - version: 0.5.0(svelte@4.0.5) + version: 0.5.0(svelte@4.1.2) svelte2tsx: specifier: ^0.6.19 - version: 0.6.19(svelte@4.0.5)(typescript@5.1.6) + version: 0.6.19(svelte@4.1.2)(typescript@5.1.6) tslib: - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^2.6.1 + version: 2.6.1 typescript: specifier: ^5.1.6 version: 5.1.6 vite: - specifier: ^4.4.4 - version: 4.4.4(@types/node@20.4.2)(sass@1.63.6) + specifier: ^4.4.9 + version: 4.4.9(@types/node@20.4.9)(sass@1.64.2) vitest: specifier: ^0.33.0 - version: 0.33.0(sass@1.63.6) + version: 0.33.0(sass@1.64.2) + + video/player: + devDependencies: + '@typescript-eslint/eslint-plugin': + specifier: ^6.3.0 + version: 6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/parser': + specifier: ^6.3.0 + version: 6.3.0(eslint@8.46.0)(typescript@5.1.6) + astring: + specifier: ^1.8.6 + version: 1.8.6 + eslint: + specifier: ^8.46.0 + version: 8.46.0 + eslint-config-prettier: + specifier: ^8.10.0 + version: 8.10.0(eslint@8.46.0) + prettier: + specifier: ^3.0.1 + version: 3.0.1 + rimraf: + specifier: ^5.0.1 + version: 5.0.1 + typescript: + specifier: ^5.1.6 + version: 5.1.6 + vite: + specifier: ^4.4.9 + version: 4.4.9(@types/node@20.4.7) + vite-plugin-dts: + specifier: ^3.5.1 + version: 3.5.1(@types/node@20.4.7)(typescript@5.1.6)(vite@4.4.9) packages: @@ -817,14 +827,14 @@ packages: to-fast-properties: 2.0.0 dev: true - /@commitlint/cli@17.6.6: - resolution: {integrity: sha512-sTKpr2i/Fjs9OmhU+beBxjPavpnLSqZaO6CzwKVq2Tc4UYVTMFgpKOslDhUBVlfAUBfjVO8ParxC/MXkIOevEA==} + /@commitlint/cli@17.7.0: + resolution: {integrity: sha512-28PNJaGuBQZNoz3sd+6uO3b4+5PY+vWzyBfy5JOvFB7QtoZVXf2FYTQs5VO1cn7yAd3y9/0Rx0x6Vx82W/zhuA==} engines: {node: '>=v14'} hasBin: true dependencies: '@commitlint/format': 17.4.4 - '@commitlint/lint': 17.6.6 - '@commitlint/load': 17.5.0 + '@commitlint/lint': 17.7.0 + '@commitlint/load': 17.7.1 '@commitlint/read': 17.5.1 '@commitlint/types': 17.4.4 execa: 5.1.1 @@ -837,23 +847,23 @@ packages: - '@swc/wasm' dev: true - /@commitlint/config-conventional@17.6.6: - resolution: {integrity: sha512-phqPz3BDhfj49FUYuuZIuDiw+7T6gNAEy7Yew1IBHqSohVUCWOK2FXMSAExzS2/9X+ET93g0Uz83KjiHDOOFag==} + /@commitlint/config-conventional@17.7.0: + resolution: {integrity: sha512-iicqh2o6et+9kWaqsQiEYZzfLbtoWv9uZl8kbI8EGfnc0HeGafQBF7AJ0ylN9D/2kj6txltsdyQs8+2fTMwWEw==} engines: {node: '>=v14'} dependencies: - conventional-changelog-conventionalcommits: 5.0.0 + conventional-changelog-conventionalcommits: 6.1.0 dev: true - /@commitlint/config-validator@17.4.4: - resolution: {integrity: sha512-bi0+TstqMiqoBAQDvdEP4AFh0GaKyLFlPPEObgI29utoKEYoPQTvF0EYqIwYYLEoJYhj5GfMIhPHJkTJhagfeg==} + /@commitlint/config-validator@17.6.7: + resolution: {integrity: sha512-vJSncmnzwMvpr3lIcm0I8YVVDJTzyjy7NZAeXbTXy+MPUdAr9pKyyg7Tx/ebOQ9kqzE6O9WT6jg2164br5UdsQ==} engines: {node: '>=v14'} dependencies: '@commitlint/types': 17.4.4 ajv: 8.12.0 dev: true - /@commitlint/ensure@17.4.4: - resolution: {integrity: sha512-AHsFCNh8hbhJiuZ2qHv/m59W/GRE9UeOXbkOqxYMNNg9pJ7qELnFcwj5oYpa6vzTSHtPGKf3C2yUFNy1GGHq6g==} + /@commitlint/ensure@17.6.7: + resolution: {integrity: sha512-mfDJOd1/O/eIb/h4qwXzUxkmskXDL9vNPnZ4AKYKiZALz4vHzwMxBSYtyL2mUIDeU9DRSpEUins8SeKtFkYHSw==} engines: {node: '>=v14'} dependencies: '@commitlint/types': 17.4.4 @@ -877,41 +887,41 @@ packages: chalk: 4.1.2 dev: true - /@commitlint/is-ignored@17.6.6: - resolution: {integrity: sha512-4Fw875faAKO+2nILC04yW/2Vy/wlV3BOYCSQ4CEFzriPEprc1Td2LILmqmft6PDEK5Sr14dT9tEzeaZj0V56Gg==} + /@commitlint/is-ignored@17.7.0: + resolution: {integrity: sha512-043rA7m45tyEfW7Zv2vZHF++176MLHH9h70fnPoYlB1slKBeKl8BwNIlnPg4xBdRBVNPaCqvXxWswx2GR4c9Hw==} engines: {node: '>=v14'} dependencies: '@commitlint/types': 17.4.4 - semver: 7.5.2 + semver: 7.5.4 dev: true - /@commitlint/lint@17.6.6: - resolution: {integrity: sha512-5bN+dnHcRLkTvwCHYMS7Xpbr+9uNi0Kq5NR3v4+oPNx6pYXt8ACuw9luhM/yMgHYwW0ajIR20wkPAFkZLEMGmg==} + /@commitlint/lint@17.7.0: + resolution: {integrity: sha512-TCQihm7/uszA5z1Ux1vw+Nf3yHTgicus/+9HiUQk+kRSQawByxZNESeQoX9ujfVd3r4Sa+3fn0JQAguG4xvvbA==} engines: {node: '>=v14'} dependencies: - '@commitlint/is-ignored': 17.6.6 - '@commitlint/parse': 17.6.5 - '@commitlint/rules': 17.6.5 + '@commitlint/is-ignored': 17.7.0 + '@commitlint/parse': 17.7.0 + '@commitlint/rules': 17.7.0 '@commitlint/types': 17.4.4 dev: true - /@commitlint/load@17.5.0: - resolution: {integrity: sha512-l+4W8Sx4CD5rYFsrhHH8HP01/8jEP7kKf33Xlx2Uk2out/UKoKPYMOIRcDH5ppT8UXLMV+x6Wm5osdRKKgaD1Q==} + /@commitlint/load@17.7.1: + resolution: {integrity: sha512-S/QSOjE1ztdogYj61p6n3UbkUvweR17FQ0zDbNtoTLc+Hz7vvfS7ehoTMQ27hPSjVBpp7SzEcOQu081RLjKHJQ==} engines: {node: '>=v14'} dependencies: - '@commitlint/config-validator': 17.4.4 + '@commitlint/config-validator': 17.6.7 '@commitlint/execute-rule': 17.4.0 - '@commitlint/resolve-extends': 17.4.4 + '@commitlint/resolve-extends': 17.6.7 '@commitlint/types': 17.4.4 - '@types/node': 20.4.2 + '@types/node': 20.4.7 chalk: 4.1.2 cosmiconfig: 8.2.0 - cosmiconfig-typescript-loader: 4.3.0(@types/node@20.4.2)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6) + cosmiconfig-typescript-loader: 4.3.0(@types/node@20.4.7)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 - ts-node: 10.9.1(@types/node@20.4.2)(typescript@5.1.6) + ts-node: 10.9.1(@types/node@20.4.7)(typescript@5.1.6) typescript: 5.1.6 transitivePeerDependencies: - '@swc/core' @@ -923,13 +933,13 @@ packages: engines: {node: '>=v14'} dev: true - /@commitlint/parse@17.6.5: - resolution: {integrity: sha512-0zle3bcn1Hevw5Jqpz/FzEWNo2KIzUbc1XyGg6WrWEoa6GH3A1pbqNF6MvE6rjuy6OY23c8stWnb4ETRZyN+Yw==} + /@commitlint/parse@17.7.0: + resolution: {integrity: sha512-dIvFNUMCUHqq5Abv80mIEjLVfw8QNuA4DS7OWip4pcK/3h5wggmjVnlwGCDvDChkw2TjK1K6O+tAEV78oxjxag==} engines: {node: '>=v14'} dependencies: '@commitlint/types': 17.4.4 - conventional-changelog-angular: 5.0.13 - conventional-commits-parser: 3.2.4 + conventional-changelog-angular: 6.0.0 + conventional-commits-parser: 4.0.0 dev: true /@commitlint/read@17.5.1: @@ -943,11 +953,11 @@ packages: minimist: 1.2.8 dev: true - /@commitlint/resolve-extends@17.4.4: - resolution: {integrity: sha512-znXr1S0Rr8adInptHw0JeLgumS11lWbk5xAWFVno+HUFVN45875kUtqjrI6AppmD3JI+4s0uZlqqlkepjJd99A==} + /@commitlint/resolve-extends@17.6.7: + resolution: {integrity: sha512-PfeoAwLHtbOaC9bGn/FADN156CqkFz6ZKiVDMjuC2N5N0740Ke56rKU7Wxdwya8R8xzLK9vZzHgNbuGhaOVKIg==} engines: {node: '>=v14'} dependencies: - '@commitlint/config-validator': 17.4.4 + '@commitlint/config-validator': 17.6.7 '@commitlint/types': 17.4.4 import-fresh: 3.3.0 lodash.mergewith: 4.6.2 @@ -955,11 +965,11 @@ packages: resolve-global: 1.0.0 dev: true - /@commitlint/rules@17.6.5: - resolution: {integrity: sha512-uTB3zSmnPyW2qQQH+Dbq2rekjlWRtyrjDo4aLFe63uteandgkI+cc0NhhbBAzcXShzVk0qqp8SlkQMu0mgHg/A==} + /@commitlint/rules@17.7.0: + resolution: {integrity: sha512-J3qTh0+ilUE5folSaoK91ByOb8XeQjiGcdIdiB/8UT1/Rd1itKo0ju/eQVGyFzgTMYt8HrDJnGTmNWwcMR1rmA==} engines: {node: '>=v14'} dependencies: - '@commitlint/ensure': 17.4.4 + '@commitlint/ensure': 17.6.7 '@commitlint/message': 17.4.2 '@commitlint/to-lines': 17.4.0 '@commitlint/types': 17.4.4 @@ -998,7 +1008,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/android-arm@0.18.17: @@ -1007,7 +1016,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/android-x64@0.18.17: @@ -1016,7 +1024,6 @@ packages: cpu: [x64] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/darwin-arm64@0.18.17: @@ -1025,7 +1032,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: true optional: true /@esbuild/darwin-x64@0.18.17: @@ -1034,7 +1040,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: true optional: true /@esbuild/freebsd-arm64@0.18.17: @@ -1043,7 +1048,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: true optional: true /@esbuild/freebsd-x64@0.18.17: @@ -1052,7 +1056,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: true optional: true /@esbuild/linux-arm64@0.18.17: @@ -1061,7 +1064,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-arm@0.18.17: @@ -1070,7 +1072,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-ia32@0.18.17: @@ -1079,7 +1080,6 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-loong64@0.18.17: @@ -1088,7 +1088,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-mips64el@0.18.17: @@ -1097,7 +1096,6 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-ppc64@0.18.17: @@ -1106,7 +1104,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-riscv64@0.18.17: @@ -1115,7 +1112,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-s390x@0.18.17: @@ -1124,7 +1120,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-x64@0.18.17: @@ -1133,7 +1128,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/netbsd-x64@0.18.17: @@ -1142,7 +1136,6 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true - dev: true optional: true /@esbuild/openbsd-x64@0.18.17: @@ -1151,7 +1144,6 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true - dev: true optional: true /@esbuild/sunos-x64@0.18.17: @@ -1160,7 +1152,6 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true - dev: true optional: true /@esbuild/win32-arm64@0.18.17: @@ -1169,7 +1160,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: true optional: true /@esbuild/win32-ia32@0.18.17: @@ -1178,7 +1168,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: true optional: true /@esbuild/win32-x64@0.18.17: @@ -1187,16 +1176,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: true optional: true - /@eslint-community/eslint-utils@4.4.0(eslint@8.45.0): + /@eslint-community/eslint-utils@4.4.0(eslint@8.46.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.45.0 + eslint: 8.46.0 eslint-visitor-keys: 3.4.2 dev: true @@ -1222,16 +1210,18 @@ packages: - supports-color dev: true - /@eslint/js@8.44.0: - resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==} + /@eslint/js@8.47.0: + resolution: {integrity: sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@fortawesome/fontawesome-common-types@6.4.0: - resolution: {integrity: sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==} - engines: {node: '>=6'} - requiresBuild: true - dev: true + /@fontsource/be-vietnam-pro@5.0.8: + resolution: {integrity: sha512-SHH6pPOMiMZyoBXU4dxg6ODysB0BadVC5Pbg/KY8m7+WEITIthyzEfZYbJf9k/EvilP7qw6k6ETXTUmE5Du+TA==} + dev: false + + /@fontsource/comfortaa@5.0.8: + resolution: {integrity: sha512-QPUXOEWT/ClHjQNXZNfCuGteYiNIEvlvZVh/ovg0g5gygt9NkvlCYlJyBjU9YgQrJI9LWZrh22dB3rEA9WD6Hw==} + dev: false /@fortawesome/fontawesome-common-types@6.4.2: resolution: {integrity: sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==} @@ -1247,12 +1237,12 @@ packages: '@fortawesome/fontawesome-common-types': 6.4.2 dev: true - /@fortawesome/free-solid-svg-icons@6.4.0: - resolution: {integrity: sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==} + /@fortawesome/free-solid-svg-icons@6.4.2: + resolution: {integrity: sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==} engines: {node: '>=6'} requiresBuild: true dependencies: - '@fortawesome/fontawesome-common-types': 6.4.0 + '@fortawesome/fontawesome-common-types': 6.4.2 dev: true /@graphql-codegen/add@5.0.0(graphql@16.7.1): @@ -1265,7 +1255,7 @@ packages: tslib: 2.5.3 dev: true - /@graphql-codegen/cli@4.0.1(@types/node@20.4.2)(graphql@16.7.1): + /@graphql-codegen/cli@4.0.1(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-/H4imnGOl3hoPXLKmIiGUnXpmBmeIClSZie/YHDzD5N59cZlGGJlIOOrUlOTDpJx5JNU1MTQcRjyTToOYM5IfA==} hasBin: true peerDependencies: @@ -1279,12 +1269,12 @@ packages: '@graphql-tools/apollo-engine-loader': 8.0.0(graphql@16.7.1) '@graphql-tools/code-file-loader': 8.0.2(graphql@16.7.1) '@graphql-tools/git-loader': 8.0.2(graphql@16.7.1) - '@graphql-tools/github-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/github-loader': 8.0.0(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.7.1) '@graphql-tools/json-file-loader': 8.0.0(graphql@16.7.1) '@graphql-tools/load': 8.0.0(graphql@16.7.1) - '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.4.2)(graphql@16.7.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.4.9)(graphql@16.7.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) '@parcel/watcher': 2.2.0 '@whatwg-node/fetch': 0.8.8 @@ -1293,7 +1283,7 @@ packages: debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.7.1 - graphql-config: 5.0.2(@types/node@20.4.2)(graphql@16.7.1) + graphql-config: 5.0.2(@types/node@20.4.9)(graphql@16.7.1) inquirer: 8.2.6 is-glob: 4.0.3 jiti: 1.19.1 @@ -1304,7 +1294,7 @@ packages: shell-quote: 1.8.1 string-env-interpolation: 1.0.1 ts-log: 2.2.5 - tslib: 2.6.0 + tslib: 2.6.1 yaml: 1.10.2 yargs: 17.7.2 transitivePeerDependencies: @@ -1317,8 +1307,8 @@ packages: - utf-8-validate dev: true - /@graphql-codegen/client-preset@4.0.1(graphql@16.7.1): - resolution: {integrity: sha512-8kt8z1JK4CGbBb+oedSCyHENNxh8UHdEFU8sBCtN4QpKsfmsEXhHHeJCTRPVbQKtEZyfVuBqf89DzuSNLs0DFw==} + /@graphql-codegen/client-preset@4.1.0(graphql@16.7.1): + resolution: {integrity: sha512-/3Ymb/fjxIF1+HGmaI1YwSZbWsrZAWMSQjh3dU425eBjctjsVQ6gzGRr+l/gE5F1mtmCf+vlbTAT03heAc/QIw==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: @@ -1502,7 +1492,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) '@whatwg-node/fetch': 0.9.9 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 transitivePeerDependencies: - encoding dev: true @@ -1516,7 +1506,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) dataloader: 2.2.2 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 dev: true @@ -1530,7 +1520,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) globby: 11.1.0 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 unixify: 1.0.0 transitivePeerDependencies: - supports-color @@ -1548,7 +1538,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) dataloader: 2.2.2 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 dev: true @@ -1560,7 +1550,7 @@ packages: dependencies: graphql: 16.7.1 lodash.sortby: 4.7.0 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@graphql-tools/executor-graphql-ws@1.1.0(graphql@16.7.1): @@ -1574,14 +1564,14 @@ packages: graphql: 16.7.1 graphql-ws: 5.14.0(graphql@16.7.1) isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 + tslib: 2.6.1 ws: 8.13.0 transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /@graphql-tools/executor-http@1.0.2(@types/node@20.4.2)(graphql@16.7.1): + /@graphql-tools/executor-http@1.0.2(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-JKTB4E3kdQM2/1NEcyrVPyQ8057ZVthCV5dFJiKktqY9IdmF00M8gupFcW3jlbM/Udn78ickeUBsUzA3EouqpA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -1592,8 +1582,8 @@ packages: '@whatwg-node/fetch': 0.9.9 extract-files: 11.0.0 graphql: 16.7.1 - meros: 1.3.0(@types/node@20.4.2) - tslib: 2.6.0 + meros: 1.3.0(@types/node@20.4.9) + tslib: 2.6.1 value-or-promise: 1.0.12 transitivePeerDependencies: - '@types/node' @@ -1609,7 +1599,7 @@ packages: '@types/ws': 8.5.5 graphql: 16.7.1 isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 + tslib: 2.6.1 ws: 8.13.0 transitivePeerDependencies: - bufferutil @@ -1626,7 +1616,7 @@ packages: '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) '@repeaterjs/repeater': 3.0.4 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 dev: true @@ -1641,25 +1631,25 @@ packages: graphql: 16.7.1 is-glob: 4.0.3 micromatch: 4.0.5 - tslib: 2.6.0 + tslib: 2.6.1 unixify: 1.0.0 transitivePeerDependencies: - supports-color dev: true - /@graphql-tools/github-loader@8.0.0(@types/node@20.4.2)(graphql@16.7.1): + /@graphql-tools/github-loader@8.0.0(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-VuroArWKcG4yaOWzV0r19ElVIV6iH6UKDQn1MXemND0xu5TzrFme0kf3U9o0YwNo0kUYEk9CyFM0BYg4he17FA==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/executor-http': 1.0.2(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/graphql-tag-pluck': 8.0.2(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) '@whatwg-node/fetch': 0.9.9 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 transitivePeerDependencies: - '@types/node' @@ -1677,7 +1667,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) globby: 11.1.0 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 unixify: 1.0.0 dev: true @@ -1694,7 +1684,7 @@ packages: '@babel/types': 7.22.5 '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 transitivePeerDependencies: - supports-color dev: true @@ -1708,7 +1698,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 resolve-from: 5.0.0 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@graphql-tools/json-file-loader@8.0.0(graphql@16.7.1): @@ -1720,7 +1710,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) globby: 11.1.0 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 unixify: 1.0.0 dev: true @@ -1734,7 +1724,7 @@ packages: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 p-limit: 3.1.0 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@graphql-tools/merge@9.0.0(graphql@16.7.1): @@ -1745,7 +1735,7 @@ packages: dependencies: '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@graphql-tools/optimize@2.0.0(graphql@16.7.1): @@ -1755,16 +1745,16 @@ packages: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 dev: true - /@graphql-tools/prisma-loader@8.0.1(@types/node@20.4.2)(graphql@16.7.1): + /@graphql-tools/prisma-loader@8.0.1(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-bl6e5sAYe35Z6fEbgKXNrqRhXlCJYeWKBkarohgYA338/SD9eEhXtg3Cedj7fut3WyRLoQFpHzfiwxKs7XrgXg==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) '@types/js-yaml': 4.0.5 '@types/json-stable-stringify': 1.0.34 @@ -1781,7 +1771,7 @@ packages: json-stable-stringify: 1.0.2 lodash: 4.17.21 scuid: 1.1.0 - tslib: 2.6.0 + tslib: 2.6.1 yaml-ast-parser: 0.0.43 transitivePeerDependencies: - '@types/node' @@ -1800,7 +1790,7 @@ packages: '@ardatan/relay-compiler': 12.0.0(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 transitivePeerDependencies: - encoding - supports-color @@ -1815,11 +1805,11 @@ packages: '@graphql-tools/merge': 9.0.0(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 dev: true - /@graphql-tools/url-loader@8.0.0(@types/node@20.4.2)(graphql@16.7.1): + /@graphql-tools/url-loader@8.0.0(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-rPc9oDzMnycvz+X+wrN3PLrhMBQkG4+sd8EzaFN6dypcssiefgWKToXtRKI8HHK68n2xEq1PyrOpkjHFJB+GwA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -1828,7 +1818,7 @@ packages: '@ardatan/sync-fetch': 0.0.1 '@graphql-tools/delegate': 10.0.1(graphql@16.7.1) '@graphql-tools/executor-graphql-ws': 1.1.0(graphql@16.7.1) - '@graphql-tools/executor-http': 1.0.2(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/executor-legacy-ws': 1.0.1(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) '@graphql-tools/wrap': 10.0.0(graphql@16.7.1) @@ -1836,7 +1826,7 @@ packages: '@whatwg-node/fetch': 0.9.9 graphql: 16.7.1 isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 ws: 8.13.0 transitivePeerDependencies: @@ -1855,7 +1845,7 @@ packages: '@graphql-typed-document-node/core': 3.2.0(graphql@16.7.1) dset: 3.1.2 graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@graphql-tools/wrap@10.0.0(graphql@16.7.1): @@ -1868,7 +1858,7 @@ packages: '@graphql-tools/schema': 10.0.0(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 value-or-promise: 1.0.12 dev: true @@ -1959,24 +1949,24 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@microsoft/api-extractor-model@7.27.5(@types/node@20.4.2): + /@microsoft/api-extractor-model@7.27.5(@types/node@20.4.7): resolution: {integrity: sha512-9/tBzYMJitR+o+zkPr1lQh2+e8ClcaTF6eZo7vZGDqRt2O5XmXWPbYJZmxyM3wb5at6lfJNEeGZrQXLjsQ0Nbw==} dependencies: '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - '@rushstack/node-core-library': 3.59.6(@types/node@20.4.2) + '@rushstack/node-core-library': 3.59.6(@types/node@20.4.7) transitivePeerDependencies: - '@types/node' dev: true - /@microsoft/api-extractor@7.36.3(@types/node@20.4.2): + /@microsoft/api-extractor@7.36.3(@types/node@20.4.7): resolution: {integrity: sha512-u0H6362AQq+r55X8drHx4npgkrCfJnMzRRHfQo8PMNKB8TcBnrTLfXhXWi+xnTM6CzlU/netEN8c4bq581Rnrg==} hasBin: true dependencies: - '@microsoft/api-extractor-model': 7.27.5(@types/node@20.4.2) + '@microsoft/api-extractor-model': 7.27.5(@types/node@20.4.7) '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - '@rushstack/node-core-library': 3.59.6(@types/node@20.4.2) + '@rushstack/node-core-library': 3.59.6(@types/node@20.4.7) '@rushstack/rig-package': 0.4.0 '@rushstack/ts-command-line': 4.15.1 colors: 1.2.5 @@ -2139,14 +2129,14 @@ packages: dependencies: asn1js: 3.0.5 pvtsutils: 1.3.2 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@peculiar/json-schema@1.1.12: resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} engines: {node: '>=8.0.0'} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@peculiar/webcrypto@1.4.3: @@ -2156,7 +2146,7 @@ packages: '@peculiar/asn1-schema': 2.3.6 '@peculiar/json-schema': 1.1.12 pvtsutils: 1.3.2 - tslib: 2.6.0 + tslib: 2.6.1 webcrypto-core: 1.7.7 dev: true @@ -2167,26 +2157,25 @@ packages: dev: true optional: true - /@playwright/test@1.36.1: - resolution: {integrity: sha512-YK7yGWK0N3C2QInPU6iaf/L3N95dlGdbsezLya4n0ZCh3IL7VgPGxC6Gnznh9ApWdOmkJeleT2kMTcWPRZvzqg==} + /@playwright/test@1.36.2: + resolution: {integrity: sha512-2rVZeyPRjxfPH6J0oGJqE8YxiM1IBRyM8hyrXYK7eSiAqmbNhxwcLa7dZ7fy9Kj26V7FYia5fh9XJRq4Dqme+g==} engines: {node: '>=16'} hasBin: true dependencies: - '@types/node': 20.4.2 - playwright-core: 1.36.1 + '@types/node': 20.4.9 + playwright-core: 1.36.2 optionalDependencies: fsevents: 2.3.2 dev: true /@polka/url@1.0.0-next.21: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} - dev: true /@repeaterjs/repeater@3.0.4: resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} dev: true - /@rollup/plugin-commonjs@25.0.3(rollup@3.26.2): + /@rollup/plugin-commonjs@25.0.3(rollup@3.28.0): resolution: {integrity: sha512-uBdtWr/H3BVcgm97MUdq2oJmqBR23ny1hOrWe2PKo9FTbjsGqg32jfasJUKYAI5ouqacjRnj65mBB/S79F+GQA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2195,16 +2184,15 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) commondir: 1.0.1 estree-walker: 2.0.2 glob: 8.1.0 is-reference: 1.2.1 magic-string: 0.27.0 - rollup: 3.26.2 - dev: true + rollup: 3.28.0 - /@rollup/plugin-json@6.0.0(rollup@3.26.2): + /@rollup/plugin-json@6.0.0(rollup@3.28.0): resolution: {integrity: sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2213,11 +2201,10 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) - rollup: 3.26.2 - dev: true + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) + rollup: 3.28.0 - /@rollup/plugin-node-resolve@15.1.0(rollup@3.26.2): + /@rollup/plugin-node-resolve@15.1.0(rollup@3.28.0): resolution: {integrity: sha512-xeZHCgsiZ9pzYVgAo9580eCGqwh/XCEUM9q6iQfGNocjgkufHAqC3exA+45URvhiYV8sBF9RlBai650eNs7AsA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2226,16 +2213,15 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.2 - rollup: 3.26.2 - dev: true + rollup: 3.28.0 - /@rollup/plugin-replace@5.0.2(rollup@3.26.2): + /@rollup/plugin-replace@5.0.2(rollup@3.28.0): resolution: {integrity: sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2244,12 +2230,12 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) magic-string: 0.27.0 - rollup: 3.26.2 + rollup: 3.28.0 dev: true - /@rollup/pluginutils@5.0.2(rollup@3.26.2): + /@rollup/pluginutils@5.0.2(rollup@3.28.0): resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2261,10 +2247,9 @@ packages: '@types/estree': 1.0.1 estree-walker: 2.0.2 picomatch: 2.3.1 - rollup: 3.26.2 - dev: true + rollup: 3.28.0 - /@rushstack/node-core-library@3.59.6(@types/node@20.4.2): + /@rushstack/node-core-library@3.59.6(@types/node@20.4.7): resolution: {integrity: sha512-bMYJwNFfWXRNUuHnsE9wMlW/mOB4jIwSUkRKtu02CwZhQdmzMsUbxE0s1xOLwTpNIwlzfW/YT7OnOHgDffLgYg==} peerDependencies: '@types/node': '*' @@ -2272,7 +2257,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.7 colors: 1.2.5 fs-extra: 7.0.1 import-lazy: 4.0.0 @@ -2302,20 +2287,20 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true - /@sveltejs/adapter-node@1.3.1(@sveltejs/kit@1.22.3): + /@sveltejs/adapter-node@1.3.1(@sveltejs/kit@1.22.4): resolution: {integrity: sha512-A0VgRQDCDPzdLNoiAbcOxGw4zT1Mc+n1LwT1OmO350R7WxrEqdMUChPPOd1iMfIDWlP4ie6E2d/WQf5es2d4Zw==} peerDependencies: '@sveltejs/kit': ^1.0.0 dependencies: - '@rollup/plugin-commonjs': 25.0.3(rollup@3.26.2) - '@rollup/plugin-json': 6.0.0(rollup@3.26.2) - '@rollup/plugin-node-resolve': 15.1.0(rollup@3.26.2) - '@sveltejs/kit': 1.22.3(svelte@4.0.5)(vite@4.4.4) - rollup: 3.26.2 - dev: true + '@rollup/plugin-commonjs': 25.0.3(rollup@3.28.0) + '@rollup/plugin-json': 6.0.0(rollup@3.28.0) + '@rollup/plugin-node-resolve': 15.1.0(rollup@3.28.0) + '@sveltejs/kit': 1.22.4(svelte@4.1.2)(vite@4.4.9) + rollup: 3.28.0 + dev: false - /@sveltejs/kit@1.22.3(svelte@4.0.5)(vite@4.4.4): - resolution: {integrity: sha512-IpHD5wvuoOIHYaHQUBJ1zERD2Iz+fB/rBXhXjl8InKw6X4VKE9BSus+ttHhE7Ke+Ie9ecfilzX8BnWE3FeQyng==} + /@sveltejs/kit@1.22.4(svelte@4.1.2)(vite@4.4.9): + resolution: {integrity: sha512-Opkqw1QXk4Cc25b/heJP2D7mX+OUBFAq4MXKfET58svTTxdeiHFKzmnuRsSF3nmxESqrLjqPAgHpib+knNGzRw==} engines: {node: ^16.14 || >=18} hasBin: true requiresBuild: true @@ -2323,7 +2308,7 @@ packages: svelte: ^3.54.0 || ^4.0.0-next.0 vite: ^4.0.0 dependencies: - '@sveltejs/vite-plugin-svelte': 2.4.3(svelte@4.0.5)(vite@4.4.4) + '@sveltejs/vite-plugin-svelte': 2.4.3(svelte@4.1.2)(vite@4.4.9) '@types/cookie': 0.5.1 cookie: 0.5.0 devalue: 4.3.2 @@ -2334,14 +2319,13 @@ packages: sade: 1.8.1 set-cookie-parser: 2.6.0 sirv: 2.0.3 - svelte: 4.0.5 + svelte: 4.1.2 undici: 5.22.1 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) transitivePeerDependencies: - supports-color - dev: true - /@sveltejs/vite-plugin-svelte-inspector@1.0.3(@sveltejs/vite-plugin-svelte@2.4.3)(svelte@4.0.5)(vite@4.4.4): + /@sveltejs/vite-plugin-svelte-inspector@1.0.3(@sveltejs/vite-plugin-svelte@2.4.3)(svelte@4.1.2)(vite@4.4.9): resolution: {integrity: sha512-Khdl5jmmPN6SUsVuqSXatKpQTMIifoQPDanaxC84m9JxIibWvSABJyHpyys0Z+1yYrxY5TTEQm+6elh0XCMaOA==} engines: {node: ^14.18.0 || >= 16} peerDependencies: @@ -2349,33 +2333,31 @@ packages: svelte: ^3.54.0 || ^4.0.0 vite: ^4.0.0 dependencies: - '@sveltejs/vite-plugin-svelte': 2.4.3(svelte@4.0.5)(vite@4.4.4) + '@sveltejs/vite-plugin-svelte': 2.4.3(svelte@4.1.2)(vite@4.4.9) debug: 4.3.4 - svelte: 4.0.5 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) + svelte: 4.1.2 + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) transitivePeerDependencies: - supports-color - dev: true - /@sveltejs/vite-plugin-svelte@2.4.3(svelte@4.0.5)(vite@4.4.4): + /@sveltejs/vite-plugin-svelte@2.4.3(svelte@4.1.2)(vite@4.4.9): resolution: {integrity: sha512-NY2h+B54KHZO3kDURTdARqthn6D4YSIebtfW75NvZ/fwyk4G+AJw3V/i0OBjyN4406Ht9yZcnNWMuRUFnDNNiA==} engines: {node: ^14.18.0 || >= 16} peerDependencies: svelte: ^3.54.0 || ^4.0.0 vite: ^4.0.0 dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 1.0.3(@sveltejs/vite-plugin-svelte@2.4.3)(svelte@4.0.5)(vite@4.4.4) + '@sveltejs/vite-plugin-svelte-inspector': 1.0.3(@sveltejs/vite-plugin-svelte@2.4.3)(svelte@4.1.2)(vite@4.4.9) debug: 4.3.4 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.2 - svelte: 4.0.5 - svelte-hmr: 0.15.3(svelte@4.0.5) - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vitefu: 0.2.4(vite@4.4.4) + svelte: 4.1.2 + svelte-hmr: 0.15.3(svelte@4.1.2) + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) + vitefu: 0.2.4(vite@4.4.9) transitivePeerDependencies: - supports-color - dev: true /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -2409,7 +2391,6 @@ packages: /@types/cookie@0.5.1: resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} - dev: true /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} @@ -2418,7 +2399,7 @@ packages: resolution: {integrity: sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==} dependencies: '@types/jsonfile': 6.1.1 - '@types/node': 20.4.2 + '@types/node': 20.4.9 dev: true /@types/js-yaml@4.0.5: @@ -2436,17 +2417,20 @@ packages: /@types/jsonfile@6.1.1: resolution: {integrity: sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==} dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.9 dev: true /@types/minimist@1.2.2: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true - /@types/node@20.4.2: - resolution: {integrity: sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==} + /@types/node@20.4.7: + resolution: {integrity: sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==} dev: true + /@types/node@20.4.9: + resolution: {integrity: sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==} + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -2457,7 +2441,6 @@ packages: /@types/resolve@1.20.2: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - dev: true /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} @@ -2466,11 +2449,11 @@ packages: /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.9 dev: true - /@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==} + /@typescript-eslint/eslint-plugin@6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-IZYjYZ0ifGSLZbwMqIip/nOamFiWJ9AH+T/GYNZBWkVcyNQOFGtSMoWV7RvY4poYCMZ/4lHzNl796WOSNxmk8A==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -2481,14 +2464,13 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.6.2 - '@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/parser': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/type-utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 - eslint: 8.45.0 - grapheme-splitter: 1.0.4 + eslint: 8.46.0 graphemer: 1.4.0 ignore: 5.2.4 natural-compare: 1.4.0 @@ -2500,8 +2482,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==} + /@typescript-eslint/parser@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-ibP+y2Gr6p0qsUkhs7InMdXrwldjxZw66wpcQq9/PzAroM45wdwyu81T+7RibNCh8oc0AgrsyCwJByncY0Ongg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2510,27 +2492,27 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 - eslint: 8.45.0 + eslint: 8.46.0 typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@6.0.0: - resolution: {integrity: sha512-o4q0KHlgCZTqjuaZ25nw5W57NeykZT9LiMEG4do/ovwvOcPnDO1BI5BQdCsUkjxFyrCL0cSzLjvIMfR9uo7cWg==} + /@typescript-eslint/scope-manager@6.3.0: + resolution: {integrity: sha512-WlNFgBEuGu74ahrXzgefiz/QlVb+qg8KDTpknKwR7hMH+lQygWyx0CQFoUmMn1zDkQjTBBIn75IxtWss77iBIQ==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/visitor-keys': 6.3.0 dev: true - /@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==} + /@typescript-eslint/type-utils@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-7Oj+1ox1T2Yc8PKpBvOKWhoI/4rWFd1j7FA/rPE0lbBPXTKjdbtC+7Ev0SeBjEKkIhKWVeZSP+mR7y1Db1CdfQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2539,23 +2521,23 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + '@typescript-eslint/utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) debug: 4.3.4 - eslint: 8.45.0 + eslint: 8.46.0 ts-api-utils: 1.0.1(typescript@5.1.6) typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@6.0.0: - resolution: {integrity: sha512-Zk9KDggyZM6tj0AJWYYKgF0yQyrcnievdhG0g5FqyU3Y2DRxJn4yWY21sJC0QKBckbsdKKjYDV2yVrrEvuTgxg==} + /@typescript-eslint/types@6.3.0: + resolution: {integrity: sha512-K6TZOvfVyc7MO9j60MkRNWyFSf86IbOatTKGrpTQnzarDZPYPVy0oe3myTMq7VjhfsUAbNUW8I5s+2lZvtx1gg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.0.0(typescript@5.1.6): - resolution: {integrity: sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==} + /@typescript-eslint/typescript-estree@6.3.0(typescript@5.1.6): + resolution: {integrity: sha512-Xh4NVDaC4eYKY4O3QGPuQNp5NxBAlEvNQYOqJquR2MePNxO11E5K3t5x4M4Mx53IZvtpW+mBxIT0s274fLUocg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -2563,8 +2545,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -2575,31 +2557,30 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==} + /@typescript-eslint/utils@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-hLLg3BZE07XHnpzglNBG8P/IXq/ZVXraEbgY7FM0Cnc1ehM8RMdn9mat3LubJ3KBeYXXPxV1nugWbQPjGeJk6Q==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.46.0) '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - eslint: 8.45.0 - eslint-scope: 5.1.1 + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + eslint: 8.46.0 semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@6.0.0: - resolution: {integrity: sha512-cvJ63l8c0yXdeT5POHpL0Q1cZoRcmRKFCtSjNGJxPkcP571EfZMcNbzWAc7oK3D1dRzm/V5EwtkANTZxqvuuUA==} + /@typescript-eslint/visitor-keys@6.3.0: + resolution: {integrity: sha512-kEhRRj7HnvaSjux1J9+7dBen15CdWmDnwrpyiHsFX6Qx2iW5LOBUgNefOFeh2PjWPlNwN8TOn6+4eBU3J/gupw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.0.0 + '@typescript-eslint/types': 6.3.0 eslint-visitor-keys: 3.4.2 dev: true @@ -2607,19 +2588,19 @@ packages: resolution: {integrity: sha512-iIoAy6BY+BUZZ7KIpnMT7C9q+ULf5ZCVxGe3/i7WZSJBrQa2h1QkIMhL+8fAKmOn9gt83jSIv5drWWnhZ9izEA==} dependencies: '@0no-co/graphql.web': 1.0.4(graphql@16.7.1) - wonka: 6.3.2 + wonka: 6.3.4 transitivePeerDependencies: - graphql dev: false - /@urql/svelte@4.0.3(graphql@16.7.1)(svelte@4.0.5): - resolution: {integrity: sha512-snb0qW3fhvVPkiTzkeQ5tu9IIjyfeH7cJMvu8a9wzdG7PALoy4DPn0E71N3HoFLjxpEa1g782bgt3ac05vepCw==} + /@urql/svelte@4.0.4(graphql@16.7.1)(svelte@4.1.2): + resolution: {integrity: sha512-HYz9dHdqEcs9d82WWczQ3XG+zuup3TS01H+txaij/QfQ+KHjrlrn0EkOHQQd1S+H8+nFjFU2x9+HE3+3fuwL1A==} peerDependencies: svelte: ^3.0.0 || ^4.0.0 dependencies: '@urql/core': 4.1.1(graphql@16.7.1) - svelte: 4.0.5 - wonka: 6.3.2 + svelte: 4.1.2 + wonka: 6.3.4 transitivePeerDependencies: - graphql dev: false @@ -2768,7 +2749,7 @@ packages: busboy: 1.6.0 fast-querystring: 1.1.2 fast-url-parser: 1.1.3 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /@whatwg-node/node-fetch@0.4.11: @@ -2779,7 +2760,7 @@ packages: busboy: 1.6.0 fast-querystring: 1.1.2 fast-url-parser: 1.1.3 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /JSONStream@1.3.5: @@ -2890,7 +2871,6 @@ packages: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - dev: true /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2935,7 +2915,7 @@ packages: dependencies: pvtsutils: 1.3.2 pvutils: 1.1.3 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /assertion-error@1.1.0: @@ -3003,7 +2983,6 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3012,7 +2991,6 @@ packages: /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - dev: true /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -3033,14 +3011,12 @@ packages: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 - dev: true /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browserslist@4.21.10: resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==} @@ -3073,14 +3049,12 @@ packages: /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} - dev: true /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} dependencies: streamsearch: 1.1.0 - dev: true /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} @@ -3096,7 +3070,7 @@ packages: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} dependencies: pascal-case: 3.1.2 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /camelcase-keys@6.2.2: @@ -3121,7 +3095,7 @@ packages: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} dependencies: no-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 upper-case-first: 2.0.2 dev: true @@ -3184,7 +3158,7 @@ packages: path-case: 3.0.4 sentence-case: 3.0.4 snake-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /chardet@0.7.0: @@ -3208,7 +3182,6 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.2 - dev: true /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} @@ -3308,12 +3281,12 @@ packages: dev: true optional: true - /commitlint@17.6.6: - resolution: {integrity: sha512-Q5M+w1nqGQSEkEW43EtPNrU+uKL2pENrJl6QigFupZ0v4AfvI8k5Q6uFgWmxlEOrSkgCYaN436Q9c9pydqyJqg==} + /commitlint@17.7.0: + resolution: {integrity: sha512-aUuDuW1Qye30A89LnPWXtxXz5XClb0ezPjj45JlkXV4HAjKFyYUOQ+Lip6eIUDW+qZ5oRONCnJ57NdZoFp6nAQ==} engines: {node: '>=v14'} hasBin: true dependencies: - '@commitlint/cli': 17.6.6 + '@commitlint/cli': 17.7.0 '@commitlint/types': 17.4.4 transitivePeerDependencies: - '@swc/core' @@ -3327,7 +3300,6 @@ packages: /commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - dev: true /compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -3360,38 +3332,33 @@ packages: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} dependencies: no-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 upper-case: 2.0.2 dev: true - /conventional-changelog-angular@5.0.13: - resolution: {integrity: sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==} - engines: {node: '>=10'} + /conventional-changelog-angular@6.0.0: + resolution: {integrity: sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg==} + engines: {node: '>=14'} dependencies: compare-func: 2.0.0 - q: 1.5.1 dev: true - /conventional-changelog-conventionalcommits@5.0.0: - resolution: {integrity: sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==} - engines: {node: '>=10'} + /conventional-changelog-conventionalcommits@6.1.0: + resolution: {integrity: sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw==} + engines: {node: '>=14'} dependencies: compare-func: 2.0.0 - lodash: 4.17.21 - q: 1.5.1 dev: true - /conventional-commits-parser@3.2.4: - resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==} - engines: {node: '>=10'} + /conventional-commits-parser@4.0.0: + resolution: {integrity: sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg==} + engines: {node: '>=14'} hasBin: true dependencies: JSONStream: 1.3.5 is-text-path: 1.0.1 - lodash: 4.17.21 meow: 8.1.2 split2: 3.2.2 - through2: 4.0.2 dev: true /convert-source-map@1.9.0: @@ -3401,9 +3368,8 @@ packages: /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - dev: true - /cosmiconfig-typescript-loader@4.3.0(@types/node@20.4.2)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6): + /cosmiconfig-typescript-loader@4.3.0(@types/node@20.4.7)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6): resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} engines: {node: '>=12', npm: '>=6'} peerDependencies: @@ -3412,9 +3378,9 @@ packages: ts-node: '>=10' typescript: '>=3' dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.7 cosmiconfig: 8.2.0 - ts-node: 10.9.1(@types/node@20.4.2)(typescript@5.1.6) + ts-node: 10.9.1(@types/node@20.4.7)(typescript@5.1.6) typescript: 5.1.6 dev: true @@ -3496,7 +3462,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -3529,7 +3494,6 @@ packages: /deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - dev: true /defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -3559,7 +3523,6 @@ packages: /devalue@4.3.2: resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} - dev: true /diff-sequences@29.4.3: resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} @@ -3589,7 +3552,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /dot-prop@5.3.0: @@ -3663,7 +3626,6 @@ packages: '@esbuild/win32-arm64': 0.18.17 '@esbuild/win32-ia32': 0.18.17 '@esbuild/win32-x64': 0.18.17 - dev: true /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -3680,17 +3642,17 @@ packages: engines: {node: '>=10'} dev: true - /eslint-config-prettier@8.8.0(eslint@8.45.0): - resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} + /eslint-config-prettier@8.10.0(eslint@8.46.0): + resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.45.0 + eslint: 8.46.0 dev: true - /eslint-plugin-svelte@2.32.2(eslint@8.45.0)(svelte@4.0.5): - resolution: {integrity: sha512-Jgbop2fNZsoxxkklZAIbDNhwAPynvnCtUXLsEC6O2qax7N/pfe2cNqT0ZoBbubXKJitQQDEyVDQ1rZs4ZWcrTA==} + /eslint-plugin-svelte@2.32.4(eslint@8.46.0)(svelte@4.1.2)(ts-node@10.9.1): + resolution: {integrity: sha512-VJ12i2Iogug1jvhwxSlognnfGj76P5gks/V4pUD4SCSVQOp14u47MNP0zAG8AQR3LT0Fi1iUvIFnY4l9z5Rwbg==} engines: {node: ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0-0 @@ -3699,32 +3661,24 @@ packages: svelte: optional: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.46.0) '@jridgewell/sourcemap-codec': 1.4.15 debug: 4.3.4 - eslint: 8.45.0 + eslint: 8.46.0 esutils: 2.0.3 - known-css-properties: 0.27.0 + known-css-properties: 0.28.0 postcss: 8.4.27 - postcss-load-config: 3.1.4(postcss@8.4.27) + postcss-load-config: 3.1.4(postcss@8.4.27)(ts-node@10.9.1) postcss-safe-parser: 6.0.0(postcss@8.4.27) postcss-selector-parser: 6.0.13 semver: 7.5.4 - svelte: 4.0.5 - svelte-eslint-parser: 0.32.2(svelte@4.0.5) + svelte: 4.1.2 + svelte-eslint-parser: 0.32.2(svelte@4.1.2) transitivePeerDependencies: - supports-color - ts-node dev: true - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: true - /eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3738,15 +3692,15 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.45.0: - resolution: {integrity: sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==} + /eslint@8.46.0: + resolution: {integrity: sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.46.0) '@eslint-community/regexpp': 4.6.2 '@eslint/eslintrc': 2.1.1 - '@eslint/js': 8.44.0 + '@eslint/js': 8.47.0 '@humanwhocodes/config-array': 0.11.10 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 @@ -3786,7 +3740,6 @@ packages: /esm-env@1.0.0: resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} - dev: true /espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} @@ -3811,11 +3764,6 @@ packages: estraverse: 5.3.0 dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -3823,7 +3771,6 @@ packages: /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - dev: true /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3872,17 +3819,6 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true - /fast-glob@3.3.0: - resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: true - /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3963,7 +3899,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} @@ -4021,19 +3956,16 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true /fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind@1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - dev: true /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -4071,7 +4003,6 @@ packages: engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -4112,7 +4043,6 @@ packages: inherits: 2.0.4 minimatch: 5.1.6 once: 1.4.0 - dev: true /global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -4149,15 +4079,11 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: true - /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true - /graphql-config@5.0.2(@types/node@20.4.2)(graphql@16.7.1): + /graphql-config@5.0.2(@types/node@20.4.9)(graphql@16.7.1): resolution: {integrity: sha512-7TPxOrlbiG0JplSZYCyxn2XQtqVhXomEjXUmWJVSS5ET1nPhOJSsIb/WTwqWhcYX6G0RlHXSj9PLtGTKmxLNGg==} engines: {node: '>= 16.0.0'} peerDependencies: @@ -4171,14 +4097,14 @@ packages: '@graphql-tools/json-file-loader': 8.0.0(graphql@16.7.1) '@graphql-tools/load': 8.0.0(graphql@16.7.1) '@graphql-tools/merge': 9.0.0(graphql@16.7.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.2)(graphql@16.7.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.4.9)(graphql@16.7.1) '@graphql-tools/utils': 10.0.4(graphql@16.7.1) cosmiconfig: 8.2.0 graphql: 16.7.1 jiti: 1.19.1 minimatch: 4.2.3 string-env-interpolation: 1.0.1 - tslib: 2.6.0 + tslib: 2.6.1 transitivePeerDependencies: - '@types/node' - bufferutil @@ -4205,7 +4131,7 @@ packages: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: graphql: 16.7.1 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /graphql-ws@5.14.0(graphql@16.7.1): @@ -4240,7 +4166,6 @@ packages: engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 - dev: true /he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} @@ -4251,7 +4176,7 @@ packages: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} dependencies: capital-case: 1.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /hosted-git-info@2.8.9: @@ -4319,7 +4244,6 @@ packages: /immutable@4.3.2: resolution: {integrity: sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==} - dev: true /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -4354,11 +4278,9 @@ packages: dependencies: once: 1.4.0 wrappy: 1.0.2 - dev: true /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -4408,25 +4330,21 @@ packages: engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 - dev: true /is-builtin-module@3.2.1: resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} engines: {node: '>=6'} dependencies: builtin-modules: 3.3.0 - dev: true /is-core-module@2.12.1: resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} dependencies: has: 1.0.3 - dev: true /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} @@ -4438,7 +4356,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} @@ -4448,17 +4365,15 @@ packages: /is-lower-case@2.0.2: resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - dev: true /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} @@ -4479,7 +4394,6 @@ packages: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} dependencies: '@types/estree': 1.0.1 - dev: true /is-reference@3.0.1: resolution: {integrity: sha512-baJJdQLiYaJdvFbJqXrcGv3WU3QCzBlUcI5QhbesIm6/xPsvmO+2CDoi/GMOFBQEQm+PXkwOPrp9KK5ozZsp2w==} @@ -4520,7 +4434,7 @@ packages: /is-upper-case@2.0.2: resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /is-windows@1.0.2: @@ -4649,10 +4563,9 @@ packages: /kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - dev: true - /known-css-properties@0.27.0: - resolution: {integrity: sha512-uMCj6+hZYDoffuvAJjFAPz56E9uoowFHmTkqRtRq5WyC5Q6Cu/fTZKNQpX/RbzChBYLLl3lo8CjFZBAZXq9qFg==} + /known-css-properties@0.28.0: + resolution: {integrity: sha512-9pSL5XB4J+ifHP0e0jmmC98OGC1nL8/JjS+fi6mnTlIf//yt/MfVLtKg7S6nCtj/8KTcWX7nRlY0XywoYY1ISQ==} dev: true /kolorist@1.8.0: @@ -4806,13 +4719,13 @@ packages: /lower-case-first@2.0.2: resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /lru-cache@10.0.0: @@ -4838,7 +4751,6 @@ packages: engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.15 - dev: true /magic-string@0.30.2: resolution: {integrity: sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==} @@ -4894,7 +4806,7 @@ packages: engines: {node: '>= 8'} dev: true - /meros@1.3.0(@types/node@20.4.2): + /meros@1.3.0(@types/node@20.4.9): resolution: {integrity: sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==} engines: {node: '>=13'} peerDependencies: @@ -4903,7 +4815,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.9 dev: true /micromatch@4.0.5: @@ -4918,7 +4830,6 @@ packages: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true - dev: true /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -4948,7 +4859,6 @@ packages: engines: {node: '>=10'} dependencies: brace-expansion: 2.0.1 - dev: true /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} @@ -4994,16 +4904,13 @@ packages: /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - dev: true /mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} - dev: true /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /muggle-string@0.3.1: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} @@ -5017,7 +4924,6 @@ packages: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -5031,7 +4937,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /node-addon-api@7.0.0: @@ -5087,7 +4993,6 @@ packages: /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - dev: true /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -5109,7 +5014,6 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -5201,7 +5105,7 @@ packages: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: dot-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /parent-module@1.0.1: @@ -5234,14 +5138,14 @@ packages: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: no-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} dependencies: dot-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /path-exists@4.0.0: @@ -5261,7 +5165,6 @@ packages: /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true /path-root-regex@0.1.2: resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} @@ -5305,12 +5208,10 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pkg-types@1.0.3: resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} @@ -5320,13 +5221,13 @@ packages: pathe: 1.1.1 dev: true - /playwright-core@1.36.1: - resolution: {integrity: sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==} + /playwright-core@1.36.2: + resolution: {integrity: sha512-sQYZt31dwkqxOrP7xy2ggDfEzUxM1lodjhsQ3NMMv5uGTRDsLxU0e4xf4wwMkF2gplIxf17QMBCodSFgm6bFVQ==} engines: {node: '>=16'} hasBin: true dev: true - /postcss-load-config@3.1.4(postcss@8.4.27): + /postcss-load-config@3.1.4(postcss@8.4.27)(ts-node@10.9.1): resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} peerDependencies: @@ -5340,6 +5241,7 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.27 + ts-node: 10.9.1(@types/node@20.4.7)(typescript@5.1.6) yaml: 1.10.2 dev: true @@ -5376,25 +5278,24 @@ packages: nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} dev: true - /prettier-plugin-svelte@3.0.0(prettier@3.0.0)(svelte@4.0.5): - resolution: {integrity: sha512-l3RQcPty2UBCoRh3yb9c5XCAmxkrc4BptAnbd5acO1gmSJtChOWkiEjnOvh7hvmtT4V80S8gXCOKAq8RNeIzSw==} + /prettier-plugin-svelte@3.0.3(prettier@3.0.1)(svelte@4.1.2): + resolution: {integrity: sha512-dLhieh4obJEK1hnZ6koxF+tMUrZbV5YGvRpf2+OADyanjya5j0z1Llo8iGwiHmFWZVG/hLEw/AJD5chXd9r3XA==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 dependencies: - prettier: 3.0.0 - svelte: 4.0.5 + prettier: 3.0.1 + svelte: 4.1.2 dev: true - /prettier@3.0.0: - resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} + /prettier@3.0.1: + resolution: {integrity: sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==} engines: {node: '>=14'} hasBin: true dev: true @@ -5426,7 +5327,7 @@ packages: /pvtsutils@1.3.2: resolution: {integrity: sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /pvutils@1.1.3: @@ -5434,11 +5335,6 @@ packages: engines: {node: '>=6.0.0'} dev: true - /q@1.5.1: - resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} - engines: {node: '>=0.6.0', teleport: '>=0.2.0'} - dev: true - /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -5492,7 +5388,6 @@ packages: engines: {node: '>=8.10.0'} dependencies: picomatch: 2.3.1 - dev: true /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} @@ -5573,7 +5468,6 @@ packages: is-core-module: 2.12.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true /restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} @@ -5614,8 +5508,15 @@ packages: glob: 10.3.3 dev: true - /rollup@3.26.2: - resolution: {integrity: sha512-6umBIGVz93er97pMgQO08LuH3m6PUb3jlDUUGFsNJB6VgTCUaDFpupf5JfU30529m/UKOgmiX+uY6Sx8cOYpLA==} + /rollup@3.28.0: + resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + + /rollup@3.28.1: + resolution: {integrity: sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: @@ -5636,7 +5537,7 @@ packages: /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /sade@1.8.1: @@ -5644,7 +5545,6 @@ packages: engines: {node: '>=6'} dependencies: mri: 1.2.0 - dev: true /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5663,28 +5563,19 @@ packages: rimraf: 2.7.1 dev: true - /sass@1.63.6: - resolution: {integrity: sha512-MJuxGMHzaOW7ipp+1KdELtqKbfAWbH7OLIdoSMnVe3EXPMTmxTmlaZDCTsgIpPCs3w99lLo9/zDKkOrJuT5byw==} + /sass@1.64.2: + resolution: {integrity: sha512-TnDlfc+CRnUAgLO9D8cQLFu/GIjJIzJCGkE7o4ekIGQOH7T3GetiRR/PsTWJUHhkzcSPrARkPI+gNWn5alCzDg==} engines: {node: '>=14.0.0'} hasBin: true dependencies: chokidar: 3.5.3 immutable: 4.3.2 source-map-js: 1.0.2 - dev: true /scuid@1.1.0: resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} dev: true - /semver@7.5.2: - resolution: {integrity: sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==} - engines: {node: '>=10'} - hasBin: true - dependencies: - lru-cache: 6.0.0 - dev: true - /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -5697,7 +5588,7 @@ packages: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} dependencies: no-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 upper-case-first: 2.0.2 dev: true @@ -5707,7 +5598,6 @@ packages: /set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} - dev: true /setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -5753,7 +5643,6 @@ packages: '@polka/url': 1.0.0-next.21 mrmime: 1.0.1 totalist: 3.0.1 - dev: true /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} @@ -5782,7 +5671,7 @@ packages: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} dependencies: dot-case: 3.0.4 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /sorcery@0.11.0: @@ -5839,7 +5728,7 @@ packages: /sponge-case@1.0.1: resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /sprintf-js@1.0.3: @@ -5857,7 +5746,6 @@ packages: /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - dev: true /string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -5953,9 +5841,8 @@ packages: /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - dev: true - /svelte-check@3.4.6(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.63.6)(svelte@4.0.5): + /svelte-check@3.4.6(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.64.2)(svelte@4.1.2): resolution: {integrity: sha512-OBlY8866Zh1zHQTkBMPS6psPi7o2umTUyj6JWm4SacnIHXpWFm658pG32m3dKvKFL49V4ntAkfFHKo4ztH07og==} hasBin: true peerDependencies: @@ -5963,12 +5850,12 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.18 chokidar: 3.5.3 - fast-glob: 3.3.0 + fast-glob: 3.3.1 import-fresh: 3.3.0 picocolors: 1.0.0 sade: 1.8.1 - svelte: 4.0.5 - svelte-preprocess: 5.0.4(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.63.6)(svelte@4.0.5)(typescript@5.1.6) + svelte: 4.1.2 + svelte-preprocess: 5.0.4(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.64.2)(svelte@4.1.2)(typescript@5.1.6) typescript: 5.1.6 transitivePeerDependencies: - '@babel/core' @@ -5982,7 +5869,7 @@ packages: - sugarss dev: true - /svelte-eslint-parser@0.32.2(svelte@4.0.5): + /svelte-eslint-parser@0.32.2(svelte@4.1.2): resolution: {integrity: sha512-Ok9D3A4b23iLQsONrjqtXtYDu5ZZ/826Blaw2LeFZVTg1pwofKDG4mz3/GYTax8fQ0plRGHI6j+d9VQYy5Lo/A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -5996,23 +5883,22 @@ packages: espree: 9.6.1 postcss: 8.4.27 postcss-scss: 4.0.6(postcss@8.4.27) - svelte: 4.0.5 + svelte: 4.1.2 dev: true /svelte-fa@3.0.4: resolution: {integrity: sha512-y04vEuAoV1wwVDItSCzPW7lzT6v1bj/y1p+W1V+NtIMpQ+8hj8MBkx7JFD7JHSnalPU1QiI8BVfguqheEA3nPg==} dev: true - /svelte-hmr@0.15.3(svelte@4.0.5): + /svelte-hmr@0.15.3(svelte@4.1.2): resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} engines: {node: ^12.20 || ^14.13.1 || >= 16} peerDependencies: svelte: ^3.19.0 || ^4.0.0 dependencies: - svelte: 4.0.5 - dev: true + svelte: 4.1.2 - /svelte-preprocess@5.0.4(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.63.6)(svelte@4.0.5)(typescript@5.1.6): + /svelte-preprocess@5.0.4(@babel/core@7.22.9)(postcss@8.4.27)(sass@1.64.2)(svelte@4.1.2)(typescript@5.1.6): resolution: {integrity: sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==} engines: {node: '>= 14.10.0'} requiresBuild: true @@ -6055,23 +5941,23 @@ packages: detect-indent: 6.1.0 magic-string: 0.27.0 postcss: 8.4.27 - sass: 1.63.6 + sass: 1.64.2 sorcery: 0.11.0 strip-indent: 3.0.0 - svelte: 4.0.5 + svelte: 4.1.2 typescript: 5.1.6 dev: true - /svelte-turnstile@0.5.0(svelte@4.0.5): + /svelte-turnstile@0.5.0(svelte@4.1.2): resolution: {integrity: sha512-FD/XOfyN2gOr7csfThLyrS/sSsRd2zgVfm5pwE1KvpWQHJYqx1HL/PQ92WEw8peL+SPqoKYx2619aZ65uPUsxg==} peerDependencies: svelte: ^3.58.0 || ^4.0.0 dependencies: - svelte: 4.0.5 + svelte: 4.1.2 turnstile-types: 1.1.2 dev: true - /svelte2tsx@0.6.19(svelte@4.0.5)(typescript@5.1.6): + /svelte2tsx@0.6.19(svelte@4.1.2)(typescript@5.1.6): resolution: {integrity: sha512-h3b5OtcO8zyVL/RiB2zsDwCopeo/UH+887uyhgb2mjnewOFwiTxu+4IGuVwrrlyuh2onM2ktfUemNrNmQwXONQ==} peerDependencies: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 @@ -6079,12 +5965,12 @@ packages: dependencies: dedent-js: 1.0.1 pascal-case: 3.1.2 - svelte: 4.0.5 + svelte: 4.1.2 typescript: 5.1.6 dev: true - /svelte@4.0.5: - resolution: {integrity: sha512-PHKPWP1wiWHBtsE57nCb8xiWB3Ht7/3Kvi3jac0XIxUM2rep8alO7YoAtgWeGD7++tFy46krilOrPW0mG3Dx+A==} + /svelte@4.1.2: + resolution: {integrity: sha512-/evA8U6CgOHe5ZD1C1W3va9iJG7mWflcCdghBORJaAhD2JzrVERJty/2gl0pIPrJYBGZwZycH6onYf+64XXF9g==} engines: {node: '>=16'} dependencies: '@ampproject/remapping': 2.2.1 @@ -6104,7 +5990,7 @@ packages: /swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /text-extensions@1.9.0: @@ -6143,7 +6029,7 @@ packages: /title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /tmp@0.0.33: @@ -6163,12 +6049,10 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - dev: true /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - dev: true /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -6197,7 +6081,7 @@ packages: resolution: {integrity: sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==} dev: true - /ts-node@10.9.1(@types/node@20.4.2)(typescript@5.1.6): + /ts-node@10.9.1(@types/node@20.4.7)(typescript@5.1.6): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -6216,7 +6100,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.4.2 + '@types/node': 20.4.7 acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 @@ -6232,8 +6116,8 @@ packages: resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} dev: true - /tslib@2.6.0: - resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==} + /tslib@2.6.1: + resolution: {integrity: sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==} dev: true /turnstile-types@1.1.2: @@ -6307,7 +6191,6 @@ packages: engines: {node: '>=14.0'} dependencies: busboy: 1.6.0 - dev: true /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -6340,13 +6223,13 @@ packages: /upper-case-first@2.0.2: resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /upper-case@2.0.2: resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.1 dev: true /uri-js@4.4.1: @@ -6363,14 +6246,14 @@ packages: resolution: {integrity: sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==} dev: true - /urql@4.0.4(graphql@16.7.1)(react@18.2.0): - resolution: {integrity: sha512-C5P4BMnAsk+rbytCWglit5ijXbIKXsa9wofSGPbuMyJKsDdL+9GfipS362Nff/Caag+eYOK5W+sox8fwEILT6Q==} + /urql@4.0.5(graphql@16.7.1)(react@18.2.0): + resolution: {integrity: sha512-VicPBQXWicSbE+0oPzU2HMyDa//76FmwyQ7LayaYQxX97nhvMLs2ZWQdUmEzQQqvmw4YFaI0wPz1Qisp+PrZIQ==} peerDependencies: react: '>= 16.8.0' dependencies: '@urql/core': 4.1.1(graphql@16.7.1) react: 18.2.0 - wonka: 6.3.2 + wonka: 6.3.4 transitivePeerDependencies: - graphql dev: false @@ -6400,7 +6283,7 @@ packages: engines: {node: '>=12'} dev: true - /vite-node@0.33.0(@types/node@20.4.2)(sass@1.63.6): + /vite-node@0.33.0(@types/node@20.4.9)(sass@1.64.2): resolution: {integrity: sha512-19FpHYbwWWxDr73ruNahC+vtEdza52kA90Qb3La98yZ0xULqV8A5JLNPUff0f5zID4984tW7l3DH2przTJUZSw==} engines: {node: '>=v14.18.0'} hasBin: true @@ -6410,7 +6293,7 @@ packages: mlly: 1.4.0 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) transitivePeerDependencies: - '@types/node' - less @@ -6422,8 +6305,8 @@ packages: - terser dev: true - /vite-plugin-dts@3.3.0(@types/node@20.4.2)(typescript@5.1.6)(vite@4.4.4): - resolution: {integrity: sha512-9jm7wV8fkA4JaKmZdeg/X71dMi8l9SbdmzQRafW4ea1fOfd/LHBDKuwFuxKpK8h1h8O7abKycXS087EP7EL8Hw==} + /vite-plugin-dts@3.5.1(@types/node@20.4.7)(typescript@5.1.6)(vite@4.4.9): + resolution: {integrity: sha512-wrrIvRTWq9xL0HKOUvJyJ+wivEoLsZ2GU2I2000v5tAAUtu9gE+5OUmUJ9yNkmyYz3tSPedkkiXHeb5jnnSXhg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: typescript: '*' @@ -6432,13 +6315,13 @@ packages: vite: optional: true dependencies: - '@microsoft/api-extractor': 7.36.3(@types/node@20.4.2) - '@rollup/pluginutils': 5.0.2(rollup@3.26.2) + '@microsoft/api-extractor': 7.36.3(@types/node@20.4.7) + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) '@vue/language-core': 1.8.8(typescript@5.1.6) debug: 4.3.4 kolorist: 1.8.0 typescript: 5.1.6 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) + vite: 4.4.9(@types/node@20.4.7) vue-tsc: 1.8.8(typescript@5.1.6) transitivePeerDependencies: - '@types/node' @@ -6446,8 +6329,8 @@ packages: - supports-color dev: true - /vite@4.4.4(@types/node@20.4.2)(sass@1.63.6): - resolution: {integrity: sha512-4mvsTxjkveWrKDJI70QmelfVqTm+ihFAb6+xf4sjEU2TmUCTlVX87tmg/QooPEMQb/lM9qGHT99ebqPziEd3wg==} + /vite@4.4.9(@types/node@20.4.7): + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true peerDependencies: @@ -6474,16 +6357,51 @@ packages: terser: optional: true dependencies: - '@types/node': 20.4.2 + '@types/node': 20.4.7 esbuild: 0.18.17 postcss: 8.4.27 - rollup: 3.26.2 - sass: 1.63.6 + rollup: 3.28.1 optionalDependencies: fsevents: 2.3.2 dev: true - /vitefu@0.2.4(vite@4.4.4): + /vite@4.4.9(@types/node@20.4.9)(sass@1.64.2): + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.4.9 + esbuild: 0.18.17 + postcss: 8.4.27 + rollup: 3.28.0 + sass: 1.64.2 + optionalDependencies: + fsevents: 2.3.2 + + /vitefu@0.2.4(vite@4.4.9): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} peerDependencies: vite: ^3.0.0 || ^4.0.0 @@ -6491,10 +6409,9 @@ packages: vite: optional: true dependencies: - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - dev: true + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) - /vitest@0.33.0(sass@1.63.6): + /vitest@0.33.0(sass@1.64.2): resolution: {integrity: sha512-1CxaugJ50xskkQ0e969R/hW47za4YXDUfWJDxip1hwbnhUjYolpfUn2AMOulqG/Dtd9WYAtkHmM/m3yKVrEejQ==} engines: {node: '>=v14.18.0'} hasBin: true @@ -6527,7 +6444,7 @@ packages: dependencies: '@types/chai': 4.3.5 '@types/chai-subset': 1.3.3 - '@types/node': 20.4.2 + '@types/node': 20.4.9 '@vitest/expect': 0.33.0 '@vitest/runner': 0.33.0 '@vitest/snapshot': 0.33.0 @@ -6546,8 +6463,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.5.0 tinypool: 0.6.0 - vite: 4.4.4(@types/node@20.4.2)(sass@1.63.6) - vite-node: 0.33.0(@types/node@20.4.2)(sass@1.63.6) + vite: 4.4.9(@types/node@20.4.9)(sass@1.64.2) + vite-node: 0.33.0(@types/node@20.4.9)(sass@1.64.2) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -6596,7 +6513,7 @@ packages: '@peculiar/json-schema': 1.1.12 asn1js: 3.0.5 pvtsutils: 1.3.2 - tslib: 2.6.0 + tslib: 2.6.1 dev: true /webidl-conversions@3.0.1: @@ -6631,8 +6548,8 @@ packages: stackback: 0.0.2 dev: true - /wonka@6.3.2: - resolution: {integrity: sha512-2xXbQ1LnwNS7egVm1HPhW2FyKrekolzhpM3mCwXdQr55gO+tAiY76rhb32OL9kKsW8taj++iP7C6hxlVzbnvrw==} + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} dev: false /wrap-ansi@6.2.0: @@ -6664,7 +6581,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true /ws@8.13.0: resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} @@ -6783,7 +6699,3 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b5d0a07..d2d4b025 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ packages: - - "frontend/player" - - "frontend/website" + - "video/player" + - "platform/website" diff --git a/proto/Cargo.toml b/proto/Cargo.toml new file mode 100644 index 00000000..cc70c55e --- /dev/null +++ b/proto/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pb" +version = "0.0.1" +edition = "2021" +authors = ["Scuffle "] +description = "Scuffle Protobuf definitions for scuffle" + +[dependencies] +tonic = "0.9.2" +prost = "0.11.9" +ulid = "1.0.0" +uuid = "1.4.1" + +[build-dependencies] +tonic-build = "0.9.2" +prost-build = "0.11.9" +walkdir = "2.3.3" +syn = { version = "2.0.28", features = ["full"] } +quote = "1.0.32" +proc-macro2 = "1.0.66" +prettyplease = "0.2.12" diff --git a/proto/build.rs b/proto/build.rs new file mode 100644 index 00000000..fd323fa8 --- /dev/null +++ b/proto/build.rs @@ -0,0 +1,104 @@ +use std::collections::{HashMap, HashSet}; +use std::io::BufRead; + +use quote::quote; +use walkdir::WalkDir; + +const PROTO_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +#[derive(Debug, Default)] +struct Tree { + children: HashMap, + absolute: String, + leaf: bool, +} + +fn generate_modules(root: &HashMap) -> proc_macro2::TokenStream { + let modules = root + .iter() + .map(|(part, tree)| { + let children = generate_modules(&tree.children); + let leaf = if tree.leaf { + let absolute = &tree.absolute; + quote! { + ::tonic::include_proto!(#absolute); + } + } else { + quote! {} + }; + + let part = syn::Ident::new(part, proc_macro2::Span::call_site()); + + quote! { + pub mod #part { + #leaf + #children + } + } + }) + .collect::>(); + + quote! { + #(#modules)* + } +} + +fn main() { + let mut config = prost_build::Config::new(); + + config.protoc_arg("--experimental_allow_proto3_optional"); + config.bytes(["."]); + + let proto_files = WalkDir::new(".") + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension() == Some(std::ffi::OsStr::new("proto"))) + .map(|e| { + let path = e.path().canonicalize().unwrap().display().to_string(); + println!("cargo:rerun-if-changed={}", path); + path + }) + .collect::>(); + + let mut root_tree = Tree::default(); + + proto_files + .iter() + .filter_map(|f| { + let file = std::fs::File::open(f).unwrap(); + std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .find_map(|l| { + l.strip_prefix("package ") + .and_then(|l| l.strip_suffix(';')) + .map(|l| l.to_string()) + }) + }) + .collect::>() + .into_iter() + .for_each(|p| { + let mut tree = &mut root_tree; + for part in p.split('.') { + let absolute = format!("{}.{}", tree.absolute, part); + tree = tree + .children + .entry(part.to_string()) + .or_insert_with(Tree::default); + tree.absolute = absolute.trim_start_matches('.').to_string(); + } + + tree.leaf = true; + }); + + std::fs::write( + std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("module.rs"), + prettyplease::unparse(&syn::parse2(generate_modules(&root_tree.children)).unwrap()), + ) + .unwrap(); + + tonic_build::configure() + .compile_with_config(config, &proto_files, &[PROTO_DIR]) + .unwrap(); +} diff --git a/proto/scuffle/utils/health.proto b/proto/health.proto similarity index 100% rename from proto/scuffle/utils/health.proto rename to proto/health.proto diff --git a/proto/scuffle/backend/api.proto b/proto/scuffle/backend/api.proto deleted file mode 100644 index 7733d122..00000000 --- a/proto/scuffle/backend/api.proto +++ /dev/null @@ -1,120 +0,0 @@ -syntax = "proto3"; - -package scuffle.backend; - -import "scuffle/types/stream_state.proto"; - -// This is an internal API for the Scuffle service. -// Used for communication between scuffle microservices. -service API { - // Method used by Ingest service to validate a stream key when a new publisher - // goes live. - rpc AuthenticateLiveStream(AuthenticateLiveStreamRequest) - returns (AuthenticateLiveStreamResponse) {} - - // Method used by the Ingest service to create a new stream. - // Only called if try_resumed is true and the stream could not be resumed. - rpc NewLiveStream(NewLiveStreamRequest) returns (NewLiveStreamResponse) {} - - // Method is used by the Ingest, Transcoder and Edge services, transcoder uses - // it to publish the variants and edge uses it to update the state of the - // stream (if it is ready to be played) and Ingest will use it to handle when - // the stream is stopped. - rpc UpdateLiveStream(UpdateLiveStreamRequest) - returns (UpdateLiveStreamResponse) {} -} - -// This request is created by the Ingest service when a new publisher goes live. -message AuthenticateLiveStreamRequest { - // The name of the app that the publisher is trying to go live on. - string app_name = 1; - // The stream key that the publisher is trying to go live with. - string stream_key = 2; - // The IP address of the publisher. - string ip_address = 3; - // Address of the ingest server which the publisher is connected to. - string ingest_address = 4; - // The connection ID of the publisher. - string connection_id = 5; -} - -// This response is sent back to the Ingest service, generated by the API -// service. -message AuthenticateLiveStreamResponse { - // A new stream ID to use for the stream. - string stream_id = 2; - // Whether the stream should be transcoded or not. - bool transcode = 3; - // should record the stream - bool record = 4; - // The variants of the stream. (if present, try resume the stream) - optional scuffle.types.StreamState state = 5; -} - -// This request is created by the Ingest service when we attempt to resume a -// stream and it fails. -message NewLiveStreamRequest { - // The ID of the stream to create. - string old_stream_id = 1; - // The new variants of the stream. - scuffle.types.StreamState state = 2; -} - -// This response is sent back to the Ingest service, generated by the API -message NewLiveStreamResponse { - // The ID of the stream that was created. - string stream_id = 1; -} - -enum StreamReadyState { - NOT_READY = 0; - READY = 1; - STOPPED = 2; - STOPPED_RESUMABLE = 3; - FAILED = 4; -} - -message UpdateLiveStreamRequest { - // The ID of the stream to update. - string stream_id = 1; - - string connection_id = 2; - - // If the stream failed, this message will be sent. - message Event { - enum Level { - INFO = 0; - WARNING = 1; - ERROR = 2; - } - - // The title of the event. - string title = 1; - // The message in the event. - string message = 2; - // The level of the event - Level level = 3; - } - - message Bitrate { - // The bitrate of the stream. - uint64 video_bitrate = 1; - uint64 audio_bitrate = 2; - uint64 metadata_bitrate = 3; - } - - // We only need oneof these fields to be set. - message Update { - uint64 timestamp = 1; - oneof update { - scuffle.types.StreamState state = 2; - StreamReadyState ready_state = 3; - Bitrate bitrate = 4; - Event event = 5; - } - } - - repeated Update updates = 3; -} - -message UpdateLiveStreamResponse {} diff --git a/proto/scuffle/events/ingest.proto b/proto/scuffle/events/ingest.proto deleted file mode 100644 index a39f28ac..00000000 --- a/proto/scuffle/events/ingest.proto +++ /dev/null @@ -1,16 +0,0 @@ -syntax = "proto3"; - -package scuffle.events; - -message IngestMessage { - string id = 1; - uint64 timestamp = 2; - - oneof data { - IngestMessageDropStream drop_stream = 3; - } -} - -message IngestMessageDropStream { - string id = 1; -} diff --git a/proto/scuffle/events/transcoder.proto b/proto/scuffle/events/transcoder.proto deleted file mode 100644 index de68c80e..00000000 --- a/proto/scuffle/events/transcoder.proto +++ /dev/null @@ -1,21 +0,0 @@ -syntax = "proto3"; - -package scuffle.events; - -import "scuffle/types/stream_state.proto"; - -message TranscoderMessage { - string id = 1; - uint64 timestamp = 2; - - oneof data { - TranscoderMessageNewStream new_stream = 3; - } -} - -message TranscoderMessageNewStream { - string request_id = 1; - string stream_id = 2; - string ingest_address = 3; - scuffle.types.StreamState state = 4; -} diff --git a/proto/scuffle/events/api.proto b/proto/scuffle/platform/internal/events/api.proto similarity index 85% rename from proto/scuffle/events/api.proto rename to proto/scuffle/platform/internal/events/api.proto index 906ebbff..80cc3a9a 100644 --- a/proto/scuffle/events/api.proto +++ b/proto/scuffle/platform/internal/events/api.proto @@ -1,6 +1,6 @@ syntax = "proto3"; -package scuffle.events; +package scuffle.platform.internal.events; message UserDisplayName { optional string username = 2; diff --git a/proto/scuffle/types/stream_state.proto b/proto/scuffle/types/stream_state.proto deleted file mode 100644 index e32c6771..00000000 --- a/proto/scuffle/types/stream_state.proto +++ /dev/null @@ -1,68 +0,0 @@ -syntax = "proto3"; - -package scuffle.types; - -message StreamState { - // A variant is a transcoded version of the stream. - // A stream variant is unique by its name and group. - message Variant { - // The name of the variant. - string name = 1; - - // Group the variant belongs to. - string group = 2; - - // The transcode states of the variant. - repeated string transcode_ids = 3; - } - - // A state that the transcoder should transcode to. - // A transcode state is unique by its id. - message Transcode { - // The id of the variant. - string id = 1; - - message VideoSettings { - // The width of the video. - uint32 width = 1; - // The height of the video. - uint32 height = 2; - // The framerate of the video. - uint32 framerate = 3; - } - - message AudioSettings { - // The sample rate of the audio. - uint32 sample_rate = 1; - // The number of channels of the audio. - uint32 channels = 2; - } - - // The settings for the transcode state (video or audio). - oneof settings { - VideoSettings video = 2; - AudioSettings audio = 3; - } - - // The bitrate of the video. - uint32 bitrate = 4; - - // The codec of the video. - string codec = 5; - - // Copy the stream directly from the source. - bool copy = 6; - } - - message Group { - // The name of the group. - string name = 1; - - // The priority of the group. - int32 priority = 2; - } - - repeated Variant variants = 1; - repeated Transcode transcodes = 2; - repeated Group groups = 3; -} diff --git a/proto/scuffle/video/edge.proto b/proto/scuffle/video/edge.proto deleted file mode 100644 index 03542b40..00000000 --- a/proto/scuffle/video/edge.proto +++ /dev/null @@ -1,28 +0,0 @@ -syntax = "proto3"; - -package scuffle.video; - -// // This is an internal Edge for the Scuffle service. -// // Used for communication between scuffle microservices. -// service Edge { -// // The transcoder service will call this method to send a segment to the -// edge. -// // Edge will then use this segment to create a new HLS variant. -// rpc PushSegment(PushSegmentRequest) returns (PushSegmentResponse) {} -// } - -// message PushSegmentRequest { -// // A stream ID can have multiple variants. -// string stream_id = 1; -// // The variant ID is the name of the variant. -// string variant_id = 2; -// // When this flag is set the edge can if it wants create a new segment with -// // this as the first part. -// bool can_be_new_segment = 3; -// // Duration of the segment in milliseconds. -// int32 duration = 4; -// // The raw segment data. -// bytes data = 5; -// } - -// message SegmentResponse {} diff --git a/proto/scuffle/video/ingest.proto b/proto/scuffle/video/ingest.proto deleted file mode 100644 index 34dbaf44..00000000 --- a/proto/scuffle/video/ingest.proto +++ /dev/null @@ -1,87 +0,0 @@ -syntax = "proto3"; - -package scuffle.video; - -// This is an internal Ingest for the Scuffle service. -// Used for communication between scuffle microservices. -service Ingest { - // WatchStream is a streaming RPC that allows the transcoder to watch a video - // stream. Used by the Scuffle transcoder to digest the video stream and then - // transcode it Pushing it to the Edge service. - rpc WatchStream(WatchStreamRequest) returns (stream WatchStreamResponse) {} - - /// TranscoderEvent is a RPC that allows the transcoder to send events to the - // Ingest service. - rpc TranscoderEvent(TranscoderEventRequest) - returns (TranscoderEventResponse) {} - - rpc ShutdownStream(ShutdownStreamRequest) returns (ShutdownStreamResponse) {} -} - -message WatchStreamRequest { - // The uuid of the request that was queued by the Ingest service. - // This is not the publish uuid. This is to make sure we can easily revoke - // queued requests. - string request_id = 1; - - // Stream id of the stream that is being transcoded. - string stream_id = 2; -} - -message WatchStreamResponse { - message MediaSegment { - enum DataType { - // The fragment is a video fragment. - VIDEO = 0; - // The fragment is an audio fragment. - AUDIO = 1; - } - - // The fragment number. - uint64 timestamp = 1; - // The fragment data. - bytes data = 2; - // Keyframe information. - bool keyframe = 3; - // Type of the fragment. - DataType data_type = 4; - } - - oneof data { - bytes init_segment = 1; - MediaSegment media_segment = 2; - // If this is true, the stream has ended, if this is false, the transcoder - // session has ended (and the stream is still going). - bool shutting_down = 3; - } -} - -message TranscoderEventRequest { - // The uuid of the request that was queued by the Ingest service. - string request_id = 1; - - // Stream id of the stream that is being transcoded. - string stream_id = 2; - - message Error { - // The error message. - string message = 1; - // The error code. - bool fatal = 2; - } - - oneof event { - bool started = 3; - bool shutting_down = 4; - Error error = 5; - } -} - -message TranscoderEventResponse {} - -message ShutdownStreamRequest { - // Stream id of the stream that is being transcoded. - string stream_id = 1; -} - -message ShutdownStreamResponse {} diff --git a/proto/scuffle/video/internal/events/organization_event.proto b/proto/scuffle/video/internal/events/organization_event.proto new file mode 100644 index 00000000..722bf169 --- /dev/null +++ b/proto/scuffle/video/internal/events/organization_event.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package scuffle.video.internal.events; + +import "scuffle/video/v1/types/ulid.proto"; + +message OrganizationEvent { + int64 timestamp = 1; + scuffle.video.v1.types.Ulid id = 2; + + message RoomLive { + scuffle.video.v1.types.Ulid connection_id = 1; + scuffle.video.v1.types.Ulid room_id = 2; + } + + message RoomReady { + scuffle.video.v1.types.Ulid connection_id = 1; + scuffle.video.v1.types.Ulid room_id = 2; + } + + message RoomDisconnect { + scuffle.video.v1.types.Ulid connection_id = 1; + scuffle.video.v1.types.Ulid room_id = 2; + bool clean = 3; + optional string error = 4; + } + + oneof event { + RoomLive room_live = 3; + RoomDisconnect room_disconnect = 4; + RoomReady room_ready = 5; + } +} \ No newline at end of file diff --git a/proto/scuffle/video/internal/events/transcoder_request.proto b/proto/scuffle/video/internal/events/transcoder_request.proto new file mode 100644 index 00000000..8d8b565d --- /dev/null +++ b/proto/scuffle/video/internal/events/transcoder_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package scuffle.video.internal.events; + +import "scuffle/video/v1/types/ulid.proto"; + +message TranscoderRequest { + scuffle.video.v1.types.Ulid organization_id = 1; + scuffle.video.v1.types.Ulid room_id = 2; + scuffle.video.v1.types.Ulid connection_id = 3; + scuffle.video.v1.types.Ulid request_id = 4; + string grpc_endpoint = 5; +} diff --git a/proto/scuffle/video/internal/ingest.proto b/proto/scuffle/video/internal/ingest.proto new file mode 100644 index 00000000..5dd89a11 --- /dev/null +++ b/proto/scuffle/video/internal/ingest.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package scuffle.video.internal; + +import "scuffle/video/v1/types/ulid.proto"; + +service Ingest { + rpc Watch(stream IngestWatchRequest) returns (stream IngestWatchResponse) {} +} + +message IngestWatchRequest { + message Open { + scuffle.video.v1.types.Ulid request_id = 1; + } + + enum Shutdown { + SHUTDOWN_REQUEST = 0; + SHUTDOWN_COMPLETE = 1; + } + + oneof message { + Open open = 1; + Shutdown shutdown = 2; + } +} + +message IngestWatchResponse { + message Media { + enum Type { + INIT = 0; + AUDIO = 1; + VIDEO = 2; + } + + Type type = 1; + bytes data = 2; + bool keyframe = 3; + } + + enum Shutdown { + SHUTDOWN_STREAM = 0; + SHUTDOWN_TRANSCODER = 1; + } + + enum Ready { + READY = 0; + } + + oneof message { + Media media = 1; + Shutdown shutdown = 2; + Ready ready = 3; + } +} diff --git a/proto/scuffle/video/internal/live_manifest.proto b/proto/scuffle/video/internal/live_manifest.proto new file mode 100644 index 00000000..8a7b0374 --- /dev/null +++ b/proto/scuffle/video/internal/live_manifest.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package scuffle.video.internal; + +message LiveManifest { + uint32 screenshot_idx = 1; +} diff --git a/proto/scuffle/video/internal/live_rendition_manifest.proto b/proto/scuffle/video/internal/live_rendition_manifest.proto new file mode 100644 index 00000000..ea083174 --- /dev/null +++ b/proto/scuffle/video/internal/live_rendition_manifest.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package scuffle.video.internal; + +import "scuffle/video/v1/types/ulid.proto"; + +message LiveRenditionManifest { + message Part { + uint32 idx = 1; + bool independent = 2; + uint32 duration = 3; + } + + message Segment { + uint32 idx = 1; + repeated Part parts = 2; + optional scuffle.video.v1.types.Ulid id = 3; + } + + message RenditionInfo { + uint32 next_segment_idx = 1; + uint32 next_part_idx = 2; + uint32 next_segment_part_idx = 3; + } + + repeated Segment segments = 1; + bool completed = 2; + uint32 timescale = 3; + uint64 total_duration = 4; + + RenditionInfo info = 5; + map other_info = 6; + + optional scuffle.video.v1.types.Ulid recording_ulid = 7; +} diff --git a/proto/scuffle/video/transcoder.proto b/proto/scuffle/video/transcoder.proto deleted file mode 100644 index 93b0a49c..00000000 --- a/proto/scuffle/video/transcoder.proto +++ /dev/null @@ -1,7 +0,0 @@ -syntax = "proto3"; - -package scuffle.video; - -// This is an internal Transcoder for the Scuffle service. -// Used for communication between scuffle microservices. -service Transcoder {} diff --git a/proto/scuffle/video/v1/events.proto b/proto/scuffle/video/v1/events.proto new file mode 100644 index 00000000..a8a213f9 --- /dev/null +++ b/proto/scuffle/video/v1/events.proto @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/events.proto"; + +// The Events service provides a stream of events that occur in the system. +service Events { + // Subscribe to events. The client should send an `OnOpen` event to + // indicate that it is ready to receive events. The server will respond + // with Events. The client should send an `AckEvent` event to indicate + // that it has processed the event. If the client does not send an + // `AckEvent` event, the server will resend the event after a timeout. + rpc Subscribe(stream EventsSubscribeRequest) returns (stream EventsSubscribeResponse) {} +} + +// EventsSubscribeRequest is a request to Events.Subscribe +// Allows the client to receive events. +message EventsSubscribeRequest { + // The OnOpen event is sent by the client to indicate that it is ready to + // receive events. + message OnOpen { + // The group is used to load balance events across multiple clients. + // If the client wants to share events with other clients, it should + // use the same group name. If the client wants to receive events + // that are not shared with other clients, it should use a unique + // group name. + string group = 1; + } + + // The AckEvent event is sent by the client to indicate that it has + // processed the event. + message AckEvent { + // The ID of the event that was processed. + string id = 1; + + // If true, the server will resend the event after a timeout. + // If false, the server will not resend the event. + bool requeue = 2; + } + + // The event that was sent by the client. + oneof event { + // The OnOpen event is sent by the client to indicate that it is ready + // This should be the first event sent by the client and should not + // be sent again. + OnOpen on_open = 1; + + // The AckEvent event is sent by the client to indicate that it has + // processed the event. + AckEvent ack_event = 2; + } +} + +// EventsSubscribeResponse is a response from Events.Subscribe +message EventsSubscribeResponse { + // The event that was sent by the server. + types.Events event = 1; +} diff --git a/proto/scuffle/video/v1/playback_key_pair.proto b/proto/scuffle/video/v1/playback_key_pair.proto new file mode 100644 index 00000000..2b082cb6 --- /dev/null +++ b/proto/scuffle/video/v1/playback_key_pair.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/playback_key_pair.proto"; +import "scuffle/video/v1/types/modify_mode.proto"; + +// PlaybackKeyPair is a service for managing playback key pairs. +// Playback key pairs are used to authenticate playback requests. +// They are used to ensure that only authorized users can view a stream. +service PlaybackKeyPair { + // Modifys a new playback key pair, or updates an existing one. + rpc Modify(PlaybackKeyPairModifyRequest) + returns (PlaybackKeyPairModifyResponse); + + // Gets playback key pairs. + rpc Get(PlaybackKeyPairGetRequest) returns (PlaybackKeyPairGetResponse); + + // Deletes playback key pairs. + rpc Delete(PlaybackKeyPairDeleteRequest) + returns (PlaybackKeyPairDeleteResponse); +} + +// PlaybackKeyPairModifyRequest is a request to PlaybackKeyPair.Modify +// Allows you to create a new playback key pair, or update an existing one. +message PlaybackKeyPairModifyRequest { + // The name of the key pair + string name = 1; + + // The public key to use + string public_key = 2; + + // The mode is used to determine how to handle the request. + // Either, upsert, create, or update. (default upsert) + types.ModifyMode mode = 3; +} + +// PlaybackKeyPairModifyResponse is a response to PlaybackKeyPair.Modify +message PlaybackKeyPairModifyResponse { + // The new or updated playback key pair. + types.PlaybackKeyPair playback_key_pair = 1; + + // Whether or not the key pair was created. + bool created = 2; +} + +// PlaybackKeyPairGetRequest is a request to PlaybackKeyPair.Get +// Allows you to get playback key pairs. +// You can filter by names (exact match). +// Allows for pagination using the previous created_at timestamp. +message PlaybackKeyPairGetRequest { + // Names to filter by, if any. (max 100) + // If multiple names are provided, they will be combined with OR. + repeated string names = 1; + + // The maximum number of key pairs to return (default 100, max 1000) + uint32 limit = 2; + + // The timestamp to start getting playback key pairs from. + // If not provided, will start from the beginning. + // If provided, will start from the first playback keypair after the + // timestamp. This is a unix timestamp in nanoseconds. + optional int64 created_at = 3; +} + +// PlaybackKeyPairGetResponse is a response to PlaybackKeyPair.Get +message PlaybackKeyPairGetResponse { + // The keypairs that were found. + repeated types.PlaybackKeyPair playback_key_pairs = 1; +} + +// Deletes playback key pairs +message PlaybackKeyPairDeleteRequest { + // Names to delete (max 100, min 1) + repeated string names = 1; +} + +// The Names of the key pairs deleted +message PlaybackKeyPairDeleteResponse { + // Names that were deleted + repeated string names = 1; +} diff --git a/proto/scuffle/video/v1/playback_session.proto b/proto/scuffle/video/v1/playback_session.proto new file mode 100644 index 00000000..6c7be766 --- /dev/null +++ b/proto/scuffle/video/v1/playback_session.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/playback_session_ids.proto"; +import "scuffle/video/v1/types/playback_session_target.proto"; +import "scuffle/video/v1/types/playback_session.proto"; + +// PlaybackSession is a session representing a user watching a video. +// This is useful for analytics and for revoking playback sessions. +service PlaybackSession { + // Get returns playback sessions for a target or for users, or direct ids. + rpc Get(PlaybackSessionGetRequest) returns (PlaybackSessionGetResponse); + + // Revoke revokes playback sessions for a target or for users, or direct ids. + rpc Revoke(PlaybackSessionRevokeRequest) + returns (PlaybackSessionRevokeResponse); + + // Count returns the number of playback sessions for a target. + rpc Count(PlaybackSessionCountRequest) returns (PlaybackSessionCountResponse); +} + +// PlaybackSessionGetRequest is a request to PlaybackSession.Get +// Used to fecth playback sessions for a target or for users, or direct ids. +message PlaybackSessionGetRequest { + // The ids to filter by + optional types.PlaybackSessionIds ids = 1; + + // Optionally filter by target + optional types.PlaybackSessionTarget target = 3; + + // Optionally filter by if the session was authorized + optional bool authorized = 4; + + // The maximum number of sessions to return (default 100, max 1000) + uint32 limit = 5; + + // Offset the results by the created_at field + // You can use this to paginate results. + optional int64 created_at = 6; +} + +// PlaybackSessionGetResponse is a response to PlaybackSession.Get +message PlaybackSessionGetResponse { + // The sessions that were found + repeated types.PlaybackSession sessions = 1; +} + +// PlaybackSessionRevokeRequest is a request to PlaybackSession.Revoke +// Used to revoke playback sessions for a target or for users, or direct ids. +// This is useful for revoking playback sessions when rooms change from public +// to private. +message PlaybackSessionRevokeRequest { + // The target to revoke sessions for + // If you specify direct session ids the target will be ignored + // If you specify a user ids and a target, it will revoke all sessions for + // that user in that target. + // If you specify user ids without a target, it will revoke all sessions for + // that user in all targets. + // If you do not provide any IDs you must provide a target. + optional types.PlaybackSessionIds ids = 1; + // If you specify a target without any session ids it will revoke all + // sessions for that target. + // If you do not provide a target you must provide IDs. + optional types.PlaybackSessionTarget target = 2; + + // Revokes sessions created before this timestamp. + // If not provided it will revoke all sessions before now. + optional int64 before = 3; +} + +// PlaybackSessionRevokeResponse is a response to PlaybackSession.Revoke +message PlaybackSessionRevokeResponse { + // The ids of the sessions that were revoked + repeated string ids = 1; +} + +// PlaybackSessionCountRequest is a request to PlaybackSession.Count +message PlaybackSessionCountRequest { + // The target to filter by + types.PlaybackSessionTarget target = 1; +} + +// PlaybackSessionCountResponse is a response to PlaybackSession.Count +message PlaybackSessionCountResponse { + // The number of sessions that were found + // This is useful for counting total sessions. + uint64 count = 1; + + // The number of sessions that were found after deduplication + // This is useful for counting unique sessions + // For example, if a user watches a video 3 times, it will only count as 1 + // deduplicated session. This is useful for analytics. + uint64 deduplicated_count = 2; +} diff --git a/proto/scuffle/video/v1/recording.proto b/proto/scuffle/video/v1/recording.proto new file mode 100644 index 00000000..f907e43d --- /dev/null +++ b/proto/scuffle/video/v1/recording.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/recording.proto"; + +// A recording is a video that was recorded in a room. +// It can be public or private and it is managed by lifecycle policies. +// You can start recording rooms by attaching a RecordingConfig to a room. +service Recording { + // Get recordings. + rpc Get(RecordingGetRequest) returns (RecordingGetResponse) {} + + // Modify recordings. + rpc Modify(RecordingModifyRequest) returns (RecordingModifyResponse) {} + + // Delete recordings. + rpc Delete(RecordingDeleteRequest) returns (RecordingDeleteResponse) {} +} + +// RecordingGetRequest is a request to Recording.Get +// Allows you to get recordings. +// You can filter by room name or id for exact matches. +// You can also limit the number of recordings returned. +// Allows for pagination using the previous created_at timestamp. +message RecordingGetRequest { + // The ID of the recording to get (exact match). (max 100) + repeated string id = 1; + + // Get recording by room name. If not set, get all recordings. + // If you provide a empty string it will return all recordings without a room + // name. + optional string room_name = 2; + + // Get recordings by the recording config name. + optional string recording_config_name = 3; + + // The number of recording to return. (default 100, max 1000) + int32 limit = 4; + + // The timestamp to start getting recording from. + // If not provided, will start from the beginning. + // If provided, will start from the first recording created after the + // timestamp. If reverse is true, will start from the first recording created + // before the timestamp. This is a unix timestamp in nanoseconds. + optional int64 created_at = 5; + + // Whether to reverse the order of the rooms. + bool reverse = 6; +} + +// RecordingGetResponse is a response to Recording.Get +message RecordingGetResponse { + // The recordings that were found. + repeated types.Recording recordings = 1; +} + +// RecordingModifyRequest is a request to Recording.Modify +// Allows you to modify recordings. +message RecordingModifyRequest { + // The IDs of the recording to modify. (max 100, min 1) + repeated string id = 1; + + // The new room name for the recording. + // If not set, the room name will not be changed. + optional string room_name = 2; + + // Update the recording config name. + // If not set, the recording config name will not be changed. + optional string recording_config_name = 3; + + // Whether the recording is public. + // If not set, the recording will not be changed. + // Changing this will effect the recording immediately. + // If the recording is public, it will be available to anyone with the link. + // If the recording is not public, you will need to issue signed tokens to + // access it. + optional bool is_public = 4; +} + +// RecordingModifyResponse is a response to Recording.Modify +message RecordingModifyResponse { + // The IDs of the recordings that were modified. + repeated string ids = 1; +} + +// RecordingDeleteRequest is a request to Recording.Delete +// Allows you to delete multiple recordings by specifying their IDs or by room +// name. +message RecordingDeleteRequest { + // The ID of the recording to delete. (max 100) + // Cannot be used with the room_name field. + repeated string ids = 1; + + // Delete recording by room name. If not set, delete all recordings. + // Cannot be used with the ids field. + optional string room_name = 2; +} + +// RecordingDeleteResponse is a response to Recording.Delete +message RecordingDeleteResponse { + // The IDs of the recordings that were deleted. + repeated string ids = 1; +} diff --git a/proto/scuffle/video/v1/recording_config.proto b/proto/scuffle/video/v1/recording_config.proto new file mode 100644 index 00000000..ebf75b95 --- /dev/null +++ b/proto/scuffle/video/v1/recording_config.proto @@ -0,0 +1,120 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/rendition.proto"; +import "scuffle/video/v1/types/modify_mode.proto"; +import "scuffle/video/v1/types/recording_lifecycle_policy.proto"; +import "scuffle/video/v1/types/recording_config.proto"; + +// RecordingConfig is the service for managing recording configs. +// Recording configs are used to determine what renditions to record for a +// stream. They also allow you to set lifecycle policies for the recordings. +// Recording configs are applied to rooms via the room's recording_config_name. +// If a room does not have a recording_config_name, it will not be recorded. +service RecordingConfig { + // Modify or update a recording config. + rpc Modify(RecordingConfigModifyRequest) + returns (RecordingConfigModifyResponse) {} + + // Get recording configs. + rpc Get(RecordingConfigGetRequest) returns (RecordingConfigGetResponse) {} + + // Delete recording configs. + rpc Delete(RecordingConfigDeleteRequest) + returns (RecordingConfigDeleteResponse) {} +} + +// RecordingConfigModifyRequest is the request message for +// RecordingConfig.Modify Allows you to create a new recording config, or update +// an existing one. +message RecordingConfigModifyRequest { + // The name of the recording config to create. + string name = 1; + + message RenditionList { + // A list of renditions to store for this recording config. (min 1) + repeated types.Rendition renditions = 1; + } + + // A list of renditions to store for this recording config. + // If not provided the existing renditions will not be updated. + // If not provided and the recording config does not exist, the default + // renditions will be used. [ SOURCE, HD, SD ] If provided and renditions are + // not provided will return an error. + optional RenditionList stored_renditions = 2; + + message LifecyclePolicyList { + // A list of lifecycle policies to apply to this recording config. (min 1) + repeated types.RecordingLifecyclePolicy items = 1; + } + + // A list of lifecycle policies to apply to this recording config. + // These policies will be applied to all recordings that are associated with + // this recording config. If not provided the existing lifecycle policies will + // not be updated. If not provided and the recording config does not exist, + // the default lifecycle policies will be used. [ ] (no policies) + optional LifecyclePolicyList lifecycle_policies = 3; + + // The mode is used to determine how to handle the request. + // Either, upsert, create, or update. (default upsert) + types.ModifyMode mode = 4; +} + +// RecordingConfigModifyResponse is the response message for +// RecordingConfig.Modify +message RecordingConfigModifyResponse { + // The created recording config. + types.RecordingConfig recording_config = 1; + + // Whether the recording config was created or not. + bool created = 2; +} + +// RecordingConfigGetRequest is the request message for RecordingConfig.Get +// Allows you to get back a list of recording configs. +// You can filter by names (exact match). +// Allows for pagination using the previous created_at timestamp. +message RecordingConfigGetRequest { + // Names to get (empty for all) + repeated string names = 1; + + // The number of recording configs to return. (default 100, max 1000) + int32 limit = 2; + + // The timestamp to start getting recording configs from. + // If not provided, will start from the beginning. + // If provided, will start from the first recording config created after the + // timestamp. This is a unix timestamp in nanoseconds. + optional int64 created_at = 3; +} + +// RecordingConfigGetResponse is the response message for RecordingConfig.Get +message RecordingConfigGetResponse { + // The recording configs that were found. + repeated types.RecordingConfig recording_configs = 1; +} + +// RecordingConfigDeleteRequest is the request message for +// RecordingConfig.Delete You can delete multiple recording configs at once by +// providing a list of names. +message RecordingConfigDeleteRequest { + // Names to delete (max 100, min 1) + repeated string names = 1; + + // Delete the recordings which are associated with the recording configs. + // If the records are not deleted, they will be without a recording config. + // Therefore they will not be deleted automatically. + // (default false) + bool delete_recordings = 2; +} + +// RecordingConfigDeleteResponse is the response message for +// RecordingConfig.Delete +message RecordingConfigDeleteResponse { + // The names of the recording configs that were deleted. + repeated string names = 1; + + // The ids of the recordings that were either deleted or disassociated. + repeated string recording_ids = 2; +} diff --git a/proto/scuffle/video/v1/room.proto b/proto/scuffle/video/v1/room.proto new file mode 100644 index 00000000..402b6921 --- /dev/null +++ b/proto/scuffle/video/v1/room.proto @@ -0,0 +1,155 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/modify_mode.proto"; +import "scuffle/video/v1/types/room.proto"; + +// Room allows you to create, update, get, disconnect, reset key, and delete +// rooms. A room is a live stream that can be published to and viewed. Rooms can +// be configured with a transcoding config, recording config, to define how the +// stream is transcoded and recorded. +service Room { + // Modify allows you to create a new room or update an existing room. + rpc Modify(RoomModifyRequest) returns (RoomModifyResponse) {} + + // Get allows you to get rooms. + rpc Get(RoomGetRequest) returns (RoomGetResponse) {} + + // Disconnect allows you to disconnect a currently live room. + rpc Disconnect(RoomDisconnectRequest) returns (RoomDisconnectResponse) {} + + // ResetKey allows you to reset the key for a room. + rpc ResetKey(RoomResetKeyRequest) returns (RoomResetKeyResponse) {} + + // Delete allows you to delete rooms. + rpc Delete(RoomDeleteRequest) returns (RoomDeleteResponse) {} +} + +// RoomModifyRequest is a request to to Room.Modify +// Allows you to create a new room or update an existing room. +message RoomModifyRequest { + // The name of the room. + string name = 1; + + // The name of the transcoding config. + // Changing this will not affect currently live rooms. + // Changing this will affect future rooms. + optional string transcoding_config_name = 2; + + // The name of the recording config. + // Changing this will not affect currently live rooms. + // Changing this will affect future rooms. + optional string recording_config_name = 3; + + // Whether the room is private. + // Private rooms will require signed tokens to view the stream. + // Will effect the room immediately. + optional bool private = 4; + + // The mode is used to determine how to handle the request. + // Either, upsert, create, or update. (default upsert) + types.ModifyMode mode = 5; +} + +// RoomModifyResponse is a response to Room.Modify +message RoomModifyResponse { + // The room that was modified. + types.Room room = 1; + + // Whether the room was created. + bool created = 2; +} + +// RoomGetRequest is a request to Room.Get +// Allows you to get rooms. +// You can filter by name, transcoding config, recording config, and live +// status. If no filters are provided, will return all rooms. You can paginate +// by setting the limit and created_at. +message RoomGetRequest { + // The name of the room. (exact match) (optional) + repeated string name = 1; + + // Filter by the transcoding config. + optional string transcoding_config_name = 2; + + // Filter by the recording config. + optional string recording_config_name = 3; + + // Filter by the live status. + optional bool live = 5; + + // Filter by the private status. + optional bool private = 6; + + // The maximum number of rooms to return. (default 100, max 1000) + int32 limit = 7; + + // The timestamp to start getting rooms from. + // If not provided, will start from the beginning. + // If provided, will start from the first room after the timestamp. + // This is a unix timestamp in nanoseconds. + optional int64 created_at = 8; +} + +// RoomGetResponse is a response to Room.Get +message RoomGetResponse { + // The rooms that were found. + repeated types.Room rooms = 1; +} + +// RoomDisconnectRequest is a request to Room.Disconnect +// This request allows you to disconnect a currently live room. +// If the room is not live, this request will do nothing. +// If the room is live the publisher will be disconnected and the room will be +// marked as not live. You can use this in conjunction with Room.ResetKey to +// prevent new publishers from connecting. +message RoomDisconnectRequest { + // The names of the rooms to disconnect. + repeated string names = 1; +} + +// RoomDisconnectResponse is a response to Room.Disconnect +message RoomDisconnectResponse { + // The rooms that were disconnected. + repeated string names = 1; +} + +// RoomResetKeyRequest is a request to Room.ResetKey +// This request allows you to reset the key for a room. +// This will not disconnect any publishers that are currently connected. +// This will prevent new publishers from connecting. +message RoomResetKeyRequest { + // The names of the rooms to reset the key for. (max 1000, required) + repeated string names = 1; +} + +// RoomResetKeyResponse is a response to Room.ResetKey +message RoomResetKeyResponse { + // The rooms that had their key reset. + // room_name->room_key + map room_keys = 1; +} + +// RoomDeleteRequest is a request to Room.Delete +// This request allows you to delete rooms. +// You can optionally delete the recordings for the rooms. +// If the room is live the publisher will be disconnected. +// If the recordings are not deleted, they will be unlinked from the room, and +// will have an empty room name. +message RoomDeleteRequest { + // The names of the rooms to delete. + repeated string names = 1; + + // Whether to delete the recordings for the rooms. + bool delete_recordings = 2; +} + +// RoomDeleteResponse is a response to Room.Delete +message RoomDeleteResponse { + // The names of the rooms that were deleted. + repeated string names = 1; + + // The ids of the recordings that were deleted or disassociated. + repeated string recording_ids = 2; +} diff --git a/proto/scuffle/video/v1/transcoding_config.proto b/proto/scuffle/video/v1/transcoding_config.proto new file mode 100644 index 00000000..aed6a76a --- /dev/null +++ b/proto/scuffle/video/v1/transcoding_config.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +package scuffle.video.v1; + +import "scuffle/video/v1/types/rendition.proto"; +import "scuffle/video/v1/types/transcoding_config.proto"; +import "scuffle/video/v1/types/modify_mode.proto"; + +// TranscodingConfig is a service for managing transcoding configs. +// Transcoding configs define how rooms will be transcoded, and what renditions +// will be available. You can attch a transcoding config to a room by calling +// Room.Modify with the transcoding_config_name field. +service TranscodingConfig { + // Modify allows you to create a new transcoding config, or update an existing + // one. + rpc Modify(TranscodingConfigModifyRequest) + returns (TranscodingConfigModifyResponse) {} + + // Get allows you to get a transcoding configs. + rpc Get(TranscodingConfigGetRequest) returns (TranscodingConfigGetResponse) {} + + // Delete allows you to delete multiple transcoding configs. + rpc Delete(TranscodingConfigDeleteRequest) + returns (TranscodingConfigDeleteResponse) {} +} + +// TranscodingConfigModifyRequest is a request to TranscodingConfig.Modify +// Allows you to create a new transcoding config, or update an existing one. +message TranscodingConfigModifyRequest { + // The name of the transcoding config. + string name = 1; + + // The renditions to enable for this config. + // Will not effect rooms that are currently live, but will effect future live + // rooms. + repeated types.Rendition renditions = 2; + + // The mode is used to determine how to handle the request. + // Either, upsert, create, or update. (default upsert) + types.ModifyMode mode = 3; +} + +// TranscodingConfigModifyResponse is a request to TranscodingConfig.Modify +message TranscodingConfigModifyResponse { + // The new or updated transcoding config. + types.TranscodingConfig transcoding_config = 1; + + // Whether or not the transcoding config was created. + bool created = 2; +} + +// TranscodingConfigGetRequest is a request to TranscodingConfig.Get +// Allows you to get a transcoding configs. +// You can filter by names (exact match). +// Allows for pagination using the previous created_at timestamp. +message TranscodingConfigGetRequest { + // Names to filter by, if any. (max 100) + // If multiple names are provided, they will be combined with OR. + repeated string names = 1; + + // The maximum number of key pairs to return (default 100, max 1000) + int32 limit = 2; + + // The timestamp to start getting playback key pairs from. + // If not provided, will start from the beginning. + // If provided, will start from the first playback keypair after the + // timestamp. This is a unix timestamp in seconds. + optional int64 created_at = 3; +} + +// TranscodingConfigGetResponse is a response to TranscodingConfig.Get +message TranscodingConfigGetResponse { + // The transcoding configs that were found. + repeated types.TranscodingConfig transcoding_configs = 1; +} + +// TranscodingConfigDeleteRequest is a request to TranscodingConfig.Delete +// Allows you to delete multiple transcoding configs. +message TranscodingConfigDeleteRequest { + // Names to filter by, if any. (max 100) + // If multiple names are provided, they will be combined with OR. + repeated string names = 1; +} + +// TranscodingConfigDeleteResponse is a response to TranscodingConfig.Delete +message TranscodingConfigDeleteResponse { + // The names of the deleted transcoding configs. + repeated string names = 1; + + // The names of the rooms that were using the deleted transcoding configs. + repeated string room_names = 2; +} diff --git a/proto/scuffle/video/v1/types/access_token.proto b/proto/scuffle/video/v1/types/access_token.proto new file mode 100644 index 00000000..db79f584 --- /dev/null +++ b/proto/scuffle/video/v1/types/access_token.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/access_token_scope.proto"; + +message AccessToken { + // The id of the access token + Ulid id = 1; + + // The scopes that this access token has + repeated AccessTokenScope scopes = 2; + + // The time that this access token was created + int64 created_at = 3; + + // The time that this access token was last updated + int64 updated_at = 4; + + // The time that this access token was last used + optional int64 last_used_at = 5; + + // The time that this access token expires + optional int64 expires_at = 6; + + // The tags that this access token has + map tags = 7; +} diff --git a/proto/scuffle/video/v1/types/access_token_scope.proto b/proto/scuffle/video/v1/types/access_token_scope.proto new file mode 100644 index 00000000..485d848b --- /dev/null +++ b/proto/scuffle/video/v1/types/access_token_scope.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +message AccessTokenScope { + // A permission is the ability to perform an action on a resource + enum Permission { + // The read permission allows reading of a resource + READ = 0; + // The write permission allows writing to a resource + WRITE = 1; + // The modify permission allows modification of a resource + MODIFY = 2; + // The delete permission allows deletion of a resource + DELETE = 3; + // The admin permission allows access to all actions on a resource and + // all future actions on a resource without needing to update the scope. + ADMIN = 4; + } + + // Requires at least one permission to be set + repeated Permission permission = 1; + + enum Resource { + // The room resource allows access to rooms + ROOM = 0; + // The recording resource allows access to recordings + RECORDING = 1; + // The transcoding config resource allows access to transcoding configs + TRANSCODING_CONFIG = 2; + // The recording config resource allows access to recording configs + RECORDING_CONFIG = 3; + // The playback key pair resource allows access to playback key pairs + PLAYBACK_KEY_PAIR = 4; + // The playback session resource allows access to playback sessions + PLAYBACK_SESSION = 5; + // The access token resource allows access to access tokens + // If you scope a token to allow for the creation of access tokens, + // you can only create access tokens with the same or less permissions. + ACCESS_TOKEN = 6; + } + + // If not set, the permission applies to all resources + optional Resource resource = 2; +} diff --git a/proto/scuffle/video/v1/types/audio_config.proto b/proto/scuffle/video/v1/types/audio_config.proto new file mode 100644 index 00000000..24faa5b3 --- /dev/null +++ b/proto/scuffle/video/v1/types/audio_config.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/rendition.proto"; + +// An audio configuration contains a friendly name, as well as the +// bitrate, channels, sample rate and codec of the audio. +message AudioConfig { + // The name of the audio configuration. + Rendition rendition = 1; + // The bitrate of the audio. + int64 bitrate = 2; + // The number of channels of the audio. + int32 channels = 3; + // The sample rate of the audio. + int32 sample_rate = 4; + // The codec of the audio. + string codec = 5; +} \ No newline at end of file diff --git a/proto/scuffle/video/v1/types/events.proto b/proto/scuffle/video/v1/types/events.proto new file mode 100644 index 00000000..9613e9c7 --- /dev/null +++ b/proto/scuffle/video/v1/types/events.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/recording.proto"; +import "scuffle/video/v1/types/room.proto"; + +message Events { + message RecordingEvent { + Recording recording = 1; + } + + message RoomEvent { + Room room = 1; + } + + int64 timestamp = 1; + Ulid event_id = 2; + + oneof event { + RecordingEvent recording = 3; + RoomEvent room = 4; + } +} diff --git a/proto/scuffle/video/v1/types/modify_mode.proto b/proto/scuffle/video/v1/types/modify_mode.proto new file mode 100644 index 00000000..2a9af816 --- /dev/null +++ b/proto/scuffle/video/v1/types/modify_mode.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +// ModifyMode is used to determine how to handle a resource modify request. +enum ModifyMode { + // The default mode. If the resource already exists, it will be updated. + // If it does not exist, it will be created. + UPSERT = 0; + // If the resource already exists, it will be updated. If it does not exist, + // the request will fail. + UPDATE = 1; + // If the resource already exists, the request will fail. If it does not + // exist, it will be created. + CREATE = 2; +} diff --git a/proto/scuffle/video/v1/types/playback_key_pair.proto b/proto/scuffle/video/v1/types/playback_key_pair.proto new file mode 100644 index 00000000..04be1c0b --- /dev/null +++ b/proto/scuffle/video/v1/types/playback_key_pair.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; + +// A key pair is used to allow for private playback of a recording or live +// stream. You can create one by generating a public and private key pair, and +// then providing us with the public key. Once you have done you can sign a jwt +// token with the private key and we can verify it with the public key. This +// allows you to create a token that can be used to access a recording or live +// stream and for us to verify that it was you who created it. +// +// The returned object will contain the name of the key pair, the fingerprint of +// the public key, As well as any tags that you have associated with the key +// pair. +message PlaybackKeyPair { + // The id of the key pair. + Ulid id = 1; + + // The fingerprint of the public key. + string fingerprint = 2; + + // The time the key pair was created. + int64 created_at = 3; + + // The time the key pair was last updated. + int64 updated_at = 4; + + // The tags of the key pair. + map tags = 5; +} diff --git a/proto/scuffle/video/v1/types/playback_session.proto b/proto/scuffle/video/v1/types/playback_session.proto new file mode 100644 index 00000000..bb08a9c8 --- /dev/null +++ b/proto/scuffle/video/v1/types/playback_session.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; + +// A playback session represents a user's playback of a recording. +// Playback sessions are created when a user starts playing a recording or +// starts watching a live stream. Playback sessions are automatically deleted +// when they become inactive for a certain amount of time or when the live +// stream ends. +message PlaybackSession { + // The unique ID of the playback session. + Ulid id = 1; + + // The target of the playback session. + oneof target { + // The name of the room the playback session is for. + Ulid room_id = 2; + // Otherwise, the ID of the recording the playback session is for. + Ulid recording_id = 3; + } + + // The ID of the user that created the playback session. + // If the user was authenticated, this will be their user ID. + optional string user_id = 4; + + // The name of the playback key pair used to sign the token for the playback + // session. If the user was not authenticated, this will be null. + optional Ulid playback_key_pair_id = 5; + + // If the session was issued using a playback key pair, this is the time the session was issued. + optional int64 issued_at = 6; + + // When this playback session was created. + int64 created_at = 7; + + // When this playback session was last active. + int64 last_active_at = 8; + + // The IP address of the user that created the playback session. + string ip_address = 9; + + // The user agent of the user that created the playback session. + optional string user_agent = 10; + + // The referer of the user that created the playback session. + optional string referer = 11; + + // The origin of the user that created the playback session. + optional string origin = 12; + + enum Device { UNKNOWN_DEVICE = 0; } + + // The device of the user that created the playback session. + Device device = 13; + + enum Platform { UNKNOWN_PLATFORM = 0; } + + // The platform of the user that created the playback session. + Platform platform = 14; + + enum Browser { UNKNOWN_BROWSER = 0; } + + // The browser of the user that created the playback session. + Browser browser = 15; + + // The version of the player used to create the playback session. + optional string player_version = 16; +} diff --git a/proto/scuffle/video/v1/types/playback_session_ids.proto b/proto/scuffle/video/v1/types/playback_session_ids.proto new file mode 100644 index 00000000..062d764c --- /dev/null +++ b/proto/scuffle/video/v1/types/playback_session_ids.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +// A list of ids for playback sessions or users +message PlaybackSessionIds { + // A list of ids for playback sessions or users + repeated string ids = 1; + + // The possible types of ids + enum Type { + Session = 0; + User = 1; + } + + // The type of ids + Type type = 2; +} diff --git a/proto/scuffle/video/v1/types/playback_session_target.proto b/proto/scuffle/video/v1/types/playback_session_target.proto new file mode 100644 index 00000000..55b95e4f --- /dev/null +++ b/proto/scuffle/video/v1/types/playback_session_target.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; + +// PlaybackSessionTarget is a union type that can be either a room name or a +// recording id +message PlaybackSessionTarget { + // Either a room name or recording id + oneof target { + Ulid room_id = 1; + Ulid recording_id = 2; + } +} \ No newline at end of file diff --git a/proto/scuffle/video/v1/types/recording.proto b/proto/scuffle/video/v1/types/recording.proto new file mode 100644 index 00000000..a1193c40 --- /dev/null +++ b/proto/scuffle/video/v1/types/recording.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/rendition.proto"; + +// A recording is a collection of renditions of a recorded room. +// It created when a room is goes live using and contains a recording +// configuration. and is deleted manually or by a lifecycle policy. +message Recording { + // Unique ID of the recording + Ulid id = 1; + + // The name of the room that was recorded + Ulid room_id = 2; + + // The name of the recording configuration that was used + Ulid recording_config_id = 3; + + // The renditions of the recording + // If a lifecycle policy has removed some of the renditions, they will not be + // included here. If a lifecycle policy has removed all renditions this + // recording object will be deleted. + repeated Rendition renditions = 4; + + // The size of the recording in bytes + // If the recording is not finished, this will be the current size of the + // recording that is saved. + int64 byte_size = 5; + + // The duration of the recording in seconds + // If the recording is not finished, this will be the current duration + // of the recording that is saved. + float duration = 6; + + // The time the recording was created + int64 created_at = 7; + + // When the recording was last modified + int64 updated_at = 8; + + // The time the recording was finished + optional int64 ended_at = 9; +} diff --git a/proto/scuffle/video/v1/types/recording_config.proto b/proto/scuffle/video/v1/types/recording_config.proto new file mode 100644 index 00000000..475927cc --- /dev/null +++ b/proto/scuffle/video/v1/types/recording_config.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/rendition.proto"; +import "scuffle/video/v1/types/recording_lifecycle_policy.proto"; + +// A recording config is used to define how rooms are recorded. +// It contains a list of renditions, a list of life cycle policies, +// and a thumbnail interval. The thumbnail interval is the number of +// seconds between each thumbnail. The life cycle policies are used +// to define when a recording should be deleted. The renditions are +// used to define the different renditions that should be saved. +message RecordingConfig { + // The id of the recording config. + Ulid id = 1; + + // The renditions that should be saved. + // If no renditions are provided, the recording + // will not be saved. + repeated Rendition renditions = 2; + + // The life cycle policies that should be used. + // If no life cycle policies are provided, the recording + // will never be deleted. + repeated RecordingLifecyclePolicy lifecycle_policies = 3; + + // The time the recording config was created. + // This is a unix timestamp in nanoseconds. + int64 created_at = 4; + + // The time the recording config was last updated. + // This is a unix timestamp in nanoseconds. + int64 updated_at = 5; + + // The tags associated with the recording config. + map tags = 6; +} diff --git a/proto/scuffle/video/v1/types/recording_lifecycle_policy.proto b/proto/scuffle/video/v1/types/recording_lifecycle_policy.proto new file mode 100644 index 00000000..b67d610a --- /dev/null +++ b/proto/scuffle/video/v1/types/recording_lifecycle_policy.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/rendition.proto"; + +// A policy that can be applied to a recording. +// Defines what should happen to the recording after a certain number of days. +// If no policy is applied, the recording will be kept indefinitely. +message RecordingLifecyclePolicy { + // The number of days after which the policy should be applied. + int32 after_days = 1; + + // Enum of possible actions to perform after the specified number of days. + enum Action { DELETE = 0; } + + // The action to perform after the specified number of days. + Action action = 2; + + // The renditions to apply the policy to. + // If empty, the policy applies to no renditions. + // At least one rendition must be specified for either video or audio. + repeated Rendition renditions = 3; +} diff --git a/proto/scuffle/video/v1/types/rendition.proto b/proto/scuffle/video/v1/types/rendition.proto new file mode 100644 index 00000000..2cb37b5a --- /dev/null +++ b/proto/scuffle/video/v1/types/rendition.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +// Rendition is the type of rendition of the video. +enum Rendition { + // VIDEO_SOURCE is the original video file that was streamed. + VIDEO_SOURCE = 0; + // VIDEO_HD is the high definition rendition of the video. + VIDEO_HD = 1; + // VIDEO_SD is the standard definition rendition of the video. + VIDEO_SD = 2; + // VIDEO_LD is the low definition rendition of the video. + VIDEO_LD = 3; + + // AUDIO_SOURCE is the original audio file that was streamed. + AUDIO_SOURCE = 4; +} diff --git a/proto/scuffle/video/v1/types/room.proto b/proto/scuffle/video/v1/types/room.proto new file mode 100644 index 00000000..8fa0c3bc --- /dev/null +++ b/proto/scuffle/video/v1/types/room.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/room_status.proto"; +import "scuffle/video/v1/types/video_config.proto"; +import "scuffle/video/v1/types/audio_config.proto"; + +// A room is a container for a live stream. It contains information about the +// stream, such as the stream key, the transcoding and recording configurations +// to use, and when the stream was last live or ended. +message Room { + // The name of the room. + Ulid id = 1; + + // The name of the transcoding configuration to use for the room. + optional Ulid transcoding_config_id = 2; + + // The name of the recording configuration to use for the room. + optional Ulid recording_config_id = 3; + + // The room status + RoomStatus status = 4; + + // Whether or not the room is currently private. + bool private = 5; + + // The stream key for the room. + string stream_key = 6; + + // The video input of the room session. + // This is reported by the ingest server. + optional VideoConfig video_input = 7; + // The audio input of the room session. + // This is reported by the ingest server. + optional AudioConfig audio_input = 8; + + // The video outputs of the room session. + // This is reported by the transcode server. + repeated VideoConfig video_output = 9; + + // The audio outputs of the room session. + // This is reported by the transcode server. + repeated AudioConfig audio_output = 10; + + // The current connection id of the room session. + optional Ulid active_connection_id = 11; + + // The current recording id of the room session. + optional Ulid active_recording_id = 12; + + // The time the room was created. + // This is a Unix timestamp in nanoseconds. + int64 created_at = 13; + + // The time the room was last updated. + // This is a Unix timestamp in nanoseconds. + int64 updated_at = 14; + + // The time the room was last live. + // This is a Unix timestamp in nanoseconds. + optional int64 last_live_at = 15; + + // The time the room was last ended. + // This is a Unix timestamp in nanoseconds. + optional int64 last_disconnected_at = 16; + + // The tags associated with the room. + map tags = 17; +} diff --git a/proto/scuffle/video/v1/types/room_status.proto b/proto/scuffle/video/v1/types/room_status.proto new file mode 100644 index 00000000..70134b2b --- /dev/null +++ b/proto/scuffle/video/v1/types/room_status.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +enum RoomStatus { + ROOM_STATUS_OFFLINE = 0; + ROOM_STATUS_WAITING_FOR_TRANSCODER = 1; + ROOM_STATUS_READY = 2; +} diff --git a/proto/scuffle/video/v1/types/transcoding_config.proto b/proto/scuffle/video/v1/types/transcoding_config.proto new file mode 100644 index 00000000..3eacdb1d --- /dev/null +++ b/proto/scuffle/video/v1/types/transcoding_config.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/ulid.proto"; +import "scuffle/video/v1/types/rendition.proto"; + +// A TranscodingConfig defines how a stream should be transcoded. +// By providing a rendition list you can define the output renditions. +message TranscodingConfig { + // The name of the transcoding config. + Ulid id = 1; + + // The renditions to be transcoded. + repeated Rendition renditions = 2; + + // The time the transcoding config was created. + int64 created_at = 3; + + // The time the transcoding config was last updated. + int64 updated_at = 4; + + // The tags associated with the transcoding config. + map tags = 5; +} diff --git a/proto/scuffle/video/v1/types/ulid.proto b/proto/scuffle/video/v1/types/ulid.proto new file mode 100644 index 00000000..d746a945 --- /dev/null +++ b/proto/scuffle/video/v1/types/ulid.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +// A ULID is a 128-bit universally unique identifier. +// Similar to UUIDs, but time sortable. +message Ulid { + uint64 msb = 1; + uint64 lsb = 2; +} diff --git a/proto/scuffle/video/v1/types/video_config.proto b/proto/scuffle/video/v1/types/video_config.proto new file mode 100644 index 00000000..bb7a8ed2 --- /dev/null +++ b/proto/scuffle/video/v1/types/video_config.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package scuffle.video.v1.types; + +import "scuffle/video/v1/types/rendition.proto"; + +// A video configuration contains a friendly name, as well as the +// bitrate, fps, height, width and codec of the video. +message VideoConfig { + // The rendition of the video configuration. + Rendition rendition = 1; + // The bitrate of the video. + int64 bitrate = 2; + // The fps of the video. + int32 fps = 3; + // The height of the video. + int32 height = 4; + // The width of the video. + int32 width = 5; + // The codec of the video. + string codec = 6; +} diff --git a/proto/src/ext.rs b/proto/src/ext.rs new file mode 100644 index 00000000..2fd88b90 --- /dev/null +++ b/proto/src/ext.rs @@ -0,0 +1,38 @@ +use crate::scuffle::video::v1::types::Ulid; + +pub trait UlidExt { + fn to_ulid(&self) -> ulid::Ulid; + fn to_uuid(&self) -> uuid::Uuid { + self.to_ulid().into() + } +} + +impl UlidExt for Ulid { + fn to_ulid(&self) -> ulid::Ulid { + ulid::Ulid::from((self.msb, self.lsb)) + } +} + +impl UlidExt for Option { + fn to_ulid(&self) -> ulid::Ulid { + match self { + Some(ulid) => ulid.to_ulid(), + None => ulid::Ulid::nil(), + } + } +} + +impl From for Ulid { + fn from(uuid: uuid::Uuid) -> Self { + let (msb, lsb) = uuid.as_u64_pair(); + Self { msb, lsb } + } +} + +impl From for Ulid { + fn from(uuid: ulid::Ulid) -> Self { + let msb = (uuid.0 >> 64) as u64; + let lsb = uuid.0 as u64; + Self { msb, lsb } + } +} diff --git a/proto/src/lib.rs b/proto/src/lib.rs new file mode 100644 index 00000000..6ad93638 --- /dev/null +++ b/proto/src/lib.rs @@ -0,0 +1,14 @@ +#![allow(clippy::all)] +#![allow(unused_imports)] +#![allow(unused_qualifications)] +#![allow(unused_variables)] +#![allow(dead_code)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] +#![allow(unused_mut)] +#![allow(unused_parens)] + +include!(concat!(env!("OUT_DIR"), "/module.rs")); + +pub mod ext; diff --git a/schema.graphql b/schema.graphql index 61e1b234..69e883df 100644 --- a/schema.graphql +++ b/schema.graphql @@ -174,6 +174,7 @@ extend schema "@external" "@provides" "@requires" + "@composeDirective" ] ) directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/video/api/Cargo.toml b/video/api/Cargo.toml new file mode 100644 index 00000000..c3feb028 --- /dev/null +++ b/video/api/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "video-api" +version = "0.0.1" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1.29.1", features = ["full"] } +tracing = "0.1.37" +anyhow = "1.0.72" +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +sqlx-postgres = "0.7.1" +tonic = { version = "0.9.2", features = ["tls"] } +prost = "0.11.9" +uuid = { version = "1.4.1", features = ["v4"] } +serde = { version = "1.0.183", features = ["derive"] } +fred = { version = "6.3.0", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } +chrono = { version = "0.4.26", default-features = false, features = ["serde", "clock"] } +tokio-stream = { version = "0.1.14", features = ["sync"] } +async-stream = "0.3.5" +futures = "0.3.28" +futures-util = "0.3.28" +bytes = "1.4.0" +async-graphql = { version = "6.0.1", default-features = false, features = ["dataloader"] } +async-trait = "0.1.72" +jwt = "0.16.0" +hmac = "0.12.1" +sha2 = "0.10.7" +rand = "0.8.5" + +common = { workspace = true, features = ["default"] } +config = { workspace = true } +pb = { workspace = true } +video-database = { workspace = true } diff --git a/video/api/LICENSE.md b/video/api/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/video/api/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/video/api/src/config.rs b/video/api/src/config.rs new file mode 100644 index 00000000..7d304c59 --- /dev/null +++ b/video/api/src/config.rs @@ -0,0 +1,108 @@ +use std::net::SocketAddr; + +use anyhow::Result; +use common::config::{LoggingConfig, RedisConfig, TlsConfig}; + +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +/// The API is the backend for the Scuffle service +pub struct AppConfig { + /// The path to the config file + pub config_file: Option, + + /// Name of this instance + pub name: String, + + /// The logging config + pub logging: LoggingConfig, + + /// Database Config + pub database: DatabaseConfig, + + /// GRPC Config + pub grpc: GrpcConfig, + + /// Redis configuration + pub redis: RedisConfig, + + /// JWT secret used for access tokens + pub jwt_secret: String, +} + +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +pub struct ApiConfig { + /// Bind address for the API + pub bind_address: SocketAddr, + + /// If we should use TLS for the API server + pub tls: Option, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + bind_address: "[::]:4000".parse().expect("failed to parse bind address"), + tls: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +pub struct DatabaseConfig { + /// The database URL to use + pub uri: String, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + uri: "postgres://root@localhost:5432/scuffle_dev".to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +pub struct GrpcConfig { + /// Bind address for the GRPC server + pub bind_address: SocketAddr, + + /// If we should use TLS for the gRPC server + pub tls: Option, +} + +impl Default for GrpcConfig { + fn default() -> Self { + Self { + bind_address: "[::]:50051".parse().expect("failed to parse bind address"), + tls: None, + } + } +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + config_file: Some("config".to_string()), + name: "scuffle-api".to_string(), + logging: LoggingConfig::default(), + database: DatabaseConfig::default(), + grpc: GrpcConfig::default(), + redis: RedisConfig::default(), + jwt_secret: "secret".to_string(), + } + } +} + +impl AppConfig { + pub fn parse() -> Result { + let (mut config, config_file) = + common::config::parse::(!cfg!(test), Self::default().config_file)?; + + config.config_file = config_file; + + Ok(config) + } +} diff --git a/video/api/src/global/mod.rs b/video/api/src/global/mod.rs new file mode 100644 index 00000000..3b0b8862 --- /dev/null +++ b/video/api/src/global/mod.rs @@ -0,0 +1,128 @@ +use crate::config::AppConfig; +use std::sync::Arc; +use std::time::Duration; + +use async_graphql::dataloader::DataLoader; +use common::context::Context; +use common::prelude::FutureTimeout; +use fred::native_tls; +use fred::pool::RedisPool; +use fred::types::{RedisConfig, ServerConfig}; +use video_database::dataloader; + +pub struct GlobalState { + pub config: AppConfig, + pub db: Arc, + pub ctx: Context, + pub redis: RedisPool, + + pub access_token_by_name_loader: DataLoader, + pub access_token_used_by_name_updater: + DataLoader, +} + +impl GlobalState { + pub fn new(config: AppConfig, db: Arc, redis: RedisPool, ctx: Context) -> Self { + Self { + config, + ctx, + redis, + access_token_by_name_loader: DataLoader::new( + dataloader::access_token::AccessTokenByNameLoader::new(db.clone()), + tokio::spawn, + ), + access_token_used_by_name_updater: DataLoader::new( + dataloader::access_token::AccessTokenUsedByNameUpdater::new(db.clone()), + tokio::spawn, + ), + db, + } + } +} + +pub fn redis_config(config: &AppConfig) -> RedisConfig { + RedisConfig { + database: Some(config.redis.database), + username: config.redis.username.clone(), + password: config.redis.password.clone(), + server: if let Some(sentinel) = &config.redis.sentinel { + let addresses = config + .redis + .addresses + .iter() + .map(|a| { + let mut parts = a.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + (host, port) + }) + .collect::>(); + + ServerConfig::new_sentinel(addresses, sentinel.service_name.clone()) + } else { + let server = config.redis.addresses.first().expect("no redis addresses"); + if config.redis.addresses.len() > 1 { + tracing::warn!("multiple redis addresses, only using first: {}", server); + } + + let mut parts = server.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + ServerConfig::new_centralized(host, port) + }, + tls: if let Some(tls) = &config.redis.tls { + let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); + let key = std::fs::read(&tls.key).expect("failed to read redis key"); + let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); + + Some( + fred::native_tls::TlsConnector::builder() + .identity( + native_tls::Identity::from_pkcs8(&cert, &key) + .expect("failed to parse redis cert/key"), + ) + .add_root_certificate( + native_tls::Certificate::from_pem(&ca_cert) + .expect("failed to parse redis ca"), + ) + .build() + .expect("failed to build redis tls") + .into(), + ) + } else { + None + }, + ..Default::default() + } +} + +pub async fn setup_redis(config: &AppConfig) -> RedisPool { + let redis = RedisPool::new( + redis_config(config), + Some(Default::default()), + Some(Default::default()), + config.redis.pool_size, + ) + .expect("failed to create redis pool"); + + redis.connect(); + + redis + .wait_for_connect() + .timeout(Duration::from_secs(2)) + .await + .expect("failed to connect to redis") + .expect("failed to connect to redis"); + + redis +} diff --git a/video/api/src/grpc/events.rs b/video/api/src/grpc/events.rs new file mode 100644 index 00000000..c41db52d --- /dev/null +++ b/video/api/src/grpc/events.rs @@ -0,0 +1,46 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status, Streaming}; + +use pb::scuffle::video::v1::{ + events_server::{Events, EventsServer as EventsService}, + EventsSubscribeRequest, EventsSubscribeResponse, +}; + +type Result = std::result::Result; + +/// The Events service provides a stream of events that occur in the system. +pub struct EventsServer { + global: Weak, +} + +impl EventsServer { + pub fn new(global: &Arc) -> EventsService { + EventsService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl Events for EventsServer { + /// Server streaming response type for the Subscribe method. + type SubscribeStream = Pin> + Send>>; + + /// Subscribe to events. The client should send an `OnOpen` event to + /// indicate that it is ready to receive events. The server will respond + /// with Events. The client should send an `AckEvent` event to indicate + /// that it has processed the event. If the client does not send an + /// `AckEvent` event, the server will resend the event after a timeout. + async fn subscribe( + &self, + _request: Request>, + ) -> Result> { + todo!("TODO: implement Events::subscribe") + } +} diff --git a/video/api/src/grpc/health.rs b/video/api/src/grpc/health.rs new file mode 100644 index 00000000..cb38c474 --- /dev/null +++ b/video/api/src/grpc/health.rs @@ -0,0 +1,75 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status}; + +use pb::grpc::health::v1::{ + health_check_response::ServingStatus, + health_server::{Health, HealthServer as HealthService}, + HealthCheckRequest, HealthCheckResponse, +}; + +pub struct HealthServer { + global: Weak, +} + +impl HealthServer { + pub fn new(global: &Arc) -> HealthService { + HealthService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +type Result = std::result::Result; + +#[async_trait] +impl Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + let serving = self + .global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + Ok(Response::new(HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + })) + } + + async fn watch(&self, _: Request) -> Result> { + let global = self.global.clone(); + + let output = try_stream!({ + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let serving = global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + yield HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + }; + } + }); + + Ok(Response::new(Box::pin(output))) + } +} diff --git a/video/api/src/grpc/mod.rs b/video/api/src/grpc/mod.rs new file mode 100644 index 00000000..a6397cff --- /dev/null +++ b/video/api/src/grpc/mod.rs @@ -0,0 +1,60 @@ +use crate::global::GlobalState; +use anyhow::Result; +use std::sync::Arc; +use tokio::select; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; + +mod events; +mod health; +mod playback_key_pair; +mod playback_session; +mod recording; +mod recording_config; +mod room; +mod transcoder_config; + +mod utils; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("GRPC Listening on {}", global.config.grpc.bind_address); + + let server = if let Some(tls) = &global.config.grpc.tls { + let cert = tokio::fs::read(&tls.cert).await?; + let key = tokio::fs::read(&tls.key).await?; + let ca_cert = tokio::fs::read(&tls.ca_cert).await?; + tracing::info!("gRPC TLS enabled"); + Server::builder().tls_config( + ServerTlsConfig::new() + .identity(Identity::from_pem(cert, key)) + .client_ca_root(Certificate::from_pem(ca_cert)), + )? + } else { + tracing::info!("gRPC TLS disabled"); + Server::builder() + } + .add_service(room::RoomServer::new(&global)) + .add_service(health::HealthServer::new(&global)) + .add_service(playback_key_pair::PlaybackKeyPairServer::new(&global)) + .add_service(playback_session::PlaybackSessionServer::new(&global)) + .add_service(recording::RecordingServer::new(&global)) + .add_service(recording_config::RecordingConfigServer::new(&global)) + .add_service(transcoder_config::TranscoderConfigServer::new(&global)) + .add_service(events::EventsServer::new(&global)) + .serve_with_shutdown(global.config.grpc.bind_address, async { + global.ctx.done().await; + }); + + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = server => { + if let Err(r) = r { + tracing::error!("gRPC server failed: {:?}", r); + return Err(r.into()); + } + }, + } + + Ok(()) +} diff --git a/video/api/src/grpc/playback_key_pair.rs b/video/api/src/grpc/playback_key_pair.rs new file mode 100644 index 00000000..8131c996 --- /dev/null +++ b/video/api/src/grpc/playback_key_pair.rs @@ -0,0 +1,54 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + playback_key_pair_server::{PlaybackKeyPair, PlaybackKeyPairServer as PlaybackKeyPairService}, + PlaybackKeyPairDeleteRequest, PlaybackKeyPairDeleteResponse, PlaybackKeyPairGetRequest, + PlaybackKeyPairGetResponse, PlaybackKeyPairModifyRequest, PlaybackKeyPairModifyResponse, +}; + +type Result = std::result::Result; + +/// PlaybackKeyPair is a service for managing playback key pairs. +/// Playback key pairs are used to authenticate playback requests. +/// They are used to ensure that only authorized users can view a stream. +pub struct PlaybackKeyPairServer { + global: Weak, +} + +impl PlaybackKeyPairServer { + pub fn new(global: &Arc) -> PlaybackKeyPairService { + PlaybackKeyPairService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl PlaybackKeyPair for PlaybackKeyPairServer { + /// Modifys a new playback key pair, or updates an existing one. + async fn modify( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackKeyPair::modify") + } + + /// Gets playback key pairs. + async fn get( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackKeyPair::get") + } + + /// Deletes playback key pairs. + async fn delete( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackKeyPair::delete") + } +} diff --git a/video/api/src/grpc/playback_session.rs b/video/api/src/grpc/playback_session.rs new file mode 100644 index 00000000..30c1a797 --- /dev/null +++ b/video/api/src/grpc/playback_session.rs @@ -0,0 +1,53 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + playback_session_server::{PlaybackSession, PlaybackSessionServer as PlaybackSessionService}, + PlaybackSessionCountRequest, PlaybackSessionCountResponse, PlaybackSessionGetRequest, + PlaybackSessionGetResponse, PlaybackSessionRevokeRequest, PlaybackSessionRevokeResponse, +}; + +type Result = std::result::Result; + +/// PlaybackSession is a session representing a user watching a video. +/// This is useful for analytics and for revoking playback sessions. +pub struct PlaybackSessionServer { + global: Weak, +} + +impl PlaybackSessionServer { + pub fn new(global: &Arc) -> PlaybackSessionService { + PlaybackSessionService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl PlaybackSession for PlaybackSessionServer { + /// Get returns playback sessions for a target or for users, or direct ids. + async fn get( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackSession::get") + } + + /// Revoke revokes playback sessions for a target or for users, or direct ids. + async fn revoke( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackSession::revoke") + } + + /// Count returns the number of playback sessions for a target. + async fn count( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement PlaybackSession::count") + } +} diff --git a/video/api/src/grpc/recording.rs b/video/api/src/grpc/recording.rs new file mode 100644 index 00000000..a0fe775b --- /dev/null +++ b/video/api/src/grpc/recording.rs @@ -0,0 +1,54 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + recording_server::{Recording, RecordingServer as RecordingService}, + RecordingDeleteRequest, RecordingDeleteResponse, RecordingGetRequest, RecordingGetResponse, + RecordingModifyRequest, RecordingModifyResponse, +}; + +type Result = std::result::Result; + +/// A recording is a video that was recorded in a room. +/// It can be public or private and it is managed by lifecycle policies. +/// You can start recording rooms by attaching a RecordingConfig to a room. +pub struct RecordingServer { + global: Weak, +} + +impl RecordingServer { + pub fn new(global: &Arc) -> RecordingService { + RecordingService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl Recording for RecordingServer { + /// Modify recordings. + async fn modify( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement Recording::modify") + } + + /// Get recordings. + async fn get( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement Recording::get") + } + + /// Delete recordings. + async fn delete( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement Recording::delete") + } +} diff --git a/video/api/src/grpc/recording_config.rs b/video/api/src/grpc/recording_config.rs new file mode 100644 index 00000000..dd7b51db --- /dev/null +++ b/video/api/src/grpc/recording_config.rs @@ -0,0 +1,56 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + recording_config_server::{RecordingConfig, RecordingConfigServer as RecordingConfigService}, + RecordingConfigDeleteRequest, RecordingConfigDeleteResponse, RecordingConfigGetRequest, + RecordingConfigGetResponse, RecordingConfigModifyRequest, RecordingConfigModifyResponse, +}; + +type Result = std::result::Result; + +/// RecordingConfig is the service for managing recording configs. +/// Recording configs are used to determine what renditions to record for a +/// stream. They also allow you to set lifecycle policies for the recordings. +/// Recording configs are applied to rooms via the room's recording_config_name. +/// If a room does not have a recording_config_name, it will not be recorded. +pub struct RecordingConfigServer { + global: Weak, +} + +impl RecordingConfigServer { + pub fn new(global: &Arc) -> RecordingConfigService { + RecordingConfigService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl RecordingConfig for RecordingConfigServer { + /// Modify or update a recording config. + async fn modify( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement RecordingConfig::modify") + } + + /// Get recording configs. + async fn get( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement RecordingConfig::get") + } + + /// Delete recording configs. + async fn delete( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement RecordingConfig::delete") + } +} diff --git a/video/api/src/grpc/room.rs b/video/api/src/grpc/room.rs new file mode 100644 index 00000000..d24ac405 --- /dev/null +++ b/video/api/src/grpc/room.rs @@ -0,0 +1,182 @@ +use crate::global::GlobalState; +use std::{ + collections::HashMap, + sync::{Arc, Weak}, +}; + +use sqlx::FromRow; +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + room_server::{Room as RoomServiceTrait, RoomServer as RoomService}, + types::{ + access_token_scope::{Permission, Resource}, + ModifyMode, + }, + RoomDeleteRequest, RoomDeleteResponse, RoomDisconnectRequest, RoomDisconnectResponse, + RoomGetRequest, RoomGetResponse, RoomModifyRequest, RoomModifyResponse, RoomResetKeyRequest, + RoomResetKeyResponse, +}; +use video_database::{dataloader::IdNamePair, room::Room}; + +use super::utils::{get_global, validate_auth_request, AccessTokenExt, HandleInternalError}; + +type Result = std::result::Result; + +mod utils; + +#[cfg(test)] +mod tests; + +/// Room allows you to create, update, get, disconnect, reset key, and delete +/// rooms. A room is a live stream that can be published to and viewed. Rooms can +/// be configured with a transcoding config, recording config, to define how the +/// stream is transcoded and recorded. +pub struct RoomServer { + global: Weak, +} + +impl RoomServer { + pub fn new(global: &Arc) -> RoomService { + RoomService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl RoomServiceTrait for RoomServer { + /// Modify allows you to create a new room or update an existing room. + async fn modify( + &self, + request: Request, + ) -> Result> { + let global = get_global(&self.global)?; + + let access_token = validate_auth_request(&global, &request).await?; + + access_token.has_scope((Resource::Room, Permission::Modify))?; + + let request = request.into_inner(); + + let mut query_builder = utils::room_modify_query(&request, &access_token)?; + let room = Room::from_row( + &query_builder + .build() + .fetch_one(global.db.as_ref()) + .await + .map_err(|e| { + if let Some(e) = e.as_database_error() { + if e.is_unique_violation() { + // Is name unique violation + return Status::already_exists("room name already exists"); + } + + if let Some(constraint) = e.constraint() { + match constraint { + "fk_room_transcoding_config" => { + return Status::not_found("transcoding config not found"); + } + "fk_room_recording_config" => { + return Status::not_found("recording config not found"); + } + "fk_room_organization" => { + return Status::not_found("organization not found"); + } + _ => {} + } + } + } + + tracing::error!(error = %e, "failed to modify room"); + Status::internal("failed to modify room") + })?, + ) + .to_grpc()?; + + let created = match request.mode() { + ModifyMode::Create => true, + ModifyMode::Update => false, + ModifyMode::Upsert => room.created_at == room.updated_at, + }; + + Ok(Response::new(RoomModifyResponse { + room: Some(room.into_proto()), + created, + })) + } + + /// Get allows you to get rooms. + async fn get(&self, request: Request) -> Result> { + let global = get_global(&self.global)?; + + let access_token = validate_auth_request(&global, &request).await?; + + access_token.has_scope((Resource::Room, Permission::Read))?; + + let request = request.into_inner(); + + let mut query_builder = utils::room_get_query(&request, &access_token)?; + + let rooms = query_builder + .build() + .fetch_all(global.db.as_ref()) + .await + .to_grpc()? + .iter() + .map(|r| Room::from_row(r).map(|r| r.into_proto())) + .collect::>>() + .to_grpc()?; + + Ok(Response::new(RoomGetResponse { rooms })) + } + + /// Disconnect allows you to disconnect a currently live room. + async fn disconnect( + &self, + request: Request, + ) -> Result> { + todo!("TODO: implement Room::disconnect") + } + + /// ResetKey allows you to reset the key for a room. + async fn reset_key( + &self, + request: Request, + ) -> Result> { + let global = get_global(&self.global)?; + + let access_token = validate_auth_request(&global, &request).await?; + + access_token.has_scope((Resource::Room, Permission::Modify))?; + + let request = request.into_inner(); + + let mut query_builder = utils::room_reset_key_query(&request, &access_token)?; + + let rooms = query_builder + .build() + .fetch_all(global.db.as_ref()) + .await + .to_grpc()? + .iter() + .map(Room::from_row) + .collect::>>() + .to_grpc()?; + + Ok(Response::new(RoomResetKeyResponse { + room_keys: rooms + .into_iter() + .map(|r| (r.name, r.stream_key)) + .collect::>(), + })) + } + + /// Delete allows you to delete rooms. + async fn delete( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement Room::delete") + } +} diff --git a/video/api/src/grpc/room/tests.rs b/video/api/src/grpc/room/tests.rs new file mode 100644 index 00000000..90303ce5 --- /dev/null +++ b/video/api/src/grpc/room/tests.rs @@ -0,0 +1,814 @@ +use pb::scuffle::video::v1::{ + types::ModifyMode, RoomGetRequest, RoomModifyRequest, RoomResetKeyRequest, +}; +use sqlx::Execute; +use uuid::Uuid; +use video_database::access_token::AccessToken; + +use crate::grpc::room::utils::room_reset_key_query; + +use super::utils::{room_get_query, room_modify_query}; + +#[test] +fn test_room_modify_query_no_fields() { + let mut request = RoomModifyRequest { + name: "test".to_string(), + mode: ModifyMode::Update.into(), + ..Default::default() + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let error = match room_modify_query(&request, &access_token) { + Err(error) => error, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "no fields to update, please specify at least one field to update" + ); + + request.mode = ModifyMode::Create.into(); + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!( + query.sql(), + "INSERT INTO room (organization_id, name) VALUES ($1, $2) RETURNING *" + ); + + request.mode = ModifyMode::Upsert.into(); + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!( + query.sql(), + "INSERT INTO room (organization_id, name) VALUES ($1, $2) ON CONFLICT (organization_id, name) DO UPDATE SET updated_at = NOW() RETURNING *" + ); +} + +#[test] +fn test_room_modify_query_1_field() { + let cases = vec![ + ( + ModifyMode::Update, + "UPDATE room SET private = $1, updated_at = NOW() WHERE organization_id = $2 AND name = $3 RETURNING *" + ), + ( + ModifyMode::Create, + "INSERT INTO room (organization_id, name, private) VALUES ($1, $2, $3) RETURNING *" + ), + ( + ModifyMode::Upsert, + "INSERT INTO room (organization_id, name, private) VALUES ($1, $2, $3) ON CONFLICT (organization_id, name) DO UPDATE SET private = EXCLUDED.private, updated_at = NOW() RETURNING *" + ), + ]; + + for (mode, expected) in cases { + let request = RoomModifyRequest { + mode: mode as i32, + name: "test".to_string(), + private: Some(true), + recording_config_name: None, + transcoding_config_name: None, + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!(query.sql(), expected); + } +} + +#[test] +fn test_room_modify_query_2_fields() { + let cases = vec![ + ( + ModifyMode::Update, + "UPDATE room SET recording_config_name = $1, private = $2, updated_at = NOW() WHERE organization_id = $3 AND name = $4 RETURNING *" + ), + ( + ModifyMode::Create, + "INSERT INTO room (organization_id, name, recording_config_name, private) VALUES ($1, $2, $3, $4) RETURNING *" + ), + ( + ModifyMode::Upsert, + "INSERT INTO room (organization_id, name, recording_config_name, private) VALUES ($1, $2, $3, $4) ON CONFLICT (organization_id, name) DO UPDATE SET recording_config_name = EXCLUDED.recording_config_name, private = EXCLUDED.private, updated_at = NOW() RETURNING *" + ), + ]; + + for (mode, expected) in cases { + let request = RoomModifyRequest { + mode: mode as i32, + name: "test".to_string(), + private: Some(true), + recording_config_name: Some("test".to_string()), + transcoding_config_name: None, + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!(query.sql(), expected); + } +} + +#[test] +fn test_room_modify_query_all_fields() { + let cases = vec![ + ( + ModifyMode::Update, + "UPDATE room SET transcoding_config_name = $1, recording_config_name = $2, private = $3, updated_at = NOW() WHERE organization_id = $4 AND name = $5 RETURNING *" + ), + ( + ModifyMode::Create, + "INSERT INTO room (organization_id, name, transcoding_config_name, recording_config_name, private) VALUES ($1, $2, $3, $4, $5) RETURNING *" + ), + ( + ModifyMode::Upsert, + "INSERT INTO room (organization_id, name, transcoding_config_name, recording_config_name, private) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (organization_id, name) DO UPDATE SET transcoding_config_name = EXCLUDED.transcoding_config_name, recording_config_name = EXCLUDED.recording_config_name, private = EXCLUDED.private, updated_at = NOW() RETURNING *" + ), + ]; + + for (mode, expected) in cases { + let request = RoomModifyRequest { + mode: mode as i32, + name: "test".to_string(), + private: Some(true), + recording_config_name: Some("test".to_string()), + transcoding_config_name: Some("test".to_string()), + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!(query.sql(), expected); + } +} + +#[test] +fn test_room_modify_query_invalid_name() { + let request = RoomModifyRequest { + mode: ModifyMode::Create.into(), + name: "..".to_string(), + private: None, + recording_config_name: None, + transcoding_config_name: None, + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let error = match room_modify_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid room name, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); +} + +#[test] +fn test_room_modify_query_invalid_transcoding_name() { + let request = RoomModifyRequest { + mode: ModifyMode::Create.into(), + name: "test".to_string(), + private: None, + recording_config_name: None, + transcoding_config_name: Some("..".to_string()), + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let error = match room_modify_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid transcoding_config_name, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); +} + +#[test] +fn test_room_modify_query_invalid_recording_name() { + let request = RoomModifyRequest { + mode: ModifyMode::Create.into(), + name: "test".to_string(), + private: None, + transcoding_config_name: None, + recording_config_name: Some("..".to_string()), + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let error = match room_modify_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid recording_config_name, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); +} + +#[test] +fn test_room_modify_null_fields() { + let cases = vec![ + ( + ModifyMode::Update, + "UPDATE room SET transcoding_config_name = NULL, recording_config_name = NULL, updated_at = NOW() WHERE organization_id = $1 AND name = $2 RETURNING *" + ), + ( + ModifyMode::Create, + "INSERT INTO room (organization_id, name, transcoding_config_name, recording_config_name) VALUES ($1, $2, NULL, NULL) RETURNING *" + ), + ( + ModifyMode::Upsert, + "INSERT INTO room (organization_id, name, transcoding_config_name, recording_config_name) VALUES ($1, $2, NULL, NULL) ON CONFLICT (organization_id, name) DO UPDATE SET transcoding_config_name = EXCLUDED.transcoding_config_name, recording_config_name = EXCLUDED.recording_config_name, updated_at = NOW() RETURNING *" + ), + ]; + + for (mode, expected) in cases { + let request = RoomModifyRequest { + mode: mode as i32, + name: "test".to_string(), + private: None, + recording_config_name: Some("".into()), + transcoding_config_name: Some("".into()), + }; + + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut qb = room_modify_query(&request, &access_token).unwrap(); + let query = qb.build(); + + assert_eq!(query.sql(), expected); + } +} + +#[test] +fn test_room_get_query_live() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 10, + live: Some(false), + name: vec![], + private: None, + created_at: None, + recording_config_name: None, + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND live = $2 ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_created_at() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 10, + live: None, + name: vec![], + private: None, + created_at: Some(chrono::Utc::now().timestamp_micros()), + recording_config_name: None, + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND created_at > $2 ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_recording_name() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec![], + private: None, + created_at: None, + recording_config_name: Some("test".to_string()), + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND recording_config_name = $2 ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_transcoding_name() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec![], + private: None, + created_at: None, + recording_config_name: None, + transcoding_config_name: Some("test".to_string()), + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND transcoding_config_name = $2 ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_name() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec!["test".to_string()], + private: None, + created_at: None, + recording_config_name: None, + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND name = ANY($2::text[]) ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_private() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec![], + private: Some(true), + created_at: None, + recording_config_name: None, + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND private = $2 ORDER BY created_at LIMIT $3" + ); +} + +#[test] +fn test_room_get_query_nothing_but_limit() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec![], + private: None, + created_at: None, + recording_config_name: None, + transcoding_config_name: None, + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 ORDER BY created_at LIMIT $2" + ); +} + +#[test] +fn test_room_get_query_everything() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: Some(false), + name: vec!["test".to_string()], + private: Some(true), + created_at: Some(chrono::Utc::now().timestamp_micros()), + recording_config_name: Some("test".to_string()), + transcoding_config_name: Some("test".to_string()), + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND name = ANY($2::text[]) AND transcoding_config_name = $3 AND recording_config_name = $4 AND private = $5 AND live = $6 AND created_at > $7 ORDER BY created_at LIMIT $8" + ); +} + +#[test] +fn test_room_get_query_null_names() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest { + limit: 1000, + live: None, + name: vec![], + private: None, + created_at: None, + recording_config_name: Some("".into()), + transcoding_config_name: Some("".into()), + }; + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 AND transcoding_config_name IS NULL AND recording_config_name IS NULL ORDER BY created_at LIMIT $2" + ); +} + +#[test] +fn test_room_get_query_no_limit() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let request = RoomGetRequest::default(); + + let mut qb = room_get_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "SELECT * FROM room WHERE organization_id = $1 ORDER BY created_at LIMIT $2" + ); +} + +#[test] +fn test_room_get_query_invalid_limit() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomGetRequest { + limit: 1001, + live: Some(false), + name: vec!["test".to_string()], + private: Some(true), + created_at: Some(chrono::Utc::now().timestamp_micros()), + recording_config_name: Some("test".to_string()), + transcoding_config_name: Some("test".to_string()), + }; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "limit too large, must be between 1 and 1000" + ); + + request.limit = -1; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "limit too small, must be between 1 and 1000" + ); +} + +#[test] +fn test_room_get_query_invalid_name() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomGetRequest { + name: vec!["".to_string()], + ..Default::default() + }; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid name provided at index 0, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + request.name = vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()]; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid name provided at index 0, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + // Non-ascii characters are not allowed + request.name = vec!["😀".to_string()]; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid name provided at index 0, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + // too many names is not allowed (max 100) + request.name = vec!["test".to_string(); 101]; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!(error.message(), "too many names provided, max 100"); +} + +#[test] +fn test_room_get_query_invalid_recording_transcoding_name() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomGetRequest { + transcoding_config_name: Some("..".into()), + ..Default::default() + }; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid transcoding_config_name provided, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + request.transcoding_config_name = None; + request.recording_config_name = Some("..".into()); + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid recording_config_name provided, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); +} + +#[test] +fn test_room_get_query_invalid_created_at() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomGetRequest { + // The request expects timestamps in microseconds so this will be way into the future + created_at: Some(chrono::Utc::now().timestamp_nanos()), + ..Default::default() + }; + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!(error.message(), "invalid created_at must be in the past"); + + request.created_at = Some(-1); + + let error = match room_get_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!(error.message(), "invalid created_at must be positive"); +} + +#[test] +fn test_room_reset_key_query() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomResetKeyRequest { + names: vec!["test".to_string()], + }; + + let mut qb = room_reset_key_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "UPDATE room as r SET stream_key = v.stream_key, updated_at = NOW() FROM (VALUES ($1, $2)) AS v(name, stream_key) WHERE r.organization_id = $3 AND r.name = v.name RETURNING r.*" + ); + + request.names = vec!["test".to_string(), "test2".to_string()]; + + let mut qb = room_reset_key_query(&request, &access_token).unwrap(); + + let query = qb.build(); + + assert_eq!( + query.sql(), + "UPDATE room as r SET stream_key = v.stream_key, updated_at = NOW() FROM (VALUES ($1, $2), ($3, $4)) AS v(name, stream_key) WHERE r.organization_id = $5 AND r.name = v.name RETURNING r.*" + ); +} + +#[test] +fn test_room_reset_key_query_invalid_names() { + let access_token = AccessToken { + organization_id: Uuid::new_v4(), + ..Default::default() + }; + + let mut request = RoomResetKeyRequest { + names: vec!["".to_string()], + }; + + let error = match room_reset_key_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid room name provided at index 0, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + request.names = vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()]; + + let error = match room_reset_key_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "invalid room name provided at index 0, names must match ^[a-zA-Z0-9_-]{1,32}$" + ); + + // Too many names not allowed (max 100) + + request.names = vec!["test".to_string(); 101]; + + let error = match room_reset_key_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!(error.message(), "too many names provided, max 100"); + + request.names = vec![]; + + let error = match room_reset_key_query(&request, &access_token) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!(error.message(), "no names provided"); +} diff --git a/video/api/src/grpc/room/utils.rs b/video/api/src/grpc/room/utils.rs new file mode 100644 index 00000000..89e8a83d --- /dev/null +++ b/video/api/src/grpc/room/utils.rs @@ -0,0 +1,351 @@ +use chrono::Utc; +use pb::scuffle::video::v1::{ + types::ModifyMode, RoomGetRequest, RoomModifyRequest, RoomResetKeyRequest, +}; +use rand::Rng; +use sqlx::QueryBuilder; +use tonic::Status; +use video_database::access_token::AccessToken; + +pub fn generate_stream_key() -> String { + const VALID_CHARACTERS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + let mut rng = rand::thread_rng(); + + let mut stream_key = String::with_capacity(32); + + for _ in 0..32 { + let random = rng.gen_range(0..VALID_CHARACTERS.len()) as u8; + + stream_key.push(VALID_CHARACTERS.as_bytes()[random as usize] as char); + } + + stream_key +} + +pub fn room_modify_query<'a>( + request: &'a RoomModifyRequest, + access_token: &'a AccessToken, +) -> Result, Status> { + let mut query_builder = QueryBuilder::::default(); + + if !video_database::name::validate(&request.name) { + return Err(Status::invalid_argument(format!( + "invalid room name, names must match {}", + video_database::name::REGEX, + ))); + } + + if let Some(recording_config_name) = &request.recording_config_name { + if !recording_config_name.is_empty() + && !video_database::name::validate(recording_config_name) + { + return Err(Status::invalid_argument(format!( + "invalid recording_config_name, names must match {}", + video_database::name::REGEX, + ))); + } + } + + if let Some(transcoding_config_name) = &request.transcoding_config_name { + if !transcoding_config_name.is_empty() + && !video_database::name::validate(transcoding_config_name) + { + return Err(Status::invalid_argument(format!( + "invalid transcoding_config_name, names must match {}", + video_database::name::REGEX, + ))); + } + } + + match request.mode() { + ModifyMode::Update => { + if request.private.is_none() + && request.recording_config_name.is_none() + && request.transcoding_config_name.is_none() + { + return Err(Status::invalid_argument( + "no fields to update, please specify at least one field to update", + )); + } + + query_builder.push("UPDATE room SET "); + + let mut separated = query_builder.separated(", "); + + if let Some(transcoding_config_name) = &request.transcoding_config_name { + if transcoding_config_name.is_empty() { + separated.push("transcoding_config_name = NULL"); + } else { + separated.push("transcoding_config_name = "); + separated.push_bind_unseparated(transcoding_config_name); + } + } + + if let Some(recording_config_name) = &request.recording_config_name { + if recording_config_name.is_empty() { + separated.push("recording_config_name = NULL"); + } else { + separated.push("recording_config_name = "); + separated.push_bind_unseparated(recording_config_name); + } + } + + if let Some(private) = request.private { + separated.push("private = "); + separated.push_bind_unseparated(private); + } + + separated.push("updated_at = NOW()"); + + query_builder.push(" WHERE organization_id = "); + query_builder.push_bind(access_token.organization_id); + query_builder.push(" AND name = "); + query_builder.push_bind(&request.name); + } + ModifyMode::Create | ModifyMode::Upsert => { + query_builder.push("INSERT INTO room ("); + + let mut separated = query_builder.separated(", "); + + separated.push("organization_id"); + separated.push("name"); + + if request.transcoding_config_name.is_some() { + separated.push("transcoding_config_name"); + } + + if request.recording_config_name.is_some() { + separated.push("recording_config_name"); + } + + if request.private.is_some() { + separated.push("private"); + } + + separated.push_unseparated(") VALUES ("); + + separated.push_bind_unseparated(access_token.organization_id); + separated.push_bind(&request.name); + + if let Some(transcoding_config_name) = &request.transcoding_config_name { + if transcoding_config_name.is_empty() { + separated.push("NULL"); + } else { + separated.push_bind(transcoding_config_name); + } + } + + if let Some(recording_config_name) = &request.recording_config_name { + if recording_config_name.is_empty() { + separated.push("NULL"); + } else { + separated.push_bind(recording_config_name); + } + } + + if let Some(private) = request.private { + separated.push_bind(private); + } + + separated.push_unseparated(")"); + + if request.mode() == ModifyMode::Upsert { + query_builder.push(" ON CONFLICT (organization_id, name) DO UPDATE SET "); + + let mut separated = query_builder.separated(", "); + + if request.transcoding_config_name.is_some() { + separated.push("transcoding_config_name = EXCLUDED.transcoding_config_name"); + } + + if request.recording_config_name.is_some() { + separated.push("recording_config_name = EXCLUDED.recording_config_name"); + } + + if request.private.is_some() { + separated.push("private = EXCLUDED.private"); + } + + separated.push("updated_at = NOW()"); + } + } + } + + query_builder.push(" RETURNING *"); + + Ok(query_builder) +} + +pub fn room_get_query<'a>( + request: &'a RoomGetRequest, + access_token: &'a AccessToken, +) -> Result, Status> { + let mut query_builder = QueryBuilder::::default(); + + query_builder.push("SELECT * FROM room WHERE organization_id = "); + query_builder.push_bind(access_token.organization_id); + + if !request.name.is_empty() { + if request.name.len() > 100 { + return Err(Status::invalid_argument("too many names provided, max 100")); + } + + if let Some(idx) = request + .name + .iter() + .position(|s| !video_database::name::validate(s)) + { + return Err(Status::invalid_argument(format!( + "invalid name provided at index {}, names must match {}", + idx, + video_database::name::REGEX + ))); + } + + let mut names = request + .name + .iter() + .map(|name| name.to_lowercase()) + .collect::>(); + names.sort(); + names.dedup(); + + query_builder.push(" AND name = ANY("); + query_builder.push_bind(names); + query_builder.push("::text[])"); + } + + if let Some(transcoding_config_name) = &request.transcoding_config_name { + if transcoding_config_name.is_empty() { + query_builder.push(" AND transcoding_config_name IS NULL"); + } else { + if !video_database::name::validate(transcoding_config_name) { + return Err(Status::invalid_argument(format!( + "invalid transcoding_config_name provided, names must match {}", + video_database::name::REGEX + ))); + } + + query_builder.push(" AND transcoding_config_name = "); + query_builder.push_bind(transcoding_config_name); + } + } + + if let Some(recording_config_name) = &request.recording_config_name { + if recording_config_name.is_empty() { + query_builder.push(" AND recording_config_name IS NULL"); + } else { + if !video_database::name::validate(recording_config_name) { + return Err(Status::invalid_argument(format!( + "invalid recording_config_name provided, names must match {}", + video_database::name::REGEX + ))); + } + + query_builder.push(" AND recording_config_name = "); + query_builder.push_bind(recording_config_name); + } + } + + if let Some(private) = request.private { + query_builder.push(" AND private = "); + query_builder.push_bind(private); + } + + if let Some(live) = request.live { + query_builder.push(" AND live = "); + query_builder.push_bind(live); + } + + // Used to filter out rooms that were created before a certain time + if let Some(created_at) = request.created_at { + if created_at > Utc::now().timestamp_micros() { + return Err(Status::invalid_argument( + "invalid created_at must be in the past", + )); + } + + if created_at < 0 { + return Err(Status::invalid_argument( + "invalid created_at must be positive", + )); + } + + query_builder.push(" AND created_at > "); + query_builder.push_bind(created_at); + } + + let mut limit = request.limit; + if limit == 0 { + limit = 100; + } else if limit > 1000 { + return Err(Status::invalid_argument( + "limit too large, must be between 1 and 1000", + )); + } else if limit < 0 { + return Err(Status::invalid_argument( + "limit too small, must be between 1 and 1000", + )); + } + + query_builder.push(" ORDER BY created_at LIMIT "); + query_builder.push_bind(limit); + + Ok(query_builder) +} + +pub fn room_reset_key_query<'a>( + request: &'a RoomResetKeyRequest, + access_token: &'a AccessToken, +) -> Result, Status> { + let mut query_builder = QueryBuilder::::default(); + + if request.names.len() > 100 { + return Err(Status::invalid_argument("too many names provided, max 100")); + } + + if request.names.is_empty() { + return Err(Status::invalid_argument("no names provided")); + } + + if let Some(idx) = request + .names + .iter() + .position(|s| !video_database::name::validate(s)) + { + return Err(Status::invalid_argument(format!( + "invalid room name provided at index {}, names must match {}", + idx, + video_database::name::REGEX + ))); + } + + let mut names = request + .names + .iter() + .map(|name| name.to_lowercase()) + .collect::>(); + + names.sort(); + names.dedup(); + + query_builder + .push("UPDATE room as r SET stream_key = v.stream_key, updated_at = NOW() FROM (VALUES "); + + let mut separated = query_builder.separated(", "); + + names.into_iter().for_each(|name| { + separated.push("("); + separated.push_bind_unseparated(name); + separated.push_unseparated(", "); + separated.push_bind_unseparated(generate_stream_key()); + separated.push_unseparated(")"); + }); + + query_builder.push(") AS v(name, stream_key) WHERE r.organization_id = "); + query_builder.push_bind(access_token.organization_id); + query_builder.push(" AND r.name = v.name RETURNING r.*"); + + Ok(query_builder) +} diff --git a/video/api/src/grpc/transcoder_config.rs b/video/api/src/grpc/transcoder_config.rs new file mode 100644 index 00000000..a0228180 --- /dev/null +++ b/video/api/src/grpc/transcoder_config.rs @@ -0,0 +1,58 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Request, Response, Status}; + +use pb::scuffle::video::v1::{ + transcoder_config_server::{ + TranscoderConfig, TranscoderConfigServer as TranscoderConfigService, + }, + TranscoderConfigDeleteRequest, TranscoderConfigDeleteResponse, TranscoderConfigGetRequest, + TranscoderConfigGetResponse, TranscoderConfigModifyRequest, TranscoderConfigModifyResponse, +}; + +type Result = std::result::Result; + +/// TranscoderConfig is a service for managing transcoder configs. +/// Transcoder configs define how rooms will be transcoded, and what renditions +/// will be available. You can attch a transcoder config to a room by calling +/// Room.Modify with the transcoder_config_name field. +pub struct TranscoderConfigServer { + global: Weak, +} + +impl TranscoderConfigServer { + pub fn new(global: &Arc) -> TranscoderConfigService { + TranscoderConfigService::new(Self { + global: Arc::downgrade(global), + }) + } +} + +#[async_trait] +impl TranscoderConfig for TranscoderConfigServer { + /// Modify allows you to create a new transcoder config, or update an existing + /// one. + async fn modify( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement TranscoderConfig::modify") + } + + /// Get allows you to get a transcoder configs. + async fn get( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement TranscoderConfig::get") + } + + /// Delete allows you to delete multiple transcoder configs. + async fn delete( + &self, + _request: Request, + ) -> Result> { + todo!("TODO: implement TranscoderConfig::delete") + } +} diff --git a/video/api/src/grpc/utils.rs b/video/api/src/grpc/utils.rs new file mode 100644 index 00000000..23490a45 --- /dev/null +++ b/video/api/src/grpc/utils.rs @@ -0,0 +1,315 @@ +use std::{ + collections::{BTreeMap, HashMap}, + sync::{Arc, Weak}, +}; + +use chrono::Utc; +use hmac::{Hmac, Mac}; +use jwt::VerifyWithKey; +use pb::scuffle::video::v1::types::{ + access_token_scope::{Permission, Resource}, + AccessTokenScope, +}; +use sha2::Sha256; +use tonic::{Request, Status}; +use uuid::Uuid; +use video_database::{access_token::AccessToken, dataloader::IdNamePair}; + +use crate::global::GlobalState; + +pub async fn jwt_to_access_token( + global: &Arc, + jwt: &str, +) -> Result { + let key: Hmac = Hmac::new_from_slice(global.config.jwt_secret.as_bytes()) + .map_err(|_| tonic::Status::internal("Failed to create HMAC key for JWT verification"))?; + + let claims: BTreeMap = jwt + .verify_with_key(&key) + .map_err(|_| tonic::Status::unauthenticated("Failed to verify JWT"))?; + + // Check for existence of expiration claim / not before claim (if present, check that it's not in the future) + let now = Utc::now(); + + if let Some(exp) = claims + .get("exp") + .map(|exp| exp.parse::()) + .transpose() + .map_err(|_| { + tonic::Status::unauthenticated("JWT expiration claim is not a valid integer") + })? + { + if exp < now.timestamp() { + return Err(tonic::Status::unauthenticated("JWT has expired")); + } + } + + if let Some(nbf) = claims + .get("nbf") + .map(|nbf| nbf.parse::()) + .transpose() + .map_err(|_| { + tonic::Status::unauthenticated("JWT not before claim is not a valid integer") + })? + { + if nbf > now.timestamp() { + return Err(tonic::Status::unauthenticated("JWT is not yet valid")); + } + } + + if claims + .get("iat") + .map(|iat| iat.parse::()) + .ok_or_else(|| tonic::Status::unauthenticated("JWT missing issued at claim"))? + .map_err(|_| tonic::Status::unauthenticated("JWT issued at claim is not a valid integer"))? + > now.timestamp() + { + return Err(tonic::Status::unauthenticated( + "JWT was issued in the future", + )); + } + + let organization_id = claims + .get("org") + .ok_or_else(|| tonic::Status::unauthenticated("JWT missing organization claim"))? + .parse::() + .map_err(|_| { + tonic::Status::unauthenticated("JWT organization claim is not a valid UUID") + })?; + + let name = claims + .get("name") + .ok_or_else(|| tonic::Status::unauthenticated("JWT missing name claim"))? + .to_owned(); + + let version = claims + .get("ver") + .ok_or_else(|| tonic::Status::unauthenticated("JWT missing version claim"))? + .parse::() + .map_err(|_| tonic::Status::unauthenticated("JWT version claim is not a valid integer"))?; + + let token = global + .access_token_by_name_loader + .load_one(IdNamePair(organization_id, name)) + .await + .map_err(|_| tonic::Status::internal("Failed to load access token from database"))? + .ok_or_else(|| tonic::Status::unauthenticated("JWT access token does not exist"))?; + + if token.version != version { + return Err(tonic::Status::unauthenticated( + "JWT version does not match access token version", + )); + } + + if let Some(exp) = token.expires_at { + if exp < now { + return Err(tonic::Status::unauthenticated( + "JWT access token has expired", + )); + } + } + + global + .access_token_used_by_name_updater + .load_one(IdNamePair(organization_id, token.name.clone())) + .await + .map_err(|_| tonic::Status::internal("Failed to update access token last used time"))?; + + Ok(token) +} + +pub async fn validate_auth_request( + global: &Arc, + request: &Request, +) -> Result { + let auth = request + .metadata() + .get("authorization") + .ok_or_else(|| Status::unauthenticated("no authorization header"))?; + + let auth = auth + .to_str() + .map_err(|_| Status::unauthenticated("invalid authorization header"))?; + + let auth = auth + .strip_prefix("Bearer ") + .ok_or_else(|| Status::unauthenticated("invalid authorization header"))?; + + jwt_to_access_token(global, auth).await +} + +pub fn get_global(weak: &Weak) -> Result, Status> { + weak.upgrade() + .ok_or_else(|| Status::internal("global state was dropped")) +} + +pub trait HandleInternalError { + fn to_grpc(self) -> Result; +} + +impl HandleInternalError for Result { + #[track_caller] + fn to_grpc(self) -> Result { + self.map_err(|e| { + let location = std::panic::Location::caller(); + tracing::error!(error = %e, location = %location, "internal error"); + Status::internal("internal error".to_owned()) + }) + } +} + +pub struct RequiredScope(Vec); + +type ResourcePermission = (Resource, Permission); + +impl From for RequiredScope { + fn from((resource, permission): ResourcePermission) -> Self { + Self(vec![AccessTokenScope { + resource: Some(resource.into()), + permission: vec![permission.into()], + }]) + } +} + +impl From> for RequiredScope { + fn from(permissions: Vec) -> Self { + Self( + permissions + .into_iter() + .map(|(resource, permission)| AccessTokenScope { + resource: Some(resource.into()), + permission: vec![permission.into()], + }) + .collect(), + ) + .optimize() + } +} + +impl From for RequiredScope { + fn from(permission: Permission) -> Self { + Self(vec![AccessTokenScope { + resource: None, + permission: vec![permission.into()], + }]) + } +} + +impl RequiredScope { + fn optimize(self) -> Self { + let mut scopes = self.0; + + scopes.dedup(); + + let mut scopes = scopes + .into_iter() + .fold(HashMap::new(), |mut map, new_scope| { + let resource = new_scope.resource; + + let scope = map.entry(resource).or_insert_with(|| AccessTokenScope { + resource, + permission: Vec::new(), + }); + + if scope.permission.contains(&Permission::Admin.into()) { + return map; + } + + if new_scope.permission.contains(&Permission::Admin.into()) { + scope.permission = vec![Permission::Admin.into()]; + return map; + } + + scope.permission.extend(new_scope.permission); + + scope.permission.sort(); + scope.permission.dedup(); + + map + }); + + if let Some(global_scope) = scopes.remove(&None) { + if global_scope.permission.contains(&Permission::Admin.into()) { + return Self(vec![AccessTokenScope { + resource: None, + permission: vec![Permission::Admin.into()], + }]); + } + + scopes.iter_mut().for_each(|(_, scope)| { + scope + .permission + .retain(|p| !global_scope.permission.contains(p)); + }); + + scopes.insert(None, global_scope); + } + + let scopes = scopes + .into_values() + .filter(|s| !s.permission.is_empty()) + .collect::>(); + + Self(scopes) + } +} + +impl From for RequiredScope { + fn from(scope: AccessTokenScope) -> Self { + Self(vec![scope]) + } +} + +impl From> for RequiredScope { + fn from(scopes: Vec) -> Self { + Self(scopes) + } +} + +impl std::fmt::Display for RequiredScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut permissions = Vec::new(); + + for ps in &self.0 { + let scope = ps + .resource + .and_then(Resource::from_i32) + .map(|r| r.as_str_name().to_lowercase()) + .unwrap_or_else(|| "all".to_string()); + + permissions.extend( + ps.permission + .iter() + .filter_map(|p| Permission::from_i32(*p)) + .map(|p| format!("{}:{}", scope, p.as_str_name().to_lowercase())), + ) + } + + permissions.sort(); + + permissions.join(" + ").fmt(f) + } +} + +pub trait AccessTokenExt { + fn has_scope(&self, required: impl Into) -> Result<(), Status>; +} + +impl AccessTokenExt for AccessToken { + fn has_scope(&self, required: impl Into) -> Result<(), Status> { + let required = required.into().optimize(); + + if required.0.iter().all(|required| { + self.scopes.iter().any(|scope| { + // Check that the scope is for all resources (unset) or matches the resource in the required scope + (scope.resource.is_none() || scope.resource == required.resource) && + // Check that the scope either has the Admin permission or has all of the required permissions + (scope.permission.contains(&Permission::Admin.into()) || required.permission.iter().all(|p| scope.permission.contains(p))) + }) + }) { + Ok(()) + } else { + Err(Status::permission_denied(format!("missing required scope: {}", required))) + } + } +} diff --git a/video/api/src/main.rs b/video/api/src/main.rs new file mode 100644 index 00000000..e39dd46c --- /dev/null +++ b/video/api/src/main.rs @@ -0,0 +1,66 @@ +use std::{str::FromStr, sync::Arc, time::Duration}; + +use anyhow::Result; +use common::{context::Context, logging, signal}; +use sqlx::{postgres::PgConnectOptions, ConnectOptions}; +use tokio::{select, signal::unix::SignalKind}; + +mod config; +mod global; +mod grpc; + +#[tokio::main] +async fn main() -> Result<()> { + let config = config::AppConfig::parse()?; + + logging::init(&config.logging.level, config.logging.mode)?; + + if let Some(file) = &config.config_file { + tracing::info!(file = file, "loaded config from file"); + } + + tracing::debug!("config: {:#?}", config); + + let db = Arc::new( + sqlx::PgPool::connect_with( + PgConnectOptions::from_str(&config.database.uri)? + .disable_statement_logging() + .to_owned(), + ) + .await?, + ); + + let (ctx, handler) = Context::new(); + + let redis = global::setup_redis(&config).await; + + tracing::info!("connected to redis"); + + let global = Arc::new(global::GlobalState::new(config, db, redis, ctx)); + + let grpc_future = tokio::spawn(grpc::run(global.clone())); + + // Listen on both sigint and sigterm and cancel the context when either is received + let mut signal_handler = signal::SignalHandler::new() + .with_signal(SignalKind::interrupt()) + .with_signal(SignalKind::terminate()); + + select! { + r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), + _ = signal_handler.recv() => tracing::info!("shutting down"), + } + + // We cannot have a context in scope when we cancel the handler, otherwise it will deadlock. + drop(global); + + // Cancel the context + tracing::info!("waiting for tasks to finish"); + + select! { + _ = tokio::time::sleep(Duration::from_secs(60)) => tracing::warn!("force shutting down"), + _ = signal_handler.recv() => tracing::warn!("force shutting down"), + _ = handler.cancel() => tracing::info!("shutting down"), + } + + Ok(()) +} diff --git a/video/database/Cargo.toml b/video/database/Cargo.toml new file mode 100644 index 00000000..ceb4633f --- /dev/null +++ b/video/database/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "video-database" +version = "0.0.1" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1.29.1", features = ["full"] } +tracing = "0.1.37" +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +sqlx-postgres = "0.7.1" +prost = "0.11.9" +uuid = { version = "1.4.1", features = ["v4"] } +ulid = { version = "1.0.0", features = ["uuid"] } +serde = { version = "1.0.183", features = ["derive"] } +chrono = { version = "0.4.26", default-features = false, features = ["serde", "clock"] } +futures = "0.3.28" +futures-util = "0.3.28" +bytes = "1.4.0" +async-graphql = { version = "6.0.1", default-features = false, features = ["dataloader"] } +async-trait = "0.1.72" + +pb = { workspace = true } diff --git a/video/database/LICENSE.md b/video/database/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/video/database/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/video/database/src/access_token.rs b/video/database/src/access_token.rs new file mode 100644 index 00000000..f2d14a52 --- /dev/null +++ b/video/database/src/access_token.rs @@ -0,0 +1,47 @@ +use std::collections::HashMap; + +use chrono::Utc; +use ulid::Ulid; +use uuid::Uuid; + +use pb::scuffle::video::v1::types::AccessTokenScope; + +use super::adapter::Adapter; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct AccessToken { + pub organization_id: Uuid, + pub id: Uuid, + pub version: i32, + pub updated_at: chrono::DateTime, + pub expires_at: Option>, + pub last_active_at: Option>, + pub scopes: Vec>, + pub tags: Vec, +} + +impl AccessToken { + pub fn to_proto(self) -> pb::scuffle::video::v1::types::AccessToken { + pb::scuffle::video::v1::types::AccessToken { + id: Some(self.id.into()), + created_at: Ulid::from(self.id).timestamp_ms() as i64, + updated_at: self.updated_at.timestamp_millis(), + expires_at: self.expires_at.map(|t| t.timestamp_millis()), + last_used_at: self.last_active_at.map(|t| t.timestamp_millis()), + scopes: self.scopes.into_iter().map(|s| s.0).collect(), + tags: self + .tags + .iter() + .map(|s| { + let splits = s.splitn(2, ':').collect::>(); + + if splits.len() == 2 { + (splits[0].to_string(), splits[1].to_string()) + } else { + (splits[0].to_string(), "".to_string()) + } + }) + .collect::>(), + } + } +} diff --git a/video/database/src/adapter.rs b/video/database/src/adapter.rs new file mode 100644 index 00000000..8ed73284 --- /dev/null +++ b/video/database/src/adapter.rs @@ -0,0 +1,103 @@ +#[repr(transparent)] +pub struct Adapter(pub T); + +impl Clone for Adapter +where + T: Clone, +{ + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +pub trait TraitAdapter { + fn into_inner(self) -> T; +} + +pub trait TraitAdapterVec { + fn into_vec(self) -> Vec; +} + +impl TraitAdapter for Adapter { + fn into_inner(self) -> T { + self.0 + } +} + +impl TraitAdapterVec for Vec> { + fn into_vec(self) -> Vec { + self.into_iter().map(|a| a.into_inner()).collect() + } +} + +impl std::fmt::Debug for Adapter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Default for Adapter { + fn default() -> Self { + Self(T::default()) + } +} + +impl sqlx::Type for Adapter { + fn type_info() -> sqlx::postgres::PgTypeInfo { + as sqlx::Type>::type_info() + } +} + +impl sqlx::postgres::PgHasArrayType for Adapter { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + as sqlx::postgres::PgHasArrayType>::array_type_info() + } +} + +impl sqlx::Encode<'_, sqlx::Postgres> for Adapter { + fn encode_by_ref(&self, buf: &mut sqlx::postgres::PgArgumentBuffer) -> sqlx::encode::IsNull { + as sqlx::Encode>::encode_by_ref(&self.0.encode_to_vec(), buf) + } +} + +impl sqlx::Decode<'_, sqlx::Postgres> for Adapter { + fn decode( + value: sqlx::postgres::PgValueRef<'_>, + ) -> Result> { + let bytes = as sqlx::Decode>::decode(value)?; + let inner = T::decode(bytes.as_slice())?; + Ok(Self(inner)) + } +} + +impl AsRef for Adapter { + fn as_ref(&self) -> &T { + &self.0 + } +} + +impl AsMut for Adapter { + fn as_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl std::ops::Deref for Adapter { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Adapter { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for Adapter { + fn from(inner: T) -> Self { + Self(inner) + } +} diff --git a/video/database/src/dataloader/access_token.rs b/video/database/src/dataloader/access_token.rs new file mode 100644 index 00000000..08acf430 --- /dev/null +++ b/video/database/src/dataloader/access_token.rs @@ -0,0 +1,73 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use ulid::Ulid; +use uuid::Uuid; + +use crate::access_token::AccessToken; + +pub struct AccessTokenByNameLoader { + db: Arc, +} + +impl AccessTokenByNameLoader { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +#[async_trait] +impl Loader for AccessTokenByNameLoader { + type Value = AccessToken; + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM access_tokens WHERE id = ANY($1::uuid[]) + "#, + ) + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for access_token in query { + map.insert(access_token.id.into(), access_token); + } + + Ok(map) + } +} + +pub struct AccessTokenUsedByNameUpdater { + db: Arc, +} + +impl AccessTokenUsedByNameUpdater { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +#[async_trait] +impl Loader for AccessTokenUsedByNameUpdater { + type Value = (); + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + sqlx::query( + r#" + UPDATE access_token SET last_active_at = NOW() WHERE id = ANY($1::uuid[]) + "#, + ) + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .execute(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + Ok(HashMap::new()) + } +} diff --git a/video/database/src/dataloader/mod.rs b/video/database/src/dataloader/mod.rs new file mode 100644 index 00000000..22f970c4 --- /dev/null +++ b/video/database/src/dataloader/mod.rs @@ -0,0 +1,8 @@ +pub mod access_token; +pub mod organization; +pub mod playback_key_pair; +pub mod playback_session; +pub mod recording; +pub mod recording_config; +pub mod room; +pub mod transcoding_config; diff --git a/video/database/src/dataloader/organization.rs b/video/database/src/dataloader/organization.rs new file mode 100644 index 00000000..3960cabc --- /dev/null +++ b/video/database/src/dataloader/organization.rs @@ -0,0 +1,36 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use uuid::Uuid; + +use crate::organization::Organization; + +pub struct OrganizationByIdLoader { + db: Arc, +} + +#[async_trait] +impl Loader for OrganizationByIdLoader { + type Value = Organization; + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM organizations WHERE id = ANY($1) + "#, + ) + .bind(keys) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for organization in query { + map.insert(organization.id, organization); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/playback_key_pair.rs b/video/database/src/dataloader/playback_key_pair.rs new file mode 100644 index 00000000..e18b907d --- /dev/null +++ b/video/database/src/dataloader/playback_key_pair.rs @@ -0,0 +1,34 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use ulid::Ulid; +use uuid::Uuid; + +use crate::playback_key_pair::PlaybackKeyPair; + +pub struct PlaybackKeyPairByNameLoader { + db: Arc, +} + +#[async_trait] +impl Loader for PlaybackKeyPairByNameLoader { + type Value = PlaybackKeyPair; + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + let query: Vec = + sqlx::query_as("SELECT * FROM playback_key_pairs WHERE id = ANY($1::uuid[])") + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for playback_key_pair in query { + map.insert(playback_key_pair.id.into(), playback_key_pair); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/playback_session.rs b/video/database/src/dataloader/playback_session.rs new file mode 100644 index 00000000..5fed2bab --- /dev/null +++ b/video/database/src/dataloader/playback_session.rs @@ -0,0 +1,36 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use uuid::Uuid; + +use crate::playback_session::PlaybackSession; + +pub struct PlaybackSessionByIdLoader { + db: Arc, +} + +#[async_trait] +impl Loader for PlaybackSessionByIdLoader { + type Value = PlaybackSession; + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM playback_sessions WHERE id = ANY($1) + "#, + ) + .bind(keys) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for playback_session in query { + map.insert(playback_session.id, playback_session); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/recording.rs b/video/database/src/dataloader/recording.rs new file mode 100644 index 00000000..037aa329 --- /dev/null +++ b/video/database/src/dataloader/recording.rs @@ -0,0 +1,84 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use uuid::Uuid; + +use crate::recording::Recording; + +pub struct RecordingByIdLoader { + db: Arc, +} + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct RecordingInfo { + pub recording_id: Uuid, + pub total_size: i64, + pub recording_duration: f64, +} + +#[async_trait] +impl Loader for RecordingByIdLoader { + type Value = (Recording, RecordingInfo); + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let recording_query: Vec = sqlx::query_as( + r#" + SELECT * FROM recordings WHERE id = ANY($1) + "#, + ) + .bind(keys) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::::new(); + for recording in recording_query { + map.insert(recording.id, (recording, Default::default())); + } + + let recording_segments_query: Vec = if map.is_empty() { + Vec::new() + } else { + sqlx::query_as( + r#" + WITH DistinctDurationsAndSizes AS ( + SELECT + recording_id, + SUM(segment_end - segment_start) AS rendition_duration, + SUM(size_bytes) AS rendition_size + FROM + recording_segments + WHERE + recording_id = ANY($1) + GROUP BY + recording_id, segment_number + ) + SELECT + recording_id, + SUM(rendition_size) AS total_size, + EXTRACT(EPOCH FROM MAX(rendition_duration)) AS recording_duration + FROM + DistinctDurationsAndSizes + GROUP BY + recording_id; + "#, + ) + .bind(map.keys().cloned().collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)? + }; + + for recording in recording_segments_query { + let Some(recording_info) = map.get_mut(&recording.recording_id) else { + continue; + }; + + let _ = std::mem::replace(&mut recording_info.1, recording); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/recording_config.rs b/video/database/src/dataloader/recording_config.rs new file mode 100644 index 00000000..68cb5f27 --- /dev/null +++ b/video/database/src/dataloader/recording_config.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use ulid::Ulid; +use uuid::Uuid; + +use crate::recording_config::RecordingConfig; + +pub struct RecordingConfigByNameLoader { + db: Arc, +} + +#[async_trait] +impl Loader for RecordingConfigByNameLoader { + type Value = RecordingConfig; + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM recording_configs WHERE id = ANY($1::uuid[]) + "#, + ) + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for playback_key_pair in query { + map.insert(playback_key_pair.id.into(), playback_key_pair); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/room.rs b/video/database/src/dataloader/room.rs new file mode 100644 index 00000000..435a0401 --- /dev/null +++ b/video/database/src/dataloader/room.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use ulid::Ulid; +use uuid::Uuid; + +use crate::room::Room; + +pub struct RoomByNameLoader { + db: Arc, +} + +#[async_trait] +impl Loader for RoomByNameLoader { + type Value = Room; + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM rooms WHERE id = ANY($1::uuid[]) + "#, + ) + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for room in query { + map.insert(room.id.into(), room); + } + + Ok(map) + } +} diff --git a/video/database/src/dataloader/transcoding_config.rs b/video/database/src/dataloader/transcoding_config.rs new file mode 100644 index 00000000..379722a1 --- /dev/null +++ b/video/database/src/dataloader/transcoding_config.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_graphql::dataloader::Loader; +use async_trait::async_trait; +use ulid::Ulid; +use uuid::Uuid; + +use crate::transcoding_config::TranscodingConfig; + +pub struct TranscoderConfigByNameLoader { + db: Arc, +} + +#[async_trait] +impl Loader for TranscoderConfigByNameLoader { + type Value = TranscodingConfig; + type Error = Arc; + + async fn load(&self, keys: &[Ulid]) -> Result, Self::Error> { + let query: Vec = sqlx::query_as( + r#" + SELECT * FROM transcoding_configs WHERE id = ANY($1::uuid[]) + "#, + ) + .bind(keys.iter().map(|id| Uuid::from(*id)).collect::>()) + .fetch_all(self.db.as_ref()) + .await + .map_err(Arc::new)?; + + let mut map = HashMap::new(); + for transcoding_config in query { + map.insert(transcoding_config.id.into(), transcoding_config); + } + + Ok(map) + } +} diff --git a/video/database/src/lib.rs b/video/database/src/lib.rs new file mode 100644 index 00000000..91a53218 --- /dev/null +++ b/video/database/src/lib.rs @@ -0,0 +1,16 @@ +pub mod access_token; +pub mod adapter; +pub mod dataloader; +pub mod organization; +pub mod playback_key_pair; +pub mod playback_session; +pub mod playback_session_browser; +pub mod playback_session_device; +pub mod playback_session_platform; +pub mod recording; +pub mod recording_config; +pub mod recording_rendition; +pub mod rendition; +pub mod room; +pub mod room_status; +pub mod transcoding_config; diff --git a/video/database/src/organization.rs b/video/database/src/organization.rs new file mode 100644 index 00000000..d013a120 --- /dev/null +++ b/video/database/src/organization.rs @@ -0,0 +1,10 @@ +use uuid::Uuid; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Organization { + // The primary key for the organization + pub id: Uuid, + + // The date and time the organization was last updated + pub updated_at: chrono::DateTime, +} diff --git a/video/database/src/playback_key_pair.rs b/video/database/src/playback_key_pair.rs new file mode 100644 index 00000000..a2a7afbd --- /dev/null +++ b/video/database/src/playback_key_pair.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use ulid::Ulid; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct PlaybackKeyPair { + pub id: Uuid, + pub organization_id: Uuid, + pub public_key: Vec, + pub fingerprint: String, + pub updated_at: chrono::DateTime, + pub tags: Vec, +} + +impl PlaybackKeyPair { + pub fn into_proto(self) -> pb::scuffle::video::v1::types::PlaybackKeyPair { + pb::scuffle::video::v1::types::PlaybackKeyPair { + id: Some(self.id.into()), + fingerprint: self.fingerprint, + created_at: Ulid::from(self.id).timestamp_ms() as i64, + updated_at: self.updated_at.timestamp_millis(), + tags: self + .tags + .iter() + .map(|s| { + let splits = s.splitn(2, ':').collect::>(); + + if splits.len() == 2 { + (splits[0].to_string(), splits[1].to_string()) + } else { + (splits[0].to_string(), "".to_string()) + } + }) + .collect::>(), + } + } +} diff --git a/video/database/src/playback_session.rs b/video/database/src/playback_session.rs new file mode 100644 index 00000000..cdda9b95 --- /dev/null +++ b/video/database/src/playback_session.rs @@ -0,0 +1,56 @@ +use pb::scuffle::video::v1::types::playback_session; +use ulid::Ulid; +use uuid::Uuid; + +use super::{ + playback_session_browser::PlaybackSessionBrowser, + playback_session_device::PlaybackSessionDevice, + playback_session_platform::PlaybackSessionPlatform, +}; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PlaybackSession { + pub id: Uuid, + pub room_id: Option, + pub recording_id: Option, + pub organization_id: Uuid, + pub user_id: Option, + pub playback_key_pair_id: Option, + pub issued_at: Option>, + pub last_active_at: chrono::DateTime, + pub ip_address: String, + pub user_agent: Option, + pub referer: Option, + pub origin: Option, + pub device: PlaybackSessionDevice, + pub platform: PlaybackSessionPlatform, + pub browser: PlaybackSessionBrowser, + pub player_version: Option, +} + +impl PlaybackSession { + pub fn into_proto(self) -> pb::scuffle::video::v1::types::PlaybackSession { + pb::scuffle::video::v1::types::PlaybackSession { + id: Some(self.id.into()), + target: if let Some(room_id) = self.room_id { + Some(playback_session::Target::RoomId(room_id.into())) + } else { + self.recording_id + .map(|recording_id| playback_session::Target::RecordingId(recording_id.into())) + }, + user_id: self.user_id, + playback_key_pair_id: self.playback_key_pair_id.map(|id| id.into()), + issued_at: self.issued_at.map(|dt| dt.timestamp_millis()), + created_at: Ulid::from(self.id).timestamp_ms() as i64, + last_active_at: self.last_active_at.timestamp_millis(), + ip_address: self.ip_address, + user_agent: self.user_agent, + referer: self.referer, + origin: self.origin, + device: playback_session::Device::from(self.device).into(), + platform: playback_session::Platform::from(self.platform).into(), + browser: playback_session::Browser::from(self.browser).into(), + player_version: self.player_version, + } + } +} diff --git a/video/database/src/playback_session_browser.rs b/video/database/src/playback_session_browser.rs new file mode 100644 index 00000000..63828c4b --- /dev/null +++ b/video/database/src/playback_session_browser.rs @@ -0,0 +1,15 @@ +#[derive(Debug, sqlx::Type, Default, Clone, Copy, PartialEq)] +#[sqlx(type_name = "playback_session_browser")] +pub enum PlaybackSessionBrowser { + #[sqlx(rename = "UNKNOWN")] + #[default] + Unknown, +} + +impl From for pb::scuffle::video::v1::types::playback_session::Browser { + fn from(browser: PlaybackSessionBrowser) -> Self { + match browser { + PlaybackSessionBrowser::Unknown => Self::UnknownBrowser, + } + } +} diff --git a/video/database/src/playback_session_device.rs b/video/database/src/playback_session_device.rs new file mode 100644 index 00000000..541fedb4 --- /dev/null +++ b/video/database/src/playback_session_device.rs @@ -0,0 +1,15 @@ +#[derive(Debug, sqlx::Type, Default, Clone, Copy, PartialEq)] +#[sqlx(type_name = "playback_session_device")] +pub enum PlaybackSessionDevice { + #[sqlx(rename = "UNKNOWN")] + #[default] + Unknown, +} + +impl From for pb::scuffle::video::v1::types::playback_session::Device { + fn from(value: PlaybackSessionDevice) -> Self { + match value { + PlaybackSessionDevice::Unknown => Self::UnknownDevice, + } + } +} diff --git a/video/database/src/playback_session_platform.rs b/video/database/src/playback_session_platform.rs new file mode 100644 index 00000000..17db1c63 --- /dev/null +++ b/video/database/src/playback_session_platform.rs @@ -0,0 +1,15 @@ +#[derive(Debug, sqlx::Type, Default, Clone, Copy, PartialEq)] +#[sqlx(type_name = "playback_session_platform")] +pub enum PlaybackSessionPlatform { + #[sqlx(rename = "UNKNOWN")] + #[default] + Unknown, +} + +impl From for pb::scuffle::video::v1::types::playback_session::Platform { + fn from(value: PlaybackSessionPlatform) -> Self { + match value { + PlaybackSessionPlatform::Unknown => Self::UnknownPlatform, + } + } +} diff --git a/video/database/src/recording.rs b/video/database/src/recording.rs new file mode 100644 index 00000000..97b32bed --- /dev/null +++ b/video/database/src/recording.rs @@ -0,0 +1,41 @@ +use uuid::Uuid; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Recording { + pub id: Uuid, + pub organization_id: Uuid, + + pub room_id: Option, + pub recording_config_id: Option, + + pub public: bool, + pub deleted: bool, + pub allow_dvr: bool, + + pub updated_at: chrono::DateTime, +} + +// impl Recording { +// pub fn into_proto(self, info: RecordingInfo) -> pb::scuffle::video::v1::types::Recording { +// pb::scuffle::video::v1::types::Recording { +// id: Some(self.id.into()), +// room_id: self.room_id.map(|id| id.into()), +// recording_config_id: self.recording_config_id.map(|id| id.into()), +// video_renditions: self +// .video_renditions +// .into_iter() +// .map(|r| PbRenditionVideo::from(r).into()) +// .collect(), +// audio_renditions: self +// .audio_renditions +// .into_iter() +// .map(|r| PbRenditionAudio::from(r).into()) +// .collect(), +// created_at: Ulid::from(self.id).timestamp_ms() as i64, +// updated_at: self.updated_at.timestamp_millis(), +// ended_at: self.ended_at.map(|t| t.timestamp_millis()), +// byte_size: info.total_size, +// duration: info.recording_duration as f32, +// } +// } +// } diff --git a/video/database/src/recording_config.rs b/video/database/src/recording_config.rs new file mode 100644 index 00000000..2a1a5470 --- /dev/null +++ b/video/database/src/recording_config.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use pb::scuffle::video::v1::types::{RecordingLifecyclePolicy, Rendition as PbRendition}; +use ulid::Ulid; +use uuid::Uuid; + +use crate::rendition::Rendition; + +use super::adapter::Adapter; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct RecordingConfig { + pub id: Uuid, + pub organization_id: Uuid, + pub renditions: Vec, + pub lifecycle_policies: Vec>, + pub updated_at: chrono::DateTime, + pub tags: Vec, +} + +impl RecordingConfig { + pub fn into_proto(self) -> pb::scuffle::video::v1::types::RecordingConfig { + pb::scuffle::video::v1::types::RecordingConfig { + id: Some(self.id.into()), + renditions: self + .renditions + .into_iter() + .map(|r| PbRendition::from(r).into()) + .collect(), + lifecycle_policies: self.lifecycle_policies.into_iter().map(|p| p.0).collect(), + created_at: Ulid::from(self.id).timestamp_ms() as i64, + updated_at: self.updated_at.timestamp_millis(), + tags: self + .tags + .iter() + .map(|s| { + let splits = s.splitn(2, ':').collect::>(); + + if splits.len() == 2 { + (splits[0].to_string(), splits[1].to_string()) + } else { + (splits[0].to_string(), "".to_string()) + } + }) + .collect::>(), + } + } +} diff --git a/video/database/src/recording_rendition.rs b/video/database/src/recording_rendition.rs new file mode 100644 index 00000000..6dd34628 --- /dev/null +++ b/video/database/src/recording_rendition.rs @@ -0,0 +1,43 @@ +use uuid::Uuid; + +use crate::rendition::Rendition; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct RecordingRendition { + pub recording_id: Uuid, + pub rendition: Rendition, + + pub organization_id: Uuid, + pub segment_ids: Vec, + pub segment_durations: Vec, + pub timescale: i32, + pub total_size: i64, + + #[sqlx(default)] + pub public_url: Option, +} + +// impl Recording { +// pub fn into_proto(self, info: RecordingInfo) -> pb::scuffle::video::v1::types::Recording { +// pb::scuffle::video::v1::types::Recording { +// id: Some(self.id.into()), +// room_id: self.room_id.map(|id| id.into()), +// recording_config_id: self.recording_config_id.map(|id| id.into()), +// video_renditions: self +// .video_renditions +// .into_iter() +// .map(|r| PbRenditionVideo::from(r).into()) +// .collect(), +// audio_renditions: self +// .audio_renditions +// .into_iter() +// .map(|r| PbRenditionAudio::from(r).into()) +// .collect(), +// created_at: Ulid::from(self.id).timestamp_ms() as i64, +// updated_at: self.updated_at.timestamp_millis(), +// ended_at: self.ended_at.map(|t| t.timestamp_millis()), +// byte_size: info.total_size, +// duration: info.recording_duration as f32, +// } +// } +// } diff --git a/video/database/src/rendition.rs b/video/database/src/rendition.rs new file mode 100644 index 00000000..4a42bb4e --- /dev/null +++ b/video/database/src/rendition.rs @@ -0,0 +1,75 @@ +use sqlx::postgres::PgHasArrayType; + +use sqlx::Type; + +#[derive(Debug, sqlx::Type, Clone, Copy, PartialEq, Eq, Hash)] +#[sqlx(type_name = "rendition")] +pub enum Rendition { + #[sqlx(rename = "VIDEO_SOURCE")] + VideoSource, + #[sqlx(rename = "VIDEO_HD")] + VideoHd, + #[sqlx(rename = "VIDEO_SD")] + VideoSd, + #[sqlx(rename = "VIDEO_LD")] + VideoLd, + #[sqlx(rename = "AUDIO_SOURCE")] + AudioSource, +} + +impl PgHasArrayType for Rendition { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } +} + +impl From for pb::scuffle::video::v1::types::Rendition { + fn from(value: Rendition) -> Self { + match value { + Rendition::VideoSource => Self::VideoSource, + Rendition::VideoHd => Self::VideoHd, + Rendition::VideoSd => Self::VideoSd, + Rendition::VideoLd => Self::VideoLd, + Rendition::AudioSource => Self::AudioSource, + } + } +} + +impl From for Rendition { + fn from(value: pb::scuffle::video::v1::types::Rendition) -> Self { + match value { + pb::scuffle::video::v1::types::Rendition::VideoSource => Self::VideoSource, + pb::scuffle::video::v1::types::Rendition::VideoHd => Self::VideoHd, + pb::scuffle::video::v1::types::Rendition::VideoSd => Self::VideoSd, + pb::scuffle::video::v1::types::Rendition::VideoLd => Self::VideoLd, + pb::scuffle::video::v1::types::Rendition::AudioSource => Self::AudioSource, + } + } +} + +impl std::fmt::Display for Rendition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::VideoSource => write!(f, "video_source"), + Self::VideoHd => write!(f, "video_hd"), + Self::VideoSd => write!(f, "video_sd"), + Self::VideoLd => write!(f, "video_ld"), + Self::AudioSource => write!(f, "audio_source"), + } + } +} + +impl std::str::FromStr for Rendition { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "video_source" => Ok(Self::VideoSource), + "video_hd" => Ok(Self::VideoHd), + "video_sd" => Ok(Self::VideoSd), + "video_ld" => Ok(Self::VideoLd), + "audio_source" => Ok(Self::AudioSource), + _ => Err(()), + } + } +} diff --git a/video/database/src/room.rs b/video/database/src/room.rs new file mode 100644 index 00000000..c693c84b --- /dev/null +++ b/video/database/src/room.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; + +use pb::scuffle::video::v1::types::{AudioConfig, RecordingConfig, TranscodingConfig, VideoConfig}; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{ + adapter::{Adapter, TraitAdapter, TraitAdapterVec}, + room_status::RoomStatus, +}; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Room { + pub id: Uuid, + pub organization_id: Uuid, + pub transcoding_config_id: Option, + pub recording_config_id: Option, + pub private: bool, + pub stream_key: String, + pub updated_at: chrono::DateTime, + + pub last_live_at: Option>, + pub last_disconnected_at: Option>, + + pub status: RoomStatus, + + pub video_input: Option>, + pub audio_input: Option>, + + pub video_output: Option>>, + pub audio_output: Option>>, + + pub active_recording_config: Option>, + pub active_transcoding_config: Option>, + pub active_ingest_connection_id: Option, + pub active_recording_id: Option, + pub tags: Vec, +} + +impl Room { + pub fn into_proto(self) -> pb::scuffle::video::v1::types::Room { + pb::scuffle::video::v1::types::Room { + id: Some(self.id.into()), + transcoding_config_id: self.transcoding_config_id.map(|id| id.into()), + recording_config_id: self.recording_config_id.map(|id| id.into()), + private: self.private, + stream_key: self.stream_key, + created_at: Ulid::from(self.id).timestamp_ms() as i64, + updated_at: self.updated_at.timestamp_millis(), + last_live_at: self.last_live_at.map(|t| t.timestamp_millis()), + last_disconnected_at: self.last_disconnected_at.map(|t| t.timestamp_millis()), + status: self.status.into(), + audio_input: self.audio_input.map(|a| a.into_inner()), + video_input: self.video_input.map(|v| v.into_inner()), + audio_output: self.audio_output.map(|a| a.into_vec()).unwrap_or_default(), + video_output: self.video_output.map(|v| v.into_vec()).unwrap_or_default(), + active_recording_id: self.active_recording_id.map(|r| r.into()), + active_connection_id: self.active_ingest_connection_id.map(|c| c.into()), + tags: self + .tags + .iter() + .map(|s| { + let splits = s.splitn(2, ':').collect::>(); + + if splits.len() == 2 { + (splits[0].to_string(), splits[1].to_string()) + } else { + (splits[0].to_string(), "".to_string()) + } + }) + .collect::>(), + } + } +} diff --git a/video/database/src/room_status.rs b/video/database/src/room_status.rs new file mode 100644 index 00000000..c8ec8bf9 --- /dev/null +++ b/video/database/src/room_status.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Default, sqlx::Type, Clone, Copy, PartialEq)] +#[sqlx(type_name = "room_status")] +pub enum RoomStatus { + #[sqlx(rename = "OFFLINE")] + #[default] + Offline, + #[sqlx(rename = "WAITING_FOR_TRANSCODER")] + WaitingForTranscoder, + #[sqlx(rename = "READY")] + Ready, +} + +impl From for i32 { + fn from(value: RoomStatus) -> Self { + pb::scuffle::video::v1::types::RoomStatus::from(value) as i32 + } +} + +impl From for pb::scuffle::video::v1::types::RoomStatus { + fn from(value: RoomStatus) -> Self { + match value { + RoomStatus::Offline => pb::scuffle::video::v1::types::RoomStatus::Offline, + RoomStatus::WaitingForTranscoder => { + pb::scuffle::video::v1::types::RoomStatus::WaitingForTranscoder + } + RoomStatus::Ready => pb::scuffle::video::v1::types::RoomStatus::Ready, + } + } +} diff --git a/video/database/src/transcoding_config.rs b/video/database/src/transcoding_config.rs new file mode 100644 index 00000000..f77ddda6 --- /dev/null +++ b/video/database/src/transcoding_config.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use pb::scuffle::video::v1::types::Rendition as PbRendition; + +use ulid::Ulid; +use uuid::Uuid; + +use super::rendition::Rendition; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct TranscodingConfig { + pub id: Uuid, + pub organization_id: Uuid, + pub renditions: Vec, + pub updated_at: chrono::DateTime, + pub tags: Vec, +} + +impl TranscodingConfig { + pub fn into_proto(self) -> pb::scuffle::video::v1::types::TranscodingConfig { + pb::scuffle::video::v1::types::TranscodingConfig { + id: Some(self.id.into()), + renditions: self + .renditions + .into_iter() + .map(|r| PbRendition::from(r).into()) + .collect(), + created_at: Ulid::from(self.id).timestamp_ms() as i64, + updated_at: self.updated_at.timestamp_micros(), + tags: self + .tags + .iter() + .map(|s| { + let splits = s.splitn(2, ':').collect::>(); + + if splits.len() == 2 { + (splits[0].to_string(), splits[1].to_string()) + } else { + (splits[0].to_string(), "".to_string()) + } + }) + .collect::>(), + } + } +} diff --git a/video/edge/Cargo.toml b/video/edge/Cargo.toml index 756a727d..094798f3 100644 --- a/video/edge/Cargo.toml +++ b/video/edge/Cargo.toml @@ -1,46 +1,50 @@ [package] -name = "edge" -version = "0.1.0" +name = "video-edge" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1" -tracing = "0" -native-tls = "0" -tokio-native-tls = "0" -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -hyper = { version = "0", features = ["full"] } -tonic = { version = "0", features = ["tls"] } -chrono = { version = "0", default-features = false, features = ["clock"] } -prost = "0" -async-stream = "0" -futures = "0" -futures-util = "0" -bytes = "1" -async-trait = "0" -fred = { version = "6", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } -url-parse = "1" -nix = "0" -sha2 = "0" -tokio-util = "0" -tokio-stream = "0" -serde_json = "1" -routerify = "3" -uuid = "1" -url = "2" +anyhow = "1.0.72" +tracing = "0.1.37" +native-tls = "0.2.11" +tokio-native-tls = "0.3.1" +tokio = { version = "1.29.1", features = ["full"] } +serde = { version = "1.0.183", features = ["derive"] } +hyper = { version = "0.14.27", features = ["full"] } +tonic = { version = "0.9.2", features = ["tls"] } +chrono = { version = "0.4.26", default-features = false, features = ["clock"] } +prost = "0.11.9" +async-stream = "0.3.5" +futures = "0.3.28" +futures-util = "0.3.28" +bytes = "1.4.0" +async-trait = "0.1.72" +url-parse = "1.0.7" +nix = "0.26.2" +sha2 = "0.10.7" +tokio-util = "0.7.8" +tokio-stream = "0.1.14" +serde_json = "1.0.104" +routerify = "3.0.0" +uuid = { version = "1.4.1", features = ["v4"] } +url = "2.4.0" +async-nats = "0.31.0" +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +sqlx-postgres = "0.7.1" +hmac = "0.12.1" +jwt = { version = "0.16.0", features = ["openssl"] } +openssl = "0.10.56" +ulid = { version = "1.0.0", features = ["uuid", "serde"] } -common = { path = "../../common" } -config = { path = "../../config/config" } - -[build-dependencies] -tonic-build = "0" -prost-build = "0" +common = { workspace = true, features = ["default"] } +config = { workspace = true } +pb = { workspace = true } +video-database = { workspace = true } [dev-dependencies] -dotenvy = "0" -portpicker = "0" -serial_test = "2" -tempfile = "3" +dotenvy = "0.15.7" +portpicker = "0.1.1" +serial_test = "2.0.0" +tempfile = "3.7.1" diff --git a/video/edge/build.rs b/video/edge/build.rs deleted file mode 100644 index 3c3ceedf..00000000 --- a/video/edge/build.rs +++ /dev/null @@ -1,23 +0,0 @@ -const PROTO_DIR: &str = "../../proto"; - -fn main() { - let mut config = prost_build::Config::new(); - - config.protoc_arg("--experimental_allow_proto3_optional"); - config.bytes(["."]); - - tonic_build::configure() - .compile_with_config( - config, - &[ - format!("{}/scuffle/events/ingest.proto", PROTO_DIR), - format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), - format!("{}/scuffle/backend/api.proto", PROTO_DIR), - format!("{}/scuffle/video/ingest.proto", PROTO_DIR), - format!("{}/scuffle/video/transcoder.proto", PROTO_DIR), - format!("{}/scuffle/utils/health.proto", PROTO_DIR), - ], - &[PROTO_DIR], - ) - .unwrap(); -} diff --git a/video/edge/src/config.rs b/video/edge/src/config.rs index 21ef6134..30488abc 100644 --- a/video/edge/src/config.rs +++ b/video/edge/src/config.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use anyhow::Result; -use common::config::{LoggingConfig, RedisConfig, TlsConfig}; +use common::config::{LoggingConfig, NatsConfig, TlsConfig}; #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] @@ -11,6 +11,18 @@ pub struct EdgeConfig { /// If we should use TLS pub tls: Option, + + /// The session key to use for signing session tokens + pub session_key: String, + + /// The segment key to use for signing segment tokens + pub media_key: String, + + /// The name of the key value store to use for metadata + pub metadata_kv_store: String, + + /// The name of the object store to use for media + pub media_ob_store: String, } impl Default for EdgeConfig { @@ -18,6 +30,10 @@ impl Default for EdgeConfig { Self { bind_address: "[::]:9080".to_string().parse().unwrap(), tls: None, + media_key: "media_key".to_string(), + session_key: "session_key".to_string(), + metadata_kv_store: "transcoder-metadata".to_string(), + media_ob_store: "transcoder-media".to_string(), } } } @@ -41,6 +57,21 @@ impl Default for GrpcConfig { } } +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +pub struct DatabaseConfig { + /// The database URL to use + pub uri: String, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + uri: "postgres://root@localhost:5432/scuffle_video".to_string(), + } + } +} + #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] pub struct AppConfig { @@ -59,8 +90,11 @@ pub struct AppConfig { /// gRPC server configuration pub grpc: GrpcConfig, - /// Redis configuration - pub redis: RedisConfig, + /// Nats configuration + pub nats: NatsConfig, + + /// Database configuration + pub database: DatabaseConfig, } impl Default for AppConfig { @@ -71,7 +105,8 @@ impl Default for AppConfig { edge: EdgeConfig::default(), grpc: GrpcConfig::default(), logging: LoggingConfig::default(), - redis: RedisConfig::default(), + nats: NatsConfig::default(), + database: DatabaseConfig::default(), } } } diff --git a/video/edge/src/edge/stream.rs b/video/edge/src/edge/stream.rs index e936d706..d9dc6953 100644 --- a/video/edge/src/edge/stream.rs +++ b/video/edge/src/edge/stream.rs @@ -1,355 +1,1135 @@ -use std::convert::Infallible; -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; +use std::time::Duration; -use bytes::Bytes; -use futures::stream; +use chrono::TimeZone; +use common::prelude::FutureTimeout; +use common::vec_of_strings; +use futures_util::StreamExt; +use hmac::{Hmac, Mac}; use hyper::{Body, Request, Response, StatusCode}; +use jwt::{AlgorithmType, PKeyWithDigest, SignWithKey, Token, VerifyWithKey}; +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use pb::ext::UlidExt; +use pb::scuffle::video::internal::live_rendition_manifest::RenditionInfo; +use pb::scuffle::video::internal::{LiveManifest, LiveRenditionManifest}; +use prost::Message; use routerify::{prelude::RequestExt, Router}; +use sha2::Sha256; +use sqlx::Row; +use tokio::io::AsyncReadExt; +use tokio::time::Instant; +use ulid::Ulid; +use uuid::Uuid; +use video_database::playback_key_pair::PlaybackKeyPair; +use video_database::recording::Recording; +use video_database::recording_rendition::RecordingRendition; +use video_database::rendition::Rendition; +use video_database::room::Room; +use video_database::room_status::RoomStatus; use super::error::{Result, RouteError}; +use crate::edge::error::ResultExt; use crate::{edge::ext::RequestExt as _, global::GlobalState}; -use fred::interfaces::HashesInterface; -use fred::interfaces::KeysInterface; +mod keys { + use ulid::Ulid; + use video_database::rendition::Rendition; -pub async fn variant_playlist(req: Request) -> Result> { - let global = req.get_global()?; + pub fn part( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, + part_idx: u32, + ) -> String { + format!("{organization_id}.{room_id}.{connection_id}.part.{rendition}.{part_idx}",) + } - let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; - let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + pub fn rendition_manifest( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, + ) -> String { + format!("{organization_id}.{room_id}.{connection_id}.manifest.{rendition}",) + } - tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, "variant_playlist"); + pub fn manifest(organization_id: Ulid, room_id: Ulid, connection_id: Ulid) -> String { + format!("{organization_id}.{room_id}.{connection_id}.manifest",) + } - let params: HashMap = req - .uri() - .query() - .map(|v| { - url::form_urlencoded::parse(v.as_bytes()) - .into_owned() - .collect() - }) - .unwrap_or_else(HashMap::new); + pub fn init( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, + ) -> String { + format!("{organization_id}.{room_id}.{connection_id}.init.{rendition}",) + } - // LL-HLS allows for a few query parameters: - // - _HLS_msn (Media Sequence Number) - // - _HLS_part (Part Number) + pub fn screenshot( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + idx: u32, + ) -> String { + format!("{organization_id}.{room_id}.{connection_id}.screenshot.{idx}",) + } +} - // If those are present we should block until the requested sequence number is available. +#[derive(Debug, serde::Deserialize)] +struct TokenClaims { + /// The room name that this token is for (required) + organization_id: Option, - let sequence_number = params.get("_HLS_msn").and_then(|v| v.parse::().ok()); - let part_number = params.get("_HLS_part").and_then(|v| v.parse::().ok()); + /// The room name that this token is for (required) + room_id: Option, - if sequence_number.is_none() && part_number.is_some() { - return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); - } + /// The time at which the token was issued (required) + iat: Option, - if let Some(sequence_number) = sequence_number { - let part_number = part_number.unwrap_or_default(); + /// Used to create single use tokens (optional) + id: Option, - let mut count = 0; + /// The user ID that this token is for (optional) + user_id: Option, +} - loop { - if count > 100 { - return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); - } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct MediaClaims { + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: String, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + idx: Vec, +} - let fields: Vec = global - .redis - .hmget( - &format!("transcoder:{}:{}:state", stream_id, variant_id), - vec![ - "current_segment_idx".to_string(), - "current_fragment_idx".to_string(), - ], - ) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, - ) - })?; +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct ScreenshotClaims { + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + idx: u32, +} - let current_segment_idx: u64 = fields[0].parse::().unwrap_or_default(); - let current_fragment_idx: u64 = fields[1].parse::().unwrap_or_default(); +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct SessionClaims { + id: Ulid, + organization_id: Ulid, + connection_id: Ulid, + room_id: Ulid, + iat: i64, + was_authenticated: bool, +} - if sequence_number > current_segment_idx + 3 { - return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); - } +// https://edge.scuffle.tv///index.m3u8?token= +async fn room_playlist(req: Request) -> Result> { + let global = req.get_global()?; - if sequence_number < current_segment_idx - || (sequence_number == current_segment_idx && part_number < current_fragment_idx) - { - break; + let organization_id = Ulid::from_string(req.param("organization_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid organization_id"))?; + let room_id = Ulid::from_string(req.param("room_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid room_id"))?; + let token = req.uri().query().and_then(|v| { + url::form_urlencoded::parse(v.as_bytes()).find_map(|(k, v)| { + if k == "token" { + Some(v.to_string()) + } else { + None } + }) + }); - count += 1; - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + let token = if let Some(token) = token { + let token: Token = Token::parse_unverified(&token) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid token, could not parse"))?; + + let playback_key_pair_id = Ulid::from_string( + token + .header() + .key_id + .as_ref() + .ok_or((StatusCode::BAD_REQUEST, "invalid token, missing key id"))?, + ) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid token, invalid key id"))?; + + if token.header().algorithm != AlgorithmType::Es384 { + return Err(( + StatusCode::BAD_REQUEST, + "invalid token, invalid algorithm, only ES384 is supported", + ) + .into()); } - } - let playlist: String = global - .redis - .hget( - &format!("transcoder:{}:{}:state", stream_id, variant_id), - "playlist", + if &organization_id + != token.claims().organization_id.as_ref().ok_or(( + StatusCode::BAD_REQUEST, + "invalid token, missing organization id", + ))? + { + return Err(( + StatusCode::BAD_REQUEST, + "invalid token, organization id mismatch", + ) + .into()); + } + + if &room_id + != token + .claims() + .room_id + .as_ref() + .ok_or((StatusCode::BAD_REQUEST, "invalid token, missing room id"))? + { + return Err((StatusCode::BAD_REQUEST, "invalid token, room id mismatch").into()); + } + + let iat = token + .claims() + .iat + .ok_or((StatusCode::BAD_REQUEST, "invalid token, missing iat"))?; + + if iat > chrono::Utc::now().timestamp() { + return Err(( + StatusCode::BAD_REQUEST, + "invalid token, iat is in the future", + ) + .into()); + } + + if iat < (chrono::Utc::now().timestamp()) - 60 { + return Err(( + StatusCode::BAD_REQUEST, + "invalid token, iat is too far in the past", + ) + .into()); + } + + todo!("check the database if the token has been revoked for this userid or id within the last 60 seconds"); + + let keypair: Option = sqlx::query_as( + "SELECT * FROM playback_key_pairs WHERE organization_id = $1 AND id = $2", ) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(playback_key_pair_id)) + .fetch_optional(global.db.as_ref()) .await - .map_err(|e| { + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))?; + + let keypair = + keypair.ok_or((StatusCode::BAD_REQUEST, "invalid token, keypair not found"))?; + + let signing_algo = PKeyWithDigest { + digest: MessageDigest::sha384(), + key: PKey::public_key_from_pem(&keypair.public_key).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to parse public key", + ) + })?, + }; + + Some( + token + .verify_with_key(&signing_algo) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid token, failed to verify"))?, + ) + } else { + None + }; + + let room: Option = sqlx::query_as( + "SELECT * FROM rooms WHERE organization_id = $1 AND id = $2 AND status != $3", + ) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(RoomStatus::Offline) + .fetch_optional(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))?; + + let room = room.ok_or((StatusCode::NOT_FOUND, "room not found"))?; + + let connection_id = Ulid::from( + room.active_ingest_connection_id + .ok_or((StatusCode::NOT_FOUND, "room not found"))?, + ); + + let audio_output = room + .audio_output + .ok_or((StatusCode::NOT_FOUND, "room not found"))?; + + let video_output = room + .video_output + .ok_or((StatusCode::NOT_FOUND, "room not found"))?; + + if room.private && token.is_none() { + return Err(( + StatusCode::UNAUTHORIZED, + "room is private, token is required", + ) + .into()); + } + + let id = Ulid::new(); + + sqlx::query( + r#" + INSERT INTO playback_sessions ( + id, + organization_id, + room_id, + user_id, + playback_key_pair_id, + issued_at, + ip_address, + user_agent, + referer, + origin, + player_version + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11 + ) + "#, + ) + .bind(Uuid::from(id)) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(token.as_ref().and_then(|t| t.claims().user_id.as_ref())) + .bind(token.as_ref().and_then(|t| t.header().key_id.as_ref())) + .bind(token.as_ref().and_then(|t| { + chrono::Utc + .timestamp_opt(t.claims().iat.unwrap(), 0) + .single() + })) + .bind(req.remote_addr().ip().to_string()) + .bind( + req.headers() + .get("user-agent") + .map(|v| v.to_str().unwrap_or_default()), + ) + .bind( + req.headers() + .get("referer") + .map(|v| v.to_str().unwrap_or_default()), + ) + .bind( + req.headers() + .get("origin") + .map(|v| v.to_str().unwrap_or_default()), + ) + .bind( + req.headers() + .get("x-player-version") + .map(|v| v.to_str().unwrap_or_default()), + ) + .execute(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to create session", + ))?; + + let claims = SessionClaims { + id, + organization_id, + connection_id, + room_id, + was_authenticated: token.is_some(), + iat: chrono::Utc::now().timestamp(), + }; + + let key: Hmac = Hmac::new_from_slice(global.config.edge.session_key.as_bytes()) + .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create session key", ) })?; + let session = claims + .sign_with_key(&key) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to sign session"))?; + + #[rustfmt::skip] + let mut manifest = vec_of_strings![ + "#EXTM3U", + "#EXT-X-INDEPENDENT-SEGMENTS", + ]; + + let audio = audio_output.first().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "no audio rendition found", + ))?; + + let audio_rendition = Rendition::from(audio.rendition()); - if playlist.is_empty() { - return Err((StatusCode::NOT_FOUND, "Not found").into()); + manifest.push(format!("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"{audio_rendition}\",DEFAULT=YES,AUTOSELECT=YES,URI=\"/{organization_id}/{room_id}/{session}/{audio_rendition}.m3u8\"", )); + + for video in &video_output { + let video_rendition = Rendition::from(video.rendition()); + manifest.push(format!("#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{},{}\",RESOLUTION={}x{},FRAME-RATE={},AUDIO=\"audio\"", audio.bitrate + video.bitrate, video.codec, audio.codec, video.width, video.height, video.fps)); + manifest.push(format!( + "/{organization_id}/{room_id}/{session}/{video_rendition}.m3u8" + )); } - Ok(Response::builder() - .header("Content-Type", "application/vnd.apple.mpegurl") - .header("Cache-Control", "no-cache") - .body(Body::from(playlist)) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, - ) - })?) + let manifest = manifest.join("\n"); + let mut resp = Response::new(Body::from(manifest)); + resp.headers_mut().insert( + "Content-Type", + "application/vnd.apple.mpegurl".parse().unwrap(), + ); + resp.headers_mut() + .insert("Cache-Control", "no-cache".parse().unwrap()); + + Ok(resp) } -pub async fn master_playlist(req: Request) -> Result> { +async fn session_playlist(req: Request) -> Result> { let global = req.get_global()?; - let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let organization_id = Ulid::from_string(req.param("organization_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid organization_id"))?; - tracing::info!(stream_id = ?stream_id, "master_playlist"); + let session = req.param("session").unwrap(); - let playlist: String = global - .redis - .get(&format!("transcoder:{}:playlist", stream_id)) - .await - .map_err(|e| { + let room_id = Ulid::from_string(req.param("room_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid room_id"))?; + + let rendition: Rendition = req + .param("rendition") + .unwrap() + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid variant_id"))?; + + let session_key: Hmac = Hmac::new_from_slice(global.config.edge.session_key.as_bytes()) + .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create session key", ) })?; - if playlist.is_empty() { - return Err((StatusCode::NOT_FOUND, "Not found").into()); + let session: SessionClaims = session + .verify_with_key(&session_key) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid session"))?; + + if session.organization_id != organization_id { + return Err(( + StatusCode::BAD_REQUEST, + "invalid session, organization_id mismatch", + ) + .into()); } - Ok(Response::builder() - .header("Content-Type", "application/vnd.apple.mpegurl") - .header("Cache-Control", "no-cache") - .body(Body::from(playlist)) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + if session.room_id != room_id { + return Err((StatusCode::BAD_REQUEST, "invalid session, room_id mismatch").into()); + } + + let resp = sqlx::query( + r#" + UPDATE playback_sessions SET + expires_at = NOW() + INTERVAL '10 minutes' + WHERE + id = $1 AND + organization_id = $2 AND + room_id = $3 AND + expires_at > NOW() + "#, + ) + .bind(Uuid::from(session.id)) + .bind(Uuid::from(session.organization_id)) + .bind(Uuid::from(session.room_id)) + .execute(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to update session", + ))?; + + if resp.rows_affected() == 0 { + return Err(( + StatusCode::BAD_REQUEST, + "invalid session, expired or not found", + ) + .into()); + } + + #[derive(Default, Debug)] + struct HlsConfig { + msn: Option, + part: Option, + scuffle_part: Option, + skip: bool, + scuffle_dvr: bool, + } + + let hls_config = req + .uri() + .query() + .map(|v| { + url::form_urlencoded::parse(v.as_bytes()).fold( + HlsConfig::default(), + |mut acc, (key, value)| { + match key.as_ref() { + "_HLS_msn" => { + acc.msn = value.parse::().ok(); + } + "_HLS_part" => { + acc.part = value.parse::().ok(); + } + "_HLS_skip" => { + acc.skip = value == "YES" || value == "v2"; + } + "_SCUFFLE_PART" => { + acc.scuffle_part = value.parse::().ok(); + } + "_SCUFFLE_DVR" => { + acc.scuffle_dvr = value.parse::().unwrap_or_default(); + } + _ => {} + } + + acc + }, ) - })?) -} + }) + .unwrap_or_else(HlsConfig::default); -pub async fn segment(req: Request) -> Result> { - let global = req.get_global()?; + let manifest = global + .metadata_store + .get(keys::rendition_manifest( + organization_id, + room_id, + session.connection_id, + rendition, + )) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to get manifest"))? + .ok_or((StatusCode::NOT_FOUND, "manifest not found"))?; + + let mut manifest = LiveRenditionManifest::decode(manifest).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to decode manifest", + ) + })?; - let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; - let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; - let segment = req.param("segment").unwrap(); - - tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, segment = ?segment, "segment"); - - if segment.contains('.') { - let (segment, part) = segment.split_once('.').unwrap(); - let part_number = part - .parse::() - .map_err(|_| (StatusCode::BAD_REQUEST, "Bad Request"))?; - let sequence_number = segment - .parse::() - .map_err(|_| (StatusCode::BAD_REQUEST, "Bad Request"))?; - - let mut count = 0; - loop { - if count > 10 { - return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + enum BlockStyle { + Hls(u32, u32), + Scuffle(u32), + } + + impl BlockStyle { + fn is_blocked(&self, info: &RenditionInfo) -> bool { + let segment_idx = info.next_segment_idx.saturating_sub(1); + let part_idx = info.next_part_idx.saturating_sub(1); + let segment_part_idx = info.next_segment_part_idx.saturating_sub(1); + + match self { + BlockStyle::Hls(hls_msn, hls_part) => { + segment_idx < *hls_msn + || (segment_idx == *hls_msn && segment_part_idx < *hls_part) + } + BlockStyle::Scuffle(scuffle_part) => part_idx < *scuffle_part, } + } + } - let fields: Vec = global - .redis - .hmget( - &format!("transcoder:{}:{}:state", stream_id, variant_id), - vec![ - "current_segment_idx".to_string(), - "current_fragment_idx".to_string(), - ], - ) + let block_style = match (hls_config.msn, hls_config.part, hls_config.scuffle_part) { + (Some(msn), p, None) => Some(BlockStyle::Hls(msn, p.unwrap_or(0))), + (None, None, Some(p)) => Some(BlockStyle::Scuffle(p)), + (None, None, None) => None, + _ => return Err((StatusCode::BAD_REQUEST, "invalid query params").into()), + }; + + if let Some(block_style) = block_style { + let info = manifest + .info + .as_ref() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "manifest missing info"))?; + + if !manifest.completed && block_style.is_blocked(info) { + // We need to block and wait for the next segment to be available + // before we can serve this request. + let mut watch_manifest = global + .metadata_store + .watch(keys::rendition_manifest( + organization_id, + room_id, + session.connection_id, + rendition, + )) .await - .map_err(|e| { + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to watch manifest", + ))?; + + let now = Instant::now(); + loop { + let entry = watch_manifest + .next() + .timeout(Duration::from_secs(2)) + .await + .map_err_route((StatusCode::BAD_REQUEST, "segment watch time timedout"))? + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "manifest stream closed"))? + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to watch manifest", + ))?; + + manifest = LiveRenditionManifest::decode(entry.value).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to decode manifest", ) })?; - let current_segment_idx: u64 = fields[0].parse::().unwrap_or_default(); - let current_fragment_idx: u64 = fields[1].parse::().unwrap_or_default(); + let info = manifest + .info + .as_ref() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "manifest missing info"))?; - if sequence_number > current_segment_idx + 3 { - return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); - } + if manifest.completed || !block_style.is_blocked(info) { + break; + } - if sequence_number < current_segment_idx - || (sequence_number == current_segment_idx && part_number < current_fragment_idx) - { - break; + if now.elapsed() > Duration::from_secs(3) { + return Err((StatusCode::BAD_REQUEST, "segment watch time timedout").into()); + } } - - count += 1; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } + } - let part: Option = global - .redis - .hget( - &format!("transcoder:{}:{}:{}:data", stream_id, variant_id, segment), - part_number.to_string(), - ) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, - ) - })?; - let Some(part) = part else { - return Err((StatusCode::NOT_FOUND, "Not found").into()); - }; + let info = manifest + .info + .as_ref() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "manifest missing info"))?; - return Ok(Response::builder() - .header("Content-Type", "video/mp4") - .header("Cache-Control", "max-age=31536000") - .body(Body::from(part)) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, - ) - })?); - } + const MAX_SEGMENT_DURATION: u32 = 5; + const TARGET_PART_DURATION: f64 = 0.25; + const PART_HOLD_BACK: f64 = 3.0 * TARGET_PART_DURATION; - let state: Vec = global - .redis - .hmget( - &format!("transcoder:{}:{}:{}:state", stream_id, variant_id, segment), - vec!["ready".to_string(), "fragment_count".to_string()], - ) - .await - .map_err(|e| { + let mut media_sequence = manifest.segments.first().map(|s| s.idx).unwrap_or_default(); + + let media_key: Hmac = Hmac::new_from_slice(global.config.edge.media_key.as_bytes()) + .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create media key", ) })?; - if state[0] != "true" { - return Err((StatusCode::NOT_FOUND, "Not found").into()); + + let init_jwt = MediaClaims { + connection_id: session.connection_id, + idx: vec![], + organization_id, + rendition: rendition.to_string(), + room_id: session.room_id, } + .sign_with_key(&media_key) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to sign init"))?; - let mut data: HashMap = global - .redis - .hgetall(&format!( - "transcoder:{}:{}:{}:data", - stream_id, variant_id, segment - )) + let mut server_control = vec_of_strings![ + format!("PART-HOLD-BACK={PART_HOLD_BACK:.3}"), + "CAN-BLOCK-RELOAD=YES", + ]; + + let mut version = 6; + + let recording: Option = if let Some(id) = manifest.recording_ulid { + sqlx::query_as( + r#" + SELECT + * + FROM recordings + WHERE + id = $1 + AND organization_id = $2 + AND room_id = $3 + AND deleted = FALSE + AND allow_dvr = TRUE + "#, + ) + .bind(Uuid::from(id.to_ulid())) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .fetch_optional(global.db.as_ref()) .await - .map_err(|e| { + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))? + } else { + None + }; + + let can_dvr = if let Some(recording) = &recording { + recording.public || session.was_authenticated + } else { + false + } && hls_config.scuffle_dvr; + + if can_dvr { + server_control.push(format!("CAN-SKIP-UNTIL={:.3}", MAX_SEGMENT_DURATION * 3)); + version = 9; + media_sequence = 0; + } + + let mut playlist = vec_of_strings![ + "#EXTM3U", + format!("#EXT-X-VERSION:{}", version), + format!("#EXT-X-TARGETDURATION:{MAX_SEGMENT_DURATION}"), + format!("#EXT-X-MEDIA-SEQUENCE:{media_sequence}"), + format!("#EXT-X-DISCONTINUITY-SEQUENCE:0"), + format!("#EXT-X-PART-INF:PART-TARGET={TARGET_PART_DURATION:.3}"), + format!("#EXT-X-SERVER-CONTROL:{}", server_control.join(",")), + format!("#EXT-X-MAP:URI=\"/{organization_id}/{room_id}/{init_jwt}.mp4\""), + ]; + + let public_s3_url = if can_dvr && !hls_config.skip { + let recording_id = recording.as_ref().unwrap().id; + + let recording_rendition: RecordingRendition = sqlx::query_as( + r#" + SELECT + r.* + s.public_url as public_url + FROM recording_renditions AS r + WHERE + recording_id = $1 + AND rendition = $2 + INNER JOIN s3_buckets s + ON s.id = r.s3_bucket_id + "#, + ) + .bind(recording_id) + .bind(rendition) + .fetch_one(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))?; + + for (idx, (id, duration)) in recording_rendition + .segment_ids + .iter() + .zip(recording_rendition.segment_durations.iter()) + .enumerate() + { + let id = Ulid::from(*id); + let duration = *duration as f64 / recording_rendition.timescale as f64; + let url = format!( + "{}/{organization_id}/{recording_id}/{rendition}/{idx}.{id}.mp4", + recording_rendition.public_url.as_ref().unwrap() + ); + playlist.push(format!("#EXTINF:{duration:.3},")); + playlist.push(url); + } + + recording_rendition.public_url + } else if can_dvr && hls_config.skip { + playlist.push(format!( + "#EXT-X-SKIP:SKIPPED-SEGMENTS={}", + manifest.segments.first().map(|s| s.idx).unwrap_or_default() + )); + + let recording_id = recording.as_ref().unwrap().id; + + let row = sqlx::query( + r#" + SELECT + s.public_url as public_url + FROM recording_renditions AS r + WHERE + recording_id = $1 + AND rendition = $2 + INNER JOIN s3_buckets s + ON s.id = r.s3_bucket_id + "#, + ) + .bind(recording_id) + .bind(rendition) + .fetch_one(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))?; + + row.try_get::("public_url").ok() + } else { + None + }; + + for segment in &manifest.segments { + if segment.idx >= info.next_segment_idx.saturating_sub(2) { + for part in &segment.parts { + let part_jwt = MediaClaims { + connection_id: session.connection_id, + idx: vec![part.idx], + organization_id, + rendition: rendition.to_string(), + room_id: session.room_id, + } + .sign_with_key(&media_key) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to sign part"))?; + + let duration = part.duration as f64 / manifest.timescale as f64; + + let independent = if part.independent { + ",INDEPENDENT=YES" + } else { + "" + }; + + playlist.push(format!("#EXT-X-PART:DURATION={duration:.3},URI=\"/{organization_id}/{room_id}/{part_jwt}.mp4\"{independent}")); + } + } + + if segment.idx != info.next_segment_idx.saturating_sub(1) || manifest.completed { + let segment_jwt = MediaClaims { + connection_id: session.connection_id, + idx: segment.parts.iter().map(|p| p.idx).collect(), + organization_id, + rendition: rendition.to_string(), + room_id: session.room_id, + } + .sign_with_key(&media_key) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to sign segment"))?; + + let duration = segment.parts.iter().map(|p| p.duration).sum::() as f64 + / manifest.timescale as f64; + if can_dvr { + let public_s3_url = public_s3_url.as_ref().unwrap(); + let recording_id = recording.as_ref().unwrap().id; + let id = segment.id.to_ulid(); + let idx = segment.idx; + playlist.push(format!("#EXT-X-SCUFFLE-DVR:URI=\"{public_s3_url}/{organization_id}/{recording_id}/{rendition}/{idx}.{id}.mp4\"")) + } + playlist.push(format!("#EXTINF:{duration:.3},", duration = duration)); + playlist.push(format!("/{organization_id}/{room_id}/{segment_jwt}.mp4")); + } + } + + if !manifest.completed { + for i in 0..5 { + let part_idx = info.next_part_idx + i; + + let part_jwt = MediaClaims { + connection_id: session.connection_id, + idx: vec![part_idx], + organization_id, + rendition: rendition.to_string(), + room_id: session.room_id, + } + .sign_with_key(&media_key) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to sign part"))?; + + playlist.push(format!("#EXT-X-PRELOAD-HINT:TYPE=PART,SCUFFLE-PART={part_idx},URI=\"/{organization_id}/{room_id}/{part_jwt}.mp4\"")); + } + + for (rendition, info) in manifest.other_info { + let last_msn = info.next_segment_idx.saturating_sub(1); + let last_part = info.next_segment_part_idx.saturating_sub(1); + playlist.push(format!("#EXT-X-RENDITION-REPORT:URI=\"./{rendition}.m3u8\",LAST-MSN={last_msn},LAST-PART={last_part}")); + } + } else { + playlist.push("#EXT-X-ENDLIST".to_string()); + } + + let playlist = playlist.join("\n"); + let mut resp = Response::new(Body::from(playlist)); + resp.headers_mut().insert( + "Content-Type", + "application/vnd.apple.mpegurl".parse().unwrap(), + ); + resp.headers_mut() + .insert("Cache-Control", "no-cache".parse().unwrap()); + + Ok(resp) +} + +async fn room_media(req: Request) -> Result> { + let global = req.get_global()?; + + let organization_id = Ulid::from_string(req.param("organization_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid organization_id"))?; + + let room_id = Ulid::from_string(req.param("room_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid room_id"))?; + + let media = req.param("media").unwrap(); + + let key: Hmac = + Hmac::new_from_slice(global.config.edge.media_key.as_bytes()).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create media key", ) })?; - let mut data_vec: Vec> = vec![]; - for i in 0..state[1].parse::().unwrap_or_default() { - let Some(data) = data.remove(&i.to_string()) else { - return Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into()); - }; + let claims: MediaClaims = media + .verify_with_key(&key) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid media"))?; - data_vec.push(Ok(data)); + if claims.organization_id != organization_id { + return Err(( + StatusCode::BAD_REQUEST, + "invalid media, organization_id mismatch", + ) + .into()); } - Ok(Response::builder() - .header("Content-Type", "video/mp4") - .header("Cache-Control", "max-age=31536000") - .body(Body::wrap_stream(stream::iter(data_vec))) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, - ) - })?) + if claims.room_id != room_id { + return Err((StatusCode::BAD_REQUEST, "invalid media, room_name mismatch").into()); + } + + let keys = match claims.idx.len() { + 0 => vec![keys::init( + organization_id, + room_id, + claims.connection_id, + claims.rendition.parse().unwrap(), + )], + _ => claims + .idx + .iter() + .map(|idx| { + keys::part( + organization_id, + room_id, + claims.connection_id, + claims.rendition.parse().unwrap(), + *idx, + ) + }) + .collect::>(), + }; + + // Streaming response + let mut data = Vec::new(); + + for key in keys { + let mut item = global + .media_store + .get(&key) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to get media"))?; + + item.read_to_end(&mut data) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to read media"))?; + } + + let mut resp = Response::new(Body::from(data)); + resp.headers_mut() + .insert("Content-Type", "video/mp4".parse().unwrap()); + resp.headers_mut() + .insert("Cache-Control", "max-age=31536000".parse().unwrap()); + + Ok(resp) } -pub async fn init_segment(req: Request) -> Result> { +async fn room_screenshot(req: Request) -> Result> { let global = req.get_global()?; - let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; - let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) - .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let organization_id = Ulid::from_string(req.param("organization_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid organization_id"))?; + let room_id = Ulid::from_string(req.param("room_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid room_id"))?; + let token = req.uri().query().and_then(|v| { + url::form_urlencoded::parse(v.as_bytes()).find_map(|(k, v)| { + if k == "token" { + Some(v.to_string()) + } else { + None + } + }) + }); + + let token = if let Some(token) = token { + todo!("validate token"); + + Some(token) + } else { + None + }; + + let room: Option = sqlx::query_as( + "SELECT * FROM rooms WHERE organization_id = $1 AND id = $2 AND status != $3", + ) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(RoomStatus::Offline) + .fetch_optional(global.db.as_ref()) + .await + .map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to query database", + ))?; - tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, "init segment"); + let room = room.ok_or((StatusCode::NOT_FOUND, "room not found"))?; - let part: Option = global - .redis - .get(&format!("transcoder:{}:{}:init", stream_id, variant_id)) + let connection_id = Ulid::from( + room.active_ingest_connection_id + .ok_or((StatusCode::NOT_FOUND, "room not found"))?, + ); + + if room.private && token.is_none() { + return Err(( + StatusCode::UNAUTHORIZED, + "room is private, token is required", + ) + .into()); + } + + // We have permission to see the screenshot. + let manifest = global + .metadata_store + .get(keys::manifest(organization_id, room_id, connection_id)) .await - .map_err(|e| { + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to get manifest"))? + .ok_or((StatusCode::NOT_FOUND, "manifest not found"))?; + + let manifest = LiveManifest::decode(manifest).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to decode manifest", + ) + })?; + + let key: Hmac = + Hmac::new_from_slice(global.config.edge.media_key.as_bytes()).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create media key", ) })?; - let Some(part) = part else { - return Err((StatusCode::NOT_FOUND, "Not found").into()); - }; - Ok(Response::builder() - .header("Content-Type", "video/mp4") - .header("Cache-Control", "max-age=31536000") - .body(Body::from(part)) - .map_err(|e| { + let screenshot = ScreenshotClaims { + connection_id, + idx: manifest.screenshot_idx, + organization_id, + room_id, + } + .sign_with_key(&key) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to sign screenshot", + ) + })?; + + let mut response = Response::new(Body::default()); + + *response.status_mut() = StatusCode::TEMPORARY_REDIRECT; + + let url = format!("/{organization_id}/{room_id}/{screenshot}.jpg"); + + response + .headers_mut() + .insert("Location", url.parse().unwrap()); + + response + .headers_mut() + .insert("Cache-Control", "no-cache".parse().unwrap()); + + Ok(response) +} + +async fn room_screenshot_media(req: Request) -> Result> { + let global = req.get_global()?; + + let organization_id = Ulid::from_string(req.param("organization_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid organization_id"))?; + + let room_id = Ulid::from_string(req.param("room_id").unwrap()) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid room_id"))?; + + let screenshot = req.param("screenshot").unwrap(); + + let key: Hmac = + Hmac::new_from_slice(global.config.edge.media_key.as_bytes()).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - e, + "failed to create media key", ) - })?) + })?; + + let claims: ScreenshotClaims = screenshot + .verify_with_key(&key) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid media"))?; + + if claims.organization_id != organization_id { + return Err(( + StatusCode::BAD_REQUEST, + "invalid media, organization_id mismatch", + ) + .into()); + } + + if claims.room_id != room_id { + return Err((StatusCode::BAD_REQUEST, "invalid media, room_name mismatch").into()); + } + + let key = keys::screenshot(organization_id, room_id, claims.connection_id, claims.idx); + + tracing::debug!(key = %key, "getting screenshot"); + + let mut item = global.media_store.get(&key).await.map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to get screenshot", + ))?; + + let mut buf = Vec::new(); + + item.read_to_end(&mut buf).await.map_err_route(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read screenshot", + ))?; + + let mut resp = Response::new(Body::from(buf)); + resp.headers_mut() + .insert("Content-Type", "image/jpeg".parse().unwrap()); + resp.headers_mut() + .insert("Cache-Control", "max-age=31536000".parse().unwrap()); + + Ok(resp) } pub fn routes(_: &Arc) -> Router { Router::builder() - .get("/:stream_id/:variant_id/index.m3u8", variant_playlist) - .get("/:stream_id/:variant_id/init.mp4", init_segment) - .get("/:stream_id/master.m3u8", master_playlist) - .get("/:stream_id/:variant_id/:segment.mp4", segment) + .get("/:organization_id/:room_id.m3u8", room_playlist) + .get("/:organization_id/:room_id.jpg", room_screenshot) + .get( + "/:organization_id/:room_id/:session/:rendition.m3u8", + session_playlist, + ) + .get("/:organization_id/:room_id/:media.mp4", room_media) + .get( + "/:organization_id/:room_id/:screenshot.jpg", + room_screenshot_media, + ) .build() .expect("failed to build router") } diff --git a/video/edge/src/global.rs b/video/edge/src/global.rs index e9ed09cc..a280ae33 100644 --- a/video/edge/src/global.rs +++ b/video/edge/src/global.rs @@ -1,95 +1,36 @@ +use std::sync::Arc; + use common::context::Context; -use fred::{ - pool::RedisPool, - types::{PerformanceConfig, ReconnectPolicy, RedisConfig, ServerConfig}, -}; use crate::config::AppConfig; pub struct GlobalState { pub config: AppConfig, pub ctx: Context, - pub redis: RedisPool, + pub nats: async_nats::Client, + pub jetstream: async_nats::jetstream::Context, + pub metadata_store: async_nats::jetstream::kv::Store, + pub media_store: async_nats::jetstream::object_store::ObjectStore, + pub db: Arc, } impl GlobalState { - pub fn new(config: AppConfig, ctx: Context, redis: RedisPool) -> Self { - Self { config, ctx, redis } - } -} - -pub fn setup_redis(config: &AppConfig) -> RedisPool { - let mut redis_config = RedisConfig::default(); - let performance = PerformanceConfig::default(); - let policy = ReconnectPolicy::default(); - - redis_config.database = Some(config.redis.database); - redis_config.username = config.redis.username.clone(); - redis_config.password = config.redis.password.clone(); - - redis_config.server = if let Some(sentinel) = &config.redis.sentinel { - let addresses = config - .redis - .addresses - .iter() - .map(|a| { - let mut parts = a.split(':'); - let host = parts.next().expect("no redis host"); - let port = parts - .next() - .expect("no redis port") - .parse() - .expect("failed to parse redis port"); - - (host, port) - }) - .collect::>(); - - ServerConfig::new_sentinel(addresses, sentinel.service_name.clone()) - } else { - let server = config.redis.addresses.first().expect("no redis addresses"); - if config.redis.addresses.len() > 1 { - tracing::warn!("multiple redis addresses, only using first: {}", server); + pub fn new( + config: AppConfig, + ctx: Context, + nats: async_nats::Client, + db: Arc, + metadata_store: async_nats::jetstream::kv::Store, + media_store: async_nats::jetstream::object_store::ObjectStore, + ) -> Self { + Self { + config, + ctx, + jetstream: async_nats::jetstream::new(nats.clone()), + nats, + metadata_store, + media_store, + db, } - - let mut parts = server.split(':'); - let host = parts.next().expect("no redis host"); - let port = parts - .next() - .expect("no redis port") - .parse() - .expect("failed to parse redis port"); - - ServerConfig::new_centralized(host, port) - }; - - redis_config.tls = if let Some(tls) = &config.redis.tls { - let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); - let key = std::fs::read(&tls.key).expect("failed to read redis key"); - let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); - - Some( - fred::native_tls::TlsConnector::builder() - .identity( - native_tls::Identity::from_pkcs8(&cert, &key) - .expect("failed to parse redis cert/key"), - ) - .add_root_certificate( - native_tls::Certificate::from_pem(&ca_cert).expect("failed to parse redis ca"), - ) - .build() - .expect("failed to build redis tls") - .into(), - ) - } else { - None - }; - - RedisPool::new( - redis_config, - Some(performance), - Some(policy), - config.redis.pool_size, - ) - .expect("failed to create redis pool") + } } diff --git a/video/edge/src/grpc/health.rs b/video/edge/src/grpc/health.rs index ebab92a9..8e5682fb 100644 --- a/video/edge/src/grpc/health.rs +++ b/video/edge/src/grpc/health.rs @@ -8,7 +8,7 @@ use async_stream::try_stream; use futures_util::Stream; use tonic::{async_trait, Request, Response, Status}; -use crate::pb::health::{ +use pb::grpc::health::v1::{ health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, }; diff --git a/video/edge/src/grpc/mod.rs b/video/edge/src/grpc/mod.rs index 9c066843..b7a46e00 100644 --- a/video/edge/src/grpc/mod.rs +++ b/video/edge/src/grpc/mod.rs @@ -1,14 +1,12 @@ -use crate::{ - global::GlobalState, - pb::{health::health_server, scuffle::video::transcoder_server}, -}; +use crate::global::GlobalState; + use anyhow::Result; +use pb::grpc::health::v1::health_server; use std::sync::Arc; use tokio::select; use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; pub mod health; -pub mod transcoder; pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); @@ -27,9 +25,6 @@ pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC TLS disabled"); Server::builder() } - .add_service(transcoder_server::TranscoderServer::new( - transcoder::TranscoderServer::new(&global), - )) .add_service(health_server::HealthServer::new(health::HealthServer::new( &global, ))) diff --git a/video/edge/src/grpc/transcoder.rs b/video/edge/src/grpc/transcoder.rs deleted file mode 100644 index c627fb62..00000000 --- a/video/edge/src/grpc/transcoder.rs +++ /dev/null @@ -1,24 +0,0 @@ -#![allow(dead_code)] -// TODO: Remove this once we have a real implementation - -use crate::{global::GlobalState, pb::scuffle::video::transcoder_server}; -use std::sync::{Arc, Weak}; - -use tonic::{async_trait, Status}; - -pub struct TranscoderServer { - global: Weak, -} - -impl TranscoderServer { - pub fn new(global: &Arc) -> Self { - Self { - global: Arc::downgrade(global), - } - } -} - -type Result = std::result::Result; - -#[async_trait] -impl transcoder_server::Transcoder for TranscoderServer {} diff --git a/video/edge/src/main.rs b/video/edge/src/main.rs index 46c18e13..5af032d8 100644 --- a/video/edge/src/main.rs +++ b/video/edge/src/main.rs @@ -1,14 +1,16 @@ -use std::{sync::Arc, time::Duration}; +use std::{str::FromStr, sync::Arc, time::Duration}; use anyhow::Result; -use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use async_nats::ServerAddr; +use common::{context::Context, logging, signal}; +use sqlx::ConnectOptions; +use sqlx_postgres::PgConnectOptions; use tokio::{select, signal::unix::SignalKind, time}; mod config; mod edge; mod global; mod grpc; -mod pb; #[tokio::main] async fn main() -> Result<()> { @@ -22,18 +24,65 @@ async fn main() -> Result<()> { let (ctx, handler) = Context::new(); - let redis = global::setup_redis(&config); - redis.connect(); - - redis - .wait_for_connect() - .timeout(Duration::from_secs(2)) - .await - .expect("failed to connect to redis") - .expect("failed to connect to redis"); - tracing::info!("connected to redis"); - - let global = Arc::new(global::GlobalState::new(config, ctx, redis)); + let nats = { + let mut options = async_nats::ConnectOptions::new() + .connection_timeout(Duration::from_secs(5)) + .name(&config.name) + .retry_on_initial_connect(); + + if let Some(user) = &config.nats.username { + options = options.user_and_password( + user.clone(), + config.nats.password.clone().unwrap_or_default(), + ) + } else if let Some(token) = &config.nats.token { + options = options.token(token.clone()) + } + + if let Some(tls) = &config.nats.tls { + options = options + .require_tls(true) + .add_root_certificates((&tls.ca_cert).into()) + .add_client_certificate((&tls.cert).into(), (&tls.key).into()); + } + + options + .connect( + config + .nats + .servers + .iter() + .map(|s| s.parse::()) + .collect::, _>>()?, + ) + .await? + }; + + let db = Arc::new( + sqlx::PgPool::connect_with( + PgConnectOptions::from_str(&config.database.uri)? + .disable_statement_logging() + .to_owned(), + ) + .await?, + ); + + let jetstream = async_nats::jetstream::new(nats.clone()); + let media_store = jetstream + .get_object_store(config.edge.media_ob_store.clone()) + .await?; + let metadata_store = jetstream + .get_key_value(config.edge.metadata_kv_store.clone()) + .await?; + + let global = Arc::new(global::GlobalState::new( + config, + ctx, + nats, + db, + metadata_store, + media_store, + )); let edge_future = tokio::spawn(edge::run(global.clone())); let grpc_future = tokio::spawn(grpc::run(global.clone())); diff --git a/video/edge/src/pb.rs b/video/edge/src/pb.rs deleted file mode 100644 index 4f4bd310..00000000 --- a/video/edge/src/pb.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(clippy::match_single_binding)] - -pub mod scuffle { - pub mod backend { - tonic::include_proto!("scuffle.backend"); - } - - pub mod types { - tonic::include_proto!("scuffle.types"); - } - - pub mod video { - tonic::include_proto!("scuffle.video"); - } - - pub mod events { - tonic::include_proto!("scuffle.events"); - } -} - -pub mod health { - tonic::include_proto!("grpc.health.v1"); -} diff --git a/video/edge/src/tests/grpc/health.rs b/video/edge/src/tests/grpc/health.rs index a174980e..5b0544ce 100644 --- a/video/edge/src/tests/grpc/health.rs +++ b/video/edge/src/tests/grpc/health.rs @@ -28,14 +28,14 @@ async fn test_grpc_health_check() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -71,10 +71,10 @@ async fn test_grpc_health_watch() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .watch(crate::pb::health::HealthCheckRequest::default()) + .watch(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); @@ -82,7 +82,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); let cancel = handler.cancel(); @@ -90,7 +90,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::NotServing as i32 ); cancel diff --git a/video/edge/src/tests/grpc/tls.rs b/video/edge/src/tests/grpc/tls.rs index b7eb9f83..44f380c4 100644 --- a/video/edge/src/tests/grpc/tls.rs +++ b/video/edge/src/tests/grpc/tls.rs @@ -56,15 +56,15 @@ async fn test_grpc_tls_rsa() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -126,15 +126,15 @@ async fn test_grpc_tls_ec() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() diff --git a/video/ingest/Cargo.toml b/video/ingest/Cargo.toml index 39ededc0..f0de3d14 100644 --- a/video/ingest/Cargo.toml +++ b/video/ingest/Cargo.toml @@ -1,48 +1,51 @@ [package] -name = "ingest" -version = "0.1.0" +name = "video-ingest" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1" -tracing = "0" -native-tls = "0" -tokio-native-tls = "0" -async-trait = "0" -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -hyper = { version = "0", features = ["full"] } -tonic = { version = "0", features = ["tls"] } -prost = "0" -bytes = "1" -futures = "0" -futures-util = "0" -chrono = { version = "0", default-features = false, features = ["clock"] } -serde_json = "1" -uuid = "1" -async-stream = "0" -pnet = "0" -lapin = { version = "2", features = ["native-tls"] } -tokio-executor-trait = "2" -tokio-reactor-trait = "1" +anyhow = "1.0.72" +tracing = "0.1.37" +native-tls = "0.2.11" +tokio-native-tls = "0.3.1" +async-trait = "0.1.72" +tokio = { version = "1.29.1", features = ["full"] } +serde = { version = "1.0.183", features = ["derive"] } +hyper = { version = "0.14.27", features = ["full"] } +tonic = { version = "0.9.2", features = ["tls"] } +prost = "0.11.9" +bytes = "1.4.0" +futures = "0.3.28" +futures-util = "0.3.28" +chrono = { version = "0.4.26", default-features = false, features = ["clock"] } +serde_json = "1.0.104" +uuid = "1.4.1" +ulid = { version = "1.0.0", features = ["uuid"] } +async-stream = "0.3.5" +pnet = "0.34.0" +async-nats = "0.31.0" +tokio-executor-trait = "2.1.1" +tokio-reactor-trait = "1.1.0" +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +sqlx-postgres = "0.7.1" +base64 = "0.21.2" +tokio-stream = "0.1.14" -common = { path = "../../common" } -rtmp = { path = "../protocol/rtmp" } -bytesio = { path = "../bytesio" } -flv = { path = "../container/flv" } -transmuxer = { path = "../transmuxer" } -mp4 = { path = "../container/mp4" } -aac = { path = "../codec/aac" } -config = { path = "../../config/config" } +common = { workspace = true, features = ["default"] } +rtmp = { workspace = true } +bytesio = { workspace = true } +flv = { workspace = true } +transmuxer = { workspace = true } +mp4 = { workspace = true } +aac = { workspace = true } +config = { workspace = true } +pb = { workspace = true } +video-database = { workspace = true } [dev-dependencies] -dotenvy = "0" -portpicker = "0" -serial_test = "2" -tempfile = "3" - -[build-dependencies] -tonic-build = "0" -prost-build = "0" +dotenvy = "0.15.7" +portpicker = "0.1.1" +serial_test = "2.0.0" +tempfile = "3.7.1" diff --git a/video/ingest/build.rs b/video/ingest/build.rs deleted file mode 100644 index 5a5aac82..00000000 --- a/video/ingest/build.rs +++ /dev/null @@ -1,22 +0,0 @@ -const PROTO_DIR: &str = "../../proto"; - -fn main() { - let mut config = prost_build::Config::new(); - - config.protoc_arg("--experimental_allow_proto3_optional"); - config.bytes(["."]); - - tonic_build::configure() - .compile_with_config( - config, - &[ - format!("{}/scuffle/events/ingest.proto", PROTO_DIR), - format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), - format!("{}/scuffle/backend/api.proto", PROTO_DIR), - format!("{}/scuffle/video/ingest.proto", PROTO_DIR), - format!("{}/scuffle/utils/health.proto", PROTO_DIR), - ], - &[PROTO_DIR], - ) - .unwrap(); -} diff --git a/video/ingest/src/config.rs b/video/ingest/src/config.rs index d2450b90..6c502b0b 100644 --- a/video/ingest/src/config.rs +++ b/video/ingest/src/config.rs @@ -1,7 +1,7 @@ -use std::net::SocketAddr; +use std::{net::SocketAddr, time::Duration}; use anyhow::Result; -use common::config::{LoggingConfig, RmqConfig, TlsConfig}; +use common::config::{LoggingConfig, NatsConfig, TlsConfig}; #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] @@ -47,37 +47,54 @@ impl Default for GrpcConfig { #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] -pub struct ApiConfig { - /// The bind address for the API server - pub addresses: Vec, +pub struct IngestConfig { + // NATS subject to send transcoder requests to + pub transcoder_request_subject: String, - /// Resolve interval in seconds (0 to disable) - pub resolve_interval: u64, + /// NATS subject for events + pub events_subject: String, - /// If we should use TLS for the API server - pub tls: Option, + /// The interval in to update the bitrate for a room + pub bitrate_update_interval: Duration, + + /// The maximum time to wait for a transcoder + pub transcoder_timeout: Duration, + + /// Max Bitrate for ingest + pub max_bitrate: u64, + + /// Max bytes between keyframes + pub max_bytes_between_keyframes: u64, + + /// Max time between keyframes + pub max_time_between_keyframes: Duration, } -impl Default for ApiConfig { +impl Default for IngestConfig { fn default() -> Self { Self { - addresses: vec!["localhost:50051".to_string()], - resolve_interval: 30, // 30 seconds - tls: None, + transcoder_request_subject: "transcoder-request".to_string(), + events_subject: "events".to_string(), + bitrate_update_interval: Duration::from_secs(5), + max_bitrate: 12000 * 1024, + max_bytes_between_keyframes: 12000 * 1024 * 5 / 8, + max_time_between_keyframes: Duration::from_secs(10), + transcoder_timeout: Duration::from_secs(60), } } } #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] -pub struct TranscoderConfig { - pub events_subject: String, +pub struct DatabaseConfig { + /// The database URL to use + pub uri: String, } -impl Default for TranscoderConfig { +impl Default for DatabaseConfig { fn default() -> Self { Self { - events_subject: "transcoder".to_string(), + uri: "postgres://root@localhost:5432/scuffle_video".to_string(), } } } @@ -100,14 +117,14 @@ pub struct AppConfig { /// GRPC server configuration pub grpc: GrpcConfig, - /// API client configuration - pub api: ApiConfig, + /// Database configuration + pub database: DatabaseConfig, - /// RMQ configuration - pub rmq: RmqConfig, + /// NATS configuration + pub nats: NatsConfig, - /// Transcoder configuration - pub transcoder: TranscoderConfig, + /// Ingest configuration + pub ingest: IngestConfig, } impl Default for AppConfig { @@ -118,9 +135,9 @@ impl Default for AppConfig { logging: LoggingConfig::default(), rtmp: RtmpConfig::default(), grpc: GrpcConfig::default(), - api: ApiConfig::default(), - rmq: RmqConfig::default(), - transcoder: TranscoderConfig::default(), + database: DatabaseConfig::default(), + nats: NatsConfig::default(), + ingest: IngestConfig::default(), } } } diff --git a/video/ingest/src/connection_manager.rs b/video/ingest/src/connection_manager.rs deleted file mode 100644 index f8563323..00000000 --- a/video/ingest/src/connection_manager.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use bytes::Bytes; -use tokio::sync::{mpsc, RwLock}; -use transmuxer::MediaSegment; -use uuid::Uuid; - -pub struct StreamConnection { - connection_id: Uuid, - channel: mpsc::Sender, -} - -pub enum GrpcRequest { - WatchStream { - id: Uuid, - channel: mpsc::Sender, - }, - ShutdownStream, - TranscoderStarted { - id: Uuid, - }, - TranscoderShuttingDown { - id: Uuid, - }, - TranscoderError { - id: Uuid, - message: String, - fatal: bool, - }, -} - -#[derive(Debug)] -pub enum WatchStreamEvent { - InitSegment(Bytes), - MediaSegment(MediaSegment), - ShuttingDown(bool), -} - -pub struct StreamManager { - streams: RwLock>>, -} - -impl StreamManager { - pub fn new() -> Self { - Self { - streams: RwLock::new(HashMap::new()), - } - } - - pub async fn register_stream( - &self, - stream_id: Uuid, - connection_id: Uuid, - channel: mpsc::Sender, - ) { - let mut streams = self.streams.write().await; - - streams.insert( - stream_id, - Arc::new(StreamConnection { - connection_id, - channel, - }), - ); - } - - pub async fn deregister_stream(&self, stream_id: Uuid, connection_id: Uuid) { - let mut streams = self.streams.write().await; - - let connection = streams.get(&stream_id); - - if let Some(connection) = connection { - if connection.connection_id == connection_id { - streams.remove(&stream_id); - } - } - } - - pub async fn submit_request(&self, stream_id: Uuid, request: GrpcRequest) -> bool { - let connections = self.streams.read().await; - - let Some(connection) = connections.get(&stream_id).cloned() else { - return false; - }; - - // We dont want to hold the lock while we wait for the channel to be ready - drop(connections); - - // We dont care if this fails since if it does fail, - // the channel will be dropped and therefore it will report - // to the caller that the stream is no longer available. - connection.channel.send(request).await.is_ok() - } -} diff --git a/video/ingest/src/define.rs b/video/ingest/src/define.rs new file mode 100644 index 00000000..c660957d --- /dev/null +++ b/video/ingest/src/define.rs @@ -0,0 +1,11 @@ +use pb::scuffle::video::internal::{IngestWatchRequest, IngestWatchResponse}; +use tokio::sync::mpsc; +use tonic::Streaming; +use ulid::Ulid; + +pub struct IncomingTranscoder { + pub ulid: Ulid, + pub message: IngestWatchRequest, + pub streaming: Streaming, + pub transcoder: mpsc::Sender, +} diff --git a/video/ingest/src/global.rs b/video/ingest/src/global.rs index 2c747dc0..4cfd02d9 100644 --- a/video/ingest/src/global.rs +++ b/video/ingest/src/global.rs @@ -1,23 +1,23 @@ +use std::collections::HashMap; use std::net::IpAddr; -use std::{net::SocketAddr, time::Duration}; +use std::net::SocketAddr; +use std::sync::Arc; -use common::{ - context::Context, - grpc::{make_channel, TlsSettings}, -}; -use tonic::transport::{Certificate, Channel, Identity}; +use common::context::Context; +use tokio::sync::mpsc; +use tokio::sync::Mutex; +use ulid::Ulid; -use crate::{ - config::AppConfig, connection_manager::StreamManager, - pb::scuffle::backend::api_client::ApiClient, -}; +use crate::config::AppConfig; +use crate::define::IncomingTranscoder; pub struct GlobalState { pub config: AppConfig, pub ctx: Context, - pub rmq: common::rmq::ConnectionPool, - pub connection_manager: StreamManager, - api_client: ApiClient, + pub db: Arc, + pub nats: async_nats::Client, + pub jetstream: async_nats::jetstream::Context, + pub requests: Mutex>>, } fn get_local_ip() -> IpAddr { @@ -39,31 +39,12 @@ fn get_local_ip() -> IpAddr { } impl GlobalState { - pub fn new(mut config: AppConfig, ctx: Context, rmq: common::rmq::ConnectionPool) -> Self { - let api_channel = make_channel( - config.api.addresses.clone(), - Duration::from_secs(config.api.resolve_interval), - if let Some(tls) = &config.api.tls { - let cert = std::fs::read(&tls.cert).expect("failed to read api cert"); - let key = std::fs::read(&tls.key).expect("failed to read api key"); - let ca = std::fs::read(&tls.ca_cert).expect("failed to read api ca"); - - let ca_cert = Certificate::from_pem(ca); - let identity = Identity::from_pem(cert, key); - - Some(TlsSettings { - ca_cert, - identity, - domain: tls.domain.clone().unwrap_or_default(), - }) - } else { - None - }, - ) - .expect("failed to create api channel"); - - let api_client = ApiClient::new(api_channel); - + pub fn new( + mut config: AppConfig, + db: Arc, + nats: async_nats::Client, + ctx: Context, + ) -> Self { if config.grpc.advertise_address.is_empty() { // We need to figure out what our advertise address is let port = config.grpc.bind_address.port(); @@ -80,13 +61,10 @@ impl GlobalState { Self { config, ctx, - api_client, - rmq, - connection_manager: StreamManager::new(), + db, + jetstream: async_nats::jetstream::new(nats.clone()), + nats, + requests: Default::default(), } } - - pub fn api_client(&self) -> ApiClient { - self.api_client.clone() - } } diff --git a/video/ingest/src/grpc/health.rs b/video/ingest/src/grpc/health.rs index ebab92a9..44045354 100644 --- a/video/ingest/src/grpc/health.rs +++ b/video/ingest/src/grpc/health.rs @@ -8,63 +8,57 @@ use async_stream::try_stream; use futures_util::Stream; use tonic::{async_trait, Request, Response, Status}; -use crate::pb::health::{ +use pb::grpc::health::v1::{ health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, }; +#[derive(Clone)] pub struct HealthServer { global: Weak, } impl HealthServer { - pub fn new(global: &Arc) -> Self { - Self { + pub fn new(global: &Arc) -> health_server::HealthServer { + health_server::HealthServer::new(Self { global: Arc::downgrade(global), - } + }) } -} - -type Result = std::result::Result; - -#[async_trait] -impl health_server::Health for HealthServer { - type WatchStream = Pin> + Send>>; - async fn check(&self, _: Request) -> Result> { + async fn health(&self) -> Result { let serving = self .global .upgrade() .map(|g| !g.ctx.is_done()) .unwrap_or_default(); - Ok(Response::new(HealthCheckResponse { + Ok(HealthCheckResponse { status: if serving { ServingStatus::Serving.into() } else { ServingStatus::NotServing.into() }, - })) + }) + } +} + +type Result = std::result::Result; + +#[async_trait] +impl health_server::Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + Ok(Response::new(self.health().await?)) } async fn watch(&self, _: Request) -> Result> { - let global = self.global.clone(); + let this = self.clone(); let output = try_stream!({ loop { tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - let serving = global - .upgrade() - .map(|g| !g.ctx.is_done()) - .unwrap_or_default(); - - yield HealthCheckResponse { - status: if serving { - ServingStatus::Serving.into() - } else { - ServingStatus::NotServing.into() - }, - }; + yield this.health().await?; } }); diff --git a/video/ingest/src/grpc/ingest.rs b/video/ingest/src/grpc/ingest.rs index 86f70b7e..3b9620a3 100644 --- a/video/ingest/src/grpc/ingest.rs +++ b/video/ingest/src/grpc/ingest.rs @@ -1,32 +1,31 @@ -use crate::{ - connection_manager::{GrpcRequest, WatchStreamEvent}, - global::GlobalState, - pb::scuffle::video::{ - ingest_server, transcoder_event_request, watch_stream_response, ShutdownStreamRequest, - ShutdownStreamResponse, TranscoderEventRequest, TranscoderEventResponse, - WatchStreamRequest, WatchStreamResponse, - }, -}; +use crate::{define::IncomingTranscoder, global::GlobalState}; use std::{ pin::Pin, sync::{Arc, Weak}, + time::Duration, }; use async_stream::try_stream; -use futures::Stream; -use tokio::sync::mpsc; -use tonic::{async_trait, Request, Response, Status}; -use uuid::Uuid; +use common::prelude::FutureTimeout; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status, Streaming}; + +use pb::{ + ext::UlidExt, + scuffle::video::internal::{ + ingest_server, ingest_watch_request, IngestWatchRequest, IngestWatchResponse, + }, +}; pub struct IngestServer { global: Weak, } impl IngestServer { - pub fn new(global: &Arc) -> Self { - Self { + pub fn new(global: &Arc) -> ingest_server::IngestServer { + ingest_server::IngestServer::new(Self { global: Arc::downgrade(global), - } + }) } } @@ -34,141 +33,59 @@ type Result = std::result::Result; #[async_trait] impl ingest_server::Ingest for IngestServer { - type WatchStreamStream = - Pin> + 'static + Send>>; + /// Server streaming response type for the Watch method. + type WatchStream = Pin> + Send + Sync>>; - async fn watch_stream( + async fn watch( &self, - request: Request, - ) -> Result> { - let global = self - .global - .upgrade() - .ok_or_else(|| Status::internal("Global state is gone"))?; + request: Request>, + ) -> Result> { + let global = self.global.upgrade().ok_or_else(|| { + Status::internal("Global state was dropped, cannot handle ingest request") + })?; - let request = request.into_inner(); + let mut request = request.into_inner(); - let request_id = Uuid::parse_str(&request.request_id) - .map_err(|_| Status::invalid_argument("Invalid request ID"))?; - let stream_id = Uuid::parse_str(&request.stream_id) - .map_err(|_| Status::invalid_argument("Invalid stream ID"))?; - - let (channel_tx, mut channel_rx) = mpsc::channel(256); - - let request = GrpcRequest::WatchStream { - id: request_id, - channel: channel_tx, + let Some(message) = request.message().await? else { + return Err(Status::invalid_argument("No message provided")); }; - if !global - .connection_manager - .submit_request(stream_id, request) - .await - { - return Err(Status::not_found("Stream not found")); - } - - let output = try_stream!({ - while let Some(event) = channel_rx.recv().await { - let event = match event { - WatchStreamEvent::InitSegment(data) => WatchStreamResponse { - data: Some(watch_stream_response::Data::InitSegment(data)), - }, - WatchStreamEvent::MediaSegment(ms) => WatchStreamResponse { - data: Some(watch_stream_response::Data::MediaSegment( - watch_stream_response::MediaSegment { - data: ms.data, - keyframe: ms.keyframe, - timestamp: ms.timestamp, - data_type: match ms.ty { - transmuxer::MediaType::Audio => { - watch_stream_response::media_segment::DataType::Audio.into() - } - transmuxer::MediaType::Video => { - watch_stream_response::media_segment::DataType::Video.into() - } - }, - }, - )), - }, - WatchStreamEvent::ShuttingDown(stream_shutdown) => WatchStreamResponse { - data: Some(watch_stream_response::Data::ShuttingDown(stream_shutdown)), - }, - }; - - yield event; - } - }); - - Ok(Response::new(Box::pin(output))) - } - - async fn transcoder_event( - &self, - request: Request, - ) -> Result> { - let global = self - .global - .upgrade() - .ok_or_else(|| Status::internal("Global state is gone"))?; - - let request = request.into_inner(); - - let request_id = Uuid::parse_str(&request.request_id) - .map_err(|_| Status::invalid_argument("Invalid request ID"))?; - let stream_id = Uuid::parse_str(&request.stream_id) - .map_err(|_| Status::invalid_argument("Invalid stream ID"))?; - - let request = match request.event { - Some(transcoder_event_request::Event::Started(_)) => { - GrpcRequest::TranscoderStarted { id: request_id } - } - Some(transcoder_event_request::Event::ShuttingDown(_)) => { - GrpcRequest::TranscoderShuttingDown { id: request_id } - } - Some(transcoder_event_request::Event::Error(error)) => GrpcRequest::TranscoderError { - id: request_id, - message: error.message, - fatal: error.fatal, - }, - None => return Err(Status::invalid_argument("Invalid event")), + let open_req = match &message.message { + Some(ingest_watch_request::Message::Open(message)) => message, + Some(_) => return Err(Status::invalid_argument("Invalid message type")), + None => return Err(Status::invalid_argument("No message provided")), }; - if !global - .connection_manager - .submit_request(stream_id, request) - .await - { - return Err(Status::not_found("Stream not found")); - } + let ulid = open_req.request_id.to_ulid(); - Ok(Response::new(TranscoderEventResponse {})) - } + let Some(handler) = global.requests.lock().await.remove(&ulid) else { + return Err(Status::not_found("No ingest request found with that UUID")); + }; - async fn shutdown_stream( - &self, - request: Request, - ) -> Result> { - let global = self - .global - .upgrade() - .ok_or_else(|| Status::internal("Global state is gone"))?; + let (tx, mut rx) = tokio::sync::mpsc::channel(16); - let request = request.into_inner(); + handler + .send(IncomingTranscoder { + ulid, + message, + streaming: request, + transcoder: tx, + }) + .await + .map_err(|_| Status::internal("Failed to send request to handler"))?; - let stream_id = Uuid::parse_str(&request.stream_id) - .map_err(|_| Status::invalid_argument("Invalid stream ID"))?; + let Ok(Some(message)) = rx.recv().timeout(Duration::from_secs(1)).await else { + return Err(Status::internal("Failed to receive response from handler")); + }; - let request = GrpcRequest::ShutdownStream; + let output = try_stream!({ + yield message; - if !global - .connection_manager - .submit_request(stream_id, request) - .await - { - return Err(Status::not_found("Stream not found")); - } + while let Some(message) = rx.recv().await { + yield message; + } + }); - Ok(Response::new(ShutdownStreamResponse {})) + Ok(Response::new(Box::pin(output))) } } diff --git a/video/ingest/src/grpc/mod.rs b/video/ingest/src/grpc/mod.rs index b05ef631..a3b7cb5e 100644 --- a/video/ingest/src/grpc/mod.rs +++ b/video/ingest/src/grpc/mod.rs @@ -1,14 +1,11 @@ -use crate::{ - global::GlobalState, - pb::{health::health_server, scuffle::video::ingest_server}, -}; +use crate::global::GlobalState; use anyhow::Result; use std::sync::Arc; use tokio::select; use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; -pub mod health; -pub mod ingest; +mod health; +mod ingest; pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); @@ -31,12 +28,8 @@ pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC TLS disabled"); Server::builder() } - .add_service(ingest_server::IngestServer::new(ingest::IngestServer::new( - &global, - ))) - .add_service(health_server::HealthServer::new(health::HealthServer::new( - &global, - ))) + .add_service(health::HealthServer::new(&global)) + .add_service(ingest::IngestServer::new(&global)) .serve_with_shutdown(global.config.grpc.bind_address, async { global.ctx.done().await; }); diff --git a/video/ingest/src/ingest/bytes_tracker.rs b/video/ingest/src/ingest/bytes_tracker.rs new file mode 100644 index 00000000..46742570 --- /dev/null +++ b/video/ingest/src/ingest/bytes_tracker.rs @@ -0,0 +1,39 @@ +use rtmp::ChannelData; + +#[derive(Debug, Default)] +pub struct BytesTracker { + video: u64, + audio: u64, + metadata: u64, + since_keyframe: u64, +} + +impl BytesTracker { + pub fn add(&mut self, data: &ChannelData) { + match data { + ChannelData::Video { data, .. } => self.video += data.len() as u64, + ChannelData::Audio { data, .. } => self.audio += data.len() as u64, + ChannelData::Metadata { data, .. } => self.metadata += data.len() as u64, + } + + self.since_keyframe += data.data().len() as u64; + } + + pub fn total(&self) -> u64 { + self.video + self.audio + self.metadata + } + + pub fn keyframe(&mut self) { + self.since_keyframe = 0; + } + + pub fn since_keyframe(&self) -> u64 { + self.since_keyframe + } + + pub fn clear(&mut self) { + self.video = 0; + self.audio = 0; + self.metadata = 0; + } +} diff --git a/video/ingest/src/ingest/connection.rs b/video/ingest/src/ingest/connection.rs index 49e2c954..ce2249e3 100644 --- a/video/ingest/src/ingest/connection.rs +++ b/video/ingest/src/ingest/connection.rs @@ -1,124 +1,87 @@ +use anyhow::Result; +use base64::Engine; use bytes::Bytes; use bytesio::bytesio::AsyncReadWrite; use chrono::Utc; -use common::prelude::FutureTimeout; use flv::{FlvTag, FlvTagData, FlvTagType}; use futures::Future; -use lapin::{options::BasicPublishOptions, BasicProperties}; -use prost::Message as _; -use rtmp::{ChannelData, DataConsumer, PublishRequest, Session, SessionError}; -use std::{collections::HashMap, net::IpAddr, pin::pin, sync::Arc, time::Duration}; -use tokio::{ - select, - sync::{broadcast, mpsc}, - time::Instant, +use futures_util::StreamExt; +use pb::scuffle::video::{ + internal::{ + events::{organization_event, OrganizationEvent, TranscoderRequest}, + ingest_watch_request, ingest_watch_response, IngestWatchRequest, IngestWatchResponse, + }, + v1::types::Rendition, }; -use tonic::{transport::Channel, Code}; +use prost::Message as _; +use rtmp::{ChannelData, PublishRequest, Session, SessionError}; +use std::{net::IpAddr, pin::pin, sync::Arc, time::Duration}; +use tokio::{select, sync::mpsc, time::Instant}; +use tonic::{Status, Streaming}; use transmuxer::{AudioSettings, MediaSegment, TransmuxResult, Transmuxer, VideoSettings}; +use ulid::Ulid; use uuid::Uuid; +use video_database::room_status::RoomStatus; -use crate::{ - connection_manager::{GrpcRequest, WatchStreamEvent}, - global::GlobalState, - ingest::variants::generate_variants, - pb::scuffle::{ - backend::{ - api_client::ApiClient, - update_live_stream_request::{event, update, Bitrate, Event, Update}, - AuthenticateLiveStreamRequest, NewLiveStreamRequest, StreamReadyState, - UpdateLiveStreamRequest, - }, - events::{self, transcoder_message}, - types::{stream_state, StreamState}, - }, -}; +use crate::{define::IncomingTranscoder, global::GlobalState}; -struct Connection { - id: Uuid, - api_resp: ApiResponse, - data_reciever: DataConsumer, - transmuxer: Transmuxer, - total_video_bytes: u64, - total_audio_bytes: u64, - total_metadata_bytes: u64, +use super::{ + bytes_tracker::BytesTracker, + errors::IngestError, + rtmp_session::{Data, RtmpSession}, + update::{update_db, Update}, +}; - bytes_since_keyframe: u64, +struct Transcoder { + send: mpsc::Sender, + recv: Streaming, +} - api_client: ApiClient, - stream_id_sender: broadcast::Sender, - transcoder_req_rx: mpsc::Receiver, +struct Connection { + id: Ulid, + bytes_tracker: BytesTracker, initial_segment: Option, fragment_list: Vec, - current_transcoder: Option>, // The current main transcoder - current_transcoder_id: Option, // The current main transcoder id + transmuxer: Transmuxer, - next_transcoder: Option>, // The next transcoder to be used - next_transcoder_id: Option, // The next transcoder to be used + current_transcoder_id: Ulid, + next_transcoder_id: Option, - last_transcoder_publish: Instant, + incoming_reciever: mpsc::Receiver, + incoming_sender: mpsc::Sender, - report_shutdown: bool, + update_sender: Option>, + update_recv: Option>, - transcoder_req_tx: mpsc::Sender, -} + current_transcoder: Option, // The current main transcoder + next_transcoder: Option, // The next transcoder to be used + old_transcoder: Option, // The old transcoder that is being replaced -#[derive(Default)] -struct ApiResponse { - id: Uuid, - transcode: bool, - record: bool, - stream_state: Option, -} + last_transcoder_publish: Instant, + last_keyframe: Instant, -const BITRATE_UPDATE_INTERVAL: u64 = 5; -const MAX_TRANSCODER_WAIT_TIME: u64 = 60; -const MAX_BITRATE: u64 = 16000 * 1024; // 16000kbps -const MAX_BYTES_BETWEEN_KEYFRAMES: u64 = MAX_BITRATE * 4 / 8; // 4 seconds of video at max bitrate (ie. 4 seconds between keyframes) which is ~12MB - -async fn update_api( - connection_id: Uuid, - mut update_reciever: mpsc::Receiver>, - mut api_client: ApiClient, - mut stream_id: broadcast::Receiver, -) { - let Ok(stream_id) = stream_id.recv().await else { - return; - }; + error: Option, - while let Some(updates) = update_reciever.recv().await { - let mut success = false; - for _ in 0..5 { - if let Err(e) = api_client - .update_live_stream(UpdateLiveStreamRequest { - connection_id: connection_id.to_string(), - stream_id: stream_id.to_string(), - updates: updates.clone(), - }) - .await - { - tracing::error!(msg = e.message(), status = ?e.code(), "api grpc error"); - tokio::time::sleep(Duration::from_secs(1)).await; - } else { - success = true; - break; - } - } + // The room that is being published to + organization_id: Ulid, + room_id: Ulid, +} - if !success { - tracing::error!("failed to update api with bitrate after 5 retries - giving up"); - return; - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WhichTranscoder { + Current, + Next, + Old, } #[tracing::instrument(skip(global, socket))] pub async fn handle(global: Arc, socket: S, ip: IpAddr) { // We only need a single buffer channel for this session because the entire session is single threaded // and we don't need to worry about buffering. - let (event_producer, mut event_reciever) = mpsc::channel(1); - let (data_producer, data_reciever) = mpsc::channel(1); + let (event_producer, publish) = mpsc::channel(1); + let (data_producer, data) = mpsc::channel(1); let mut session = Session::new(socket, data_producer, event_producer); @@ -129,129 +92,189 @@ pub async fn handle(global: Arc, socket: S, ip: // Essentially this is how tokio's executor works, but we are doing it manually. // This also has the advantage of being completely cleaned up when the function goes out of scope. // If we used a tokio::spawn here, we would have to manually clean up the task. - let mut session_fut = pin!(session.run()); - - let event; + let fut = pin!(session.run()); + let mut session = RtmpSession::new(fut, publish, data); - select! { + let Ok(Some(event)) = select! { _ = global.ctx.done() => { tracing::debug!("Global context closed, closing connection"); return; }, - _ = &mut session_fut => { - tracing::debug!("session closed before publish request"); - return; - }, + d = session.publish() => d, _ = tokio::time::sleep(Duration::from_secs(5)) => { tracing::debug!("session timed out before publish request"); return; }, - e = event_reciever.recv() => { - event = e.expect("event producer closed"); - }, + } else { + tracing::debug!("connection disconnected before publish"); + return; }; - let (transcoder_req_tx, transcoder_req_rx) = mpsc::channel(128); - - let mut connection = Connection { - id: Uuid::new_v4(), // Unique ID for this connection - api_resp: ApiResponse::default(), - data_reciever, - transmuxer: Transmuxer::new(), - total_audio_bytes: 0, - total_metadata_bytes: 0, - total_video_bytes: 0, - api_client: global.api_client(), - stream_id_sender: broadcast::channel(1).0, - transcoder_req_rx, - transcoder_req_tx, - current_transcoder: None, - next_transcoder: None, - initial_segment: None, - fragment_list: Vec::new(), - last_transcoder_publish: Instant::now(), - current_transcoder_id: None, - next_transcoder_id: None, - report_shutdown: true, - bytes_since_keyframe: 0, + let mut connection = match Connection::new(&global, event, ip).await { + Ok(Some(c)) => c, + Ok(None) => return, + Err(e) => { + tracing::error!(error = %e, "failed to create connection"); + return; + } }; - if connection.request_api(&global, event, ip).await { - connection.run(global, session_fut).await; + let clean_disconnect = connection.run(&global, session).await; + + if let Err(err) = connection.cleanup(&global, clean_disconnect).await { + tracing::error!(error = %err, "failed to cleanup connection") } } impl Connection { #[tracing::instrument( level = "debug", - skip(self, global, event, ip), + skip(global, event, _ip), fields(app = %event.app_name, stream = %event.stream_name) )] - async fn request_api( - &mut self, + async fn new( global: &Arc, event: PublishRequest, - ip: IpAddr, - ) -> bool { - let response = self - .api_client - .authenticate_live_stream(AuthenticateLiveStreamRequest { - app_name: event.app_name.clone(), - stream_key: event.stream_name.clone(), - ip_address: ip.to_string(), - ingest_address: global.config.grpc.advertise_address.clone(), - connection_id: self.id.to_string(), - }) - .await; + _ip: IpAddr, + ) -> Result> { + if event.app_name != "live" { + return Ok(None); + } - let response = match response { - Ok(r) => r.into_inner(), - Err(e) => { - match e.code() { - Code::PermissionDenied => { - tracing::debug!(msg = e.message(), "api denied publish request") - } - Code::InvalidArgument => { - tracing::debug!(msg = e.message(), "api rejected publish request") - } - _ => { - tracing::error!(msg = e.message(), status = ?e.code(), "api grpc error"); - } - } - return false; - } + let mut parts = event.stream_name.split('_'); + if parts.next() != Some("live") { + return Ok(None); + } + + let organization_id = match parts.next().and_then(|id| Ulid::from_string(id).ok()) { + Some(id) => id, + None => return Ok(None), }; - let Ok(id) = Uuid::parse_str(&response.stream_id) else { - tracing::error!("api responded with bad uuid: {}", response.stream_id); - return false; + let (room_id, room_secret) = match parts.next().and_then(|name| { + if name.len() > 512 { + return None; + } + + let name = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(name.as_bytes()) + .ok()?; + + let room_name_secret = std::str::from_utf8(&name).ok()?; + + let mut parts = room_name_secret.split('+'); + let room_id = Ulid::from_string(parts.next()?).ok()?; + let room_secret = parts.next()?; + if parts.next().is_some() { + return None; + } + + Some((room_id, room_secret.to_string())) + }) { + Some(name) => name, + None => return Ok(None), }; - if event.response.send(id).is_err() { - tracing::warn!("publish request receiver closed"); - return false; + #[derive(sqlx::FromRow)] + struct Response { + id: Option, } - self.api_resp = ApiResponse { - id, - transcode: response.transcode, - record: response.record, - stream_state: response.state, + let id = Ulid::new(); + + let result: Option = sqlx::query_as( + r#" + UPDATE rooms as new + SET + updated_at = NOW(), + last_live_at = NOW(), + last_disconnected_at = NULL, + active_ingest_connection_id = $1, + status = $2, + video_input = NULL, + audio_input = NULL, + ingest_bitrate = NULL, + video_output = NULL, + audio_output = NULL, + active_recording_id = NULL, + active_recording_config = NULL, + active_transcoding_config = NULL + FROM rooms as old + WHERE + new.organization_id = $3 AND + new.id = $4 AND + new.stream_key = $5 AND + (new.last_live_at < NOW() - INTERVAL '10 seconds' OR new.last_live_at IS NULL) AND + old.organization_id = new.organization_id AND + old.id = new.id AND + old.stream_key = new.stream_key + RETURNING old.active_ingest_connection_id as id + "#, + ) + .bind(Uuid::from(id)) + .bind(RoomStatus::Offline) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(&room_secret) + .fetch_optional(global.db.as_ref()) + .await?; + + let Some(result) = result else { + tracing::debug!("failed to find room"); + return Ok(None); }; - true + if let Some(old_id) = result.id { + if let Err(err) = global + .nats + .publish( + format!("ingest.{}.disconnect", Ulid::from(old_id)), + Bytes::new(), + ) + .await + { + tracing::error!(error = %err, "failed to publish disconnect event"); + } + } + + event.response.send(id.into()).ok(); + + let (update_sender, update_reciever) = mpsc::channel(15); + let (incoming_sender, incoming_reciever) = mpsc::channel(15); + + Ok(Some(Connection { + id, + transmuxer: Transmuxer::new(), + bytes_tracker: BytesTracker::default(), + current_transcoder_id: Ulid::nil(), + current_transcoder: None, + next_transcoder_id: None, + old_transcoder: None, + next_transcoder: None, + initial_segment: None, + fragment_list: Vec::new(), + last_transcoder_publish: Instant::now(), + last_keyframe: Instant::now(), + update_sender: Some(update_sender), + update_recv: Some(update_reciever), + incoming_sender, + incoming_reciever, + organization_id, + room_id, + error: None, + })) } #[tracing::instrument( level = "info", - skip(self, global, session_fut), - fields(id = %self.api_resp.id, transcode = self.api_resp.transcode, record = self.api_resp.record) + skip(self, global, session), + fields(organization_id = %self.organization_id, room_id = %self.room_id) )] - async fn run> + Send + Unpin>( + async fn run<'a>( &mut self, - global: Arc, - session_fut: F, - ) { + global: &Arc, + mut session: RtmpSession<'a, impl Future>>, + ) -> bool { tracing::info!("new publish request"); // At this point we have a stream that is publishing to us @@ -259,187 +282,372 @@ impl Connection { // The run future will close when the connection is closed or an error occurs // The data receiver will never close, because the Session object is always in scope. - let mut bitrate_update_interval = tokio::time::interval(Duration::from_secs(5)); + let mut bitrate_update_interval = + tokio::time::interval(global.config.ingest.bitrate_update_interval); bitrate_update_interval.tick().await; // Skip the first tick (resolves instantly) - let (update_channel, update_reciever) = mpsc::channel(10); - - let mut session_fut = session_fut; - - let mut api_update_fut = pin!(update_api( + let mut db_update_fut = pin!(update_db( + global.clone(), self.id, - update_reciever, - self.api_client.clone(), - self.stream_id_sender.subscribe() + self.organization_id, + self.room_id, + self.update_recv.take().unwrap(), )); let mut next_timeout = Instant::now() + Duration::from_secs(2); let mut clean_shutdown = false; - // We need to keep track of whether the api update failed, so we can - // not poll it again if its finished. (this will panic if we poll it again) - let mut api_update_failed = false; + + let mut conn_id_sub = match global + .nats + .subscribe(format!("ingest.{}.disconnect", self.id)) + .await + { + Ok(sub) => sub, + Err(e) => { + tracing::error!(error = %e, "failed to subscribe to disconnect subject"); + + self.error = Some(IngestError::FailedToSubscribe); + + return false; + } + }; while select! { _ = global.ctx.done() => { tracing::debug!("Global context closed, closing connection"); + self.error = Some(IngestError::IngestShutdown); + false }, - r = &mut session_fut => { - tracing::debug!("session closed before publish request"); - match r { - Ok(clean) => clean_shutdown = clean, - Err(e) => tracing::error!("Connection error: {}", e), - } + d = session.data() => { + match d { + Err(e) => { + tracing::error!(error = %e, "session error"); - false + self.error = Some(IngestError::RtmpConnectionError); + + false + }, + Ok(Data::Data(data)) => { + next_timeout = Instant::now() + Duration::from_secs(2); + self.on_data(global, data.expect("data producer closed")).await + } + Ok(Data::Closed(c)) => { + clean_shutdown = c; + + false + } + } }, - data = self.data_reciever.recv() => { - next_timeout = Instant::now() + Duration::from_secs(2); - self.on_data(&update_channel, &global, data.expect("data producer closed")).await + m = conn_id_sub.next() => { + if m.is_none() { + tracing::error!("connection id subject closed"); + + self.error = Some(IngestError::SubscriptionClosedUnexpectedly); + + false + } else { + tracing::debug!("disconnect requested"); + + self.error = Some(IngestError::DisconnectRequested); + clean_shutdown = true; + + false + } }, - _ = bitrate_update_interval.tick() => self.on_bitrate_update(&update_channel), + _ = bitrate_update_interval.tick() => self.on_bitrate_update(global), _ = tokio::time::sleep_until(next_timeout) => { tracing::debug!("session timed out during data"); + + self.error = Some(IngestError::RtmpConnectionTimeout); + false }, - _ = &mut api_update_fut => { + _ = &mut db_update_fut => { tracing::error!("api update future failed"); - api_update_failed = true; + + self.error = Some(IngestError::FailedToUpdateBitrate); + false } - event = self.transcoder_req_rx.recv() => self.on_grpc_request(&update_channel, &global, event.expect("transcoder closed")).await, + Some(msg) = async { + if let Some(transcoder) = self.current_transcoder.as_mut() { + Some(transcoder.recv.message().await) + } else { + None + } + } => { + // handle message from transcoder + self.handle_transcoder_message(global, msg, WhichTranscoder::Current).await + } + Some(msg) = async { + if let Some(transcoder) = self.next_transcoder.as_mut() { + Some(transcoder.recv.message().await) + } else { + None + } + } => { + // handle message from transcoder + self.handle_transcoder_message(global, msg, WhichTranscoder::Next).await + } + Some(msg) = async { + if let Some(transcoder) = self.old_transcoder.as_mut() { + Some(transcoder.recv.message().await) + } else { + None + } + } => { + // handle message from transcoder + self.handle_transcoder_message(global, msg, WhichTranscoder::Old).await + } + event = self.incoming_reciever.recv() => self.handle_incoming_request(event.expect("transcoder closed")).await, } {} - if let Some(transcoder) = self.current_transcoder.take() { - transcoder - .send(WatchStreamEvent::ShuttingDown(true)) - .await - .ok(); - } + self.update_sender.take(); - if let Some(transcoder) = self.next_transcoder.take() { - transcoder - .send(WatchStreamEvent::ShuttingDown(true)) - .await - .ok(); - } + tracing::info!(clean = clean_shutdown, "connection closed",); - if self.initial_segment.is_none() { - self.stream_id_sender.send(self.api_resp.id).ok(); - } + clean_shutdown + } - // Release the connection from the global state - // if it was never stored in the first place, this will do nothing. - global - .connection_manager - .deregister_stream(self.api_resp.id, self.id) - .await; - - if self.report_shutdown && !api_update_failed { - select! { - r = update_channel.send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::ReadyState(if clean_shutdown { - StreamReadyState::Stopped - } else { - StreamReadyState::StoppedResumable - } as i32)), - }]) => { - if r.is_err() { - tracing::error!("api update channel blocked"); + async fn handle_transcoder_message( + &mut self, + global: &Arc, + msg: Result, Status>, + transcoder: WhichTranscoder, + ) -> bool { + match msg { + Ok(Some(msg)) => { + match msg.message { + Some(ingest_watch_request::Message::Shutdown(shutdown)) => { + match ingest_watch_request::Shutdown::from_i32(shutdown).unwrap_or_default() + { + ingest_watch_request::Shutdown::Request => {} + ingest_watch_request::Shutdown::Complete => { + if transcoder == WhichTranscoder::Old { + self.old_transcoder = None; + if let Some(transcoder) = &mut self.current_transcoder { + if transcoder + .send + .send(IngestWatchResponse { + message: Some( + ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready as i32, + ), + ), + }) + .await + .is_err() + { + tracing::error!( + "failed to send ready message to transcoder" + ); + } else { + return true; + } + } else { + return true; + } + } else { + tracing::warn!( + "transcoder sent shutdown message before we requested it" + ); + } + } + } + } + _ => { + tracing::warn!("transcoder sent an unknwon message"); + return true; } - }, - _ = &mut api_update_fut => { - tracing::error!("api update future failed"); + } + + if transcoder == WhichTranscoder::Next { + self.next_transcoder_id = None; + self.next_transcoder = None; } } + Err(_) | Ok(None) => { + tracing::warn!("transcoder seems to have disconnected unexpectedly"); + + match transcoder { + WhichTranscoder::Current => { + self.current_transcoder = None; + self.current_transcoder_id = Ulid::nil(); + match sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + status = $1 + WHERE + organization_id = $2 AND + id = $3 AND + active_ingest_connection_id = $4 + "#, + ) + .bind(RoomStatus::WaitingForTranscoder) + .bind(Uuid::from(self.organization_id)) + .bind(Uuid::from(self.room_id)) + .bind(Uuid::from(self.id)) + .execute(global.db.as_ref()) + .await + { + Ok(r) => { + if r.rows_affected() != 1 { + tracing::error!("failed to update room status"); + + self.error = Some(IngestError::FailedToUpdateRoom); + + return false; + } + } + Err(e) => { + tracing::error!(error = %e, "failed to update room status"); + + self.error = Some(IngestError::FailedToUpdateRoom); + + return false; + } + } + } + WhichTranscoder::Old => { + self.old_transcoder = None; + if let Some(transcoder) = &mut self.current_transcoder { + if let Err(err) = transcoder + .send + .send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready as i32, + )), + }) + .await + { + tracing::error!(error = %err, "failed to send ready message to transcoder"); + } else { + return true; + } + } else { + return true; + } + } + WhichTranscoder::Next => { + self.next_transcoder_id = None; + self.next_transcoder = None; + } + } + } + } + + if matches!(transcoder, WhichTranscoder::Current | WhichTranscoder::Next) { + self.request_transcoder(global).await + } else { + true } + } - drop(update_channel); + async fn handle_incoming_request(&mut self, event: IncomingTranscoder) -> bool { + let Some(init_segment) = &self.initial_segment else { + tracing::error!("out of order events, requested transcoder before init segment"); + return false; + }; - if !api_update_failed { - // Wait for the api update future to finish - if api_update_fut - .timeout(Duration::from_secs(5)) - .await + if Some(event.ulid) != self.next_transcoder_id { + tracing::warn!("got incoming request from transcoder that we didn't request"); + return true; + } + + if event + .transcoder + .try_send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + r#type: ingest_watch_response::media::Type::Init.into(), + data: init_segment.clone(), + keyframe: false, + }, + )), + }) + .is_err() + { + tracing::warn!("transcoder disconnected before we could send init segment"); + return true; + } + + if self.current_transcoder.is_none() && !self.fragment_list.is_empty() { + if event + .transcoder + .try_send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), + }) .is_err() { - tracing::error!("api update future timed out"); + tracing::warn!("transcoder disconnected before we could send init segment"); + return true; } + + self.current_transcoder = Some(Transcoder { + recv: event.streaming, + send: event.transcoder, + }); + self.current_transcoder_id = self.next_transcoder_id.take().unwrap(); + } else { + self.next_transcoder = Some(Transcoder { + recv: event.streaming, + send: event.transcoder, + }); } - tracing::info!(clean = clean_shutdown, "connection closed",); + true } - async fn request_transcoder( - &mut self, - update_channel: &mpsc::Sender>, - global: &Arc, - ) -> bool { + fn send_update(&mut self, update: Update) -> bool { + if let Some(sender) = &mut self.update_sender { + sender.try_send(update).is_ok() + } else { + false + } + } + + async fn request_transcoder(&mut self, global: &Arc) -> bool { // If we already have a request pending, then we don't need to request another one. if self.next_transcoder_id.is_some() { return true; } - let request_id = Uuid::new_v4(); + let request_id = Ulid::new(); self.next_transcoder_id = Some(request_id); - let channel = match global.rmq.aquire().timeout(Duration::from_secs(1)).await { - Ok(Ok(channel)) => channel, - Ok(Err(e)) => { - tracing::error!("failed to aquire channel: {}", e); - return false; - } - Err(_) => { - tracing::error!("failed to aquire channel: timed out"); - return false; - } - }; - - if let Err(e) = channel - .basic_publish( - "", - &global.config.transcoder.events_subject, - BasicPublishOptions::default(), - events::TranscoderMessage { - id: request_id.to_string(), - timestamp: Utc::now().timestamp() as u64, - data: Some(transcoder_message::Data::NewStream( - events::TranscoderMessageNewStream { - request_id: request_id.to_string(), - stream_id: self.api_resp.id.to_string(), - ingest_address: global.config.grpc.advertise_address.clone(), - state: self.api_resp.stream_state.clone(), - }, - )), + global + .requests + .lock() + .await + .insert(request_id, self.incoming_sender.clone()); + + if let Err(err) = global + .nats + .publish( + global.config.ingest.transcoder_request_subject.clone(), + TranscoderRequest { + organization_id: Some(self.organization_id.into()), + room_id: Some(self.room_id.into()), + request_id: Some(request_id.into()), + connection_id: Some(self.id.into()), + grpc_endpoint: global.config.grpc.advertise_address.clone(), } .encode_to_vec() - .as_slice(), - BasicProperties::default() - .with_message_id(request_id.to_string().into()) - .with_content_type("application/octet-stream".into()) - .with_expiration("60000".into()), + .into(), ) .await { - tracing::error!("failed to publish to jetstream: {}", e); - return false; - } + tracing::error!(error = %err, "failed to publish transcoder request"); + + self.error = Some(IngestError::FailedToRequestTranscoder); - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Requested Transcoder".to_string(), - message: "Requested a transcoder to be assigned to this stream".to_string(), - level: event::Level::Info as i32, - })), - }]) - .is_err() - { - tracing::error!("failed to send update to api"); return false; } @@ -448,359 +656,137 @@ impl Connection { true } - async fn on_grpc_request( - &mut self, - update_channel: &mpsc::Sender>, - global: &Arc, - req: GrpcRequest, - ) -> bool { - // There are 2 possible events that happen here, either we already have a transcoder in the current_transcoder field - // Or we don't. If we do then we want to set this transcoder as the next transcoder, and when a keyframe is received - // The state will be updated to the next transcoder. - // If we don't have a transcoder, then we want to set the current transcoder and provide it with the data from the fragment list. - - let Some(init_segment) = &self.initial_segment else { - return false; - }; - - match req { - GrpcRequest::ShutdownStream => { - tracing::info!("shutdown stream request"); - self.report_shutdown = false; - return false; - } - GrpcRequest::TranscoderStarted { id } => { - tracing::info!("transcoder started: {}", id); - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::ReadyState(StreamReadyState::Ready as i32)), - }]) - .is_err() - { - tracing::error!("api update channel blocked"); - return false; - } - } - GrpcRequest::TranscoderError { - id, - message, - fatal: _, - } => { - if self.current_transcoder_id == Some(id) || self.next_transcoder_id == Some(id) { - tracing::error!("transcoder failed: {}", message); - - // When we report a state failed we dont need to report the shutdown to the API. - // This is because the API will already know that the stream has been dropped. - self.report_shutdown = false; - - if update_channel - .try_send(vec![ - Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Transcoder Error".to_string(), - message, - level: event::Level::Error as i32, - })), - }, - Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::ReadyState( - StreamReadyState::Failed as i32, - )), - }, - ]) - .is_err() - { - tracing::error!("api update channel blocked"); - return false; - } - - return false; - } else { - tracing::warn!("transcoder request failure id mismatch"); - } - } - GrpcRequest::TranscoderShuttingDown { id } => { - if self.current_transcoder_id == Some(id) { - tracing::info!("transcoder shutting down"); - return self.request_transcoder(update_channel, global).await; - } else if self.next_transcoder_id == Some(id) { - tracing::warn!("next transcoder shutting down"); - if let Some(transcoder) = self.next_transcoder.take() { - transcoder - .send(WatchStreamEvent::ShuttingDown(false)) - .await - .ok(); - } - self.next_transcoder = None; - self.next_transcoder_id = None; - return self.request_transcoder(update_channel, global).await; - } else { - tracing::warn!("transcoder request failure id mismatch"); - } - } - GrpcRequest::WatchStream { id, channel } => { - if self.next_transcoder_id != Some(id) { - // This is a request for a transcoder that we don't care about. - tracing::warn!("transcoder request id mismatch"); - return true; - } - - if self.next_transcoder.is_some() { - // If this happens something has gone wrong, we should never have 3 transcoders. - tracing::warn!("new transcoder set while new transcoder is already pending"); - return true; - } - - if self.current_transcoder.is_some() || self.fragment_list.is_empty() { - if channel - .send(WatchStreamEvent::InitSegment(init_segment.clone())) - .await - .is_err() - { - // It seems the transcoder has already closed. - tracing::warn!("new transcoder closed during initialization"); - return true; - } - - self.next_transcoder = Some(channel); - } else { - // We don't have a transcoder, so we can just set the current transcoder. - if channel - .send(WatchStreamEvent::InitSegment(init_segment.clone())) - .await - .is_err() - { - // It seems the transcoder has already closed. - tracing::warn!("transcoder closed during initialization"); - return self.request_transcoder(update_channel, global).await; - } - - for fragment in &self.fragment_list { - if channel - .send(WatchStreamEvent::MediaSegment(fragment.clone())) - .await - .is_err() - { - // It seems the transcoder has already closed. - tracing::warn!("transcoder closed during initialization"); - return self.request_transcoder(update_channel, global).await; - } - } - - self.fragment_list.clear(); - self.next_transcoder_id = None; - self.current_transcoder_id = Some(id); - self.current_transcoder = Some(channel); - } - } - } - - true - } - async fn on_init_segment( &mut self, - update_channel: &mpsc::Sender>, global: &Arc, video_settings: &VideoSettings, audio_settings: &AudioSettings, init_data: Bytes, ) -> bool { - let new_stream_state = - generate_variants(video_settings, audio_settings, self.api_resp.transcode); + self.initial_segment = Some(init_data); - // We can now at this point decide what we want to do with the stream. - // What variants should be transcoded, ect... - if let Some(old_variants) = self.api_resp.stream_state.take() { - let mut can_resume = true; + let video_settings = pb::scuffle::video::v1::types::VideoConfig { + bitrate: video_settings.bitrate as i64, + codec: video_settings.codec.to_string(), + fps: video_settings.framerate as i32, + height: video_settings.height as i32, + width: video_settings.width as i32, + rendition: Rendition::VideoSource.into(), + } + .encode_to_vec(); + + let audio_settings = pb::scuffle::video::v1::types::AudioConfig { + bitrate: audio_settings.bitrate as i64, + channels: audio_settings.channels as i32, + codec: audio_settings.codec.to_string(), + sample_rate: audio_settings.sample_rate as i32, + rendition: Rendition::AudioSource.into(), + } + .encode_to_vec(); + + match sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + status = $1, + video_input = $2, + audio_input = $3 + WHERE + organization_id = $4 AND + id = $5 AND + active_ingest_connection_id = $6 + "#, + ) + .bind(RoomStatus::WaitingForTranscoder) + .bind(video_settings) + .bind(audio_settings) + .bind(Uuid::from(self.organization_id)) + .bind(Uuid::from(self.room_id)) + .bind(Uuid::from(self.id)) + .execute(global.db.as_ref()) + .await + { + Ok(r) => { + if r.rows_affected() != 1 { + tracing::error!("failed to update room status"); - fn make_map( - state: &StreamState, - ) -> HashMap<(&str, &str), (&stream_state::Variant, Vec<&stream_state::Transcode>)> - { - let transcode_state_map = state - .transcodes - .iter() - .map(|s| (s.id.as_str(), s)) - .collect::>(); - - state - .variants - .iter() - .map(|v| { - let states = v - .transcode_ids - .iter() - .map(|id| *transcode_state_map.get(id.as_str()).unwrap()) - .collect::>(); - ((v.group.as_str(), v.name.as_str()), (v, states)) - }) - .collect() - } + self.error = Some(IngestError::FailedToUpdateRoom); - let new_map = make_map(&new_stream_state); - let mut old_map = make_map(&old_variants); - - for (key, (_, new_transcode_states)) in new_map.iter() { - if let Some((_, mut old_transcode_states)) = old_map.remove(key) { - if new_transcode_states.iter().all(|new| { - let pos = old_transcode_states.iter().position(|old| { - new.copy == old.copy - && new.codec == old.codec - && new.settings == old.settings - }); - - if let Some(pos) = pos { - old_transcode_states.remove(pos); - true - } else { - false - } - }) && old_transcode_states.is_empty() - { - // This is resumable, so we can just continue. - continue; - } + return false; } - - // If we get here, we need to start a new transcode. - tracing::info!("new variant detected, starting new transcode"); - can_resume = false; - break; } + Err(e) => { + tracing::error!(error = %e, "failed to update room status"); - can_resume = can_resume && old_map.is_empty(); - - if can_resume { - self.api_resp.stream_state = Some(old_variants); - } else { - // Report to API to get a new stream id. - // This is because the variants have changed and therefore the client player wont be able to resume. - // We need to get a new stream id so that the player can start a new session. - - let response = match self - .api_client - .new_live_stream(NewLiveStreamRequest { - old_stream_id: self.api_resp.id.to_string(), - state: Some(new_stream_state.clone()), - }) - .await - { - Ok(response) => response.into_inner(), - Err(e) => { - tracing::error!("Failed to report new stream to API: {}", e); - return false; - } - }; - - let Ok(stream_id) = response.stream_id.parse() else { - tracing::error!("invalid stream id from API"); - return false; - }; + self.error = Some(IngestError::FailedToUpdateRoom); - self.api_resp.id = stream_id; - self.api_resp.stream_state = Some(new_stream_state); + return false; } - } else if let Err(e) = self - .api_client - .update_live_stream(UpdateLiveStreamRequest { - stream_id: self.api_resp.id.to_string(), - connection_id: self.id.to_string(), - updates: vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::State(new_stream_state.clone())), - }], - }) - .await - { - tracing::error!("Failed to report new stream to API: {}", e); - return false; - } else { - self.api_resp.stream_state = Some(new_stream_state); } - // At this point now we need to create a new job for a transcoder to pick up and start transcoding. - global - .connection_manager - .register_stream(self.api_resp.id, self.id, self.transcoder_req_tx.clone()) - .await; - - self.initial_segment = Some(init_data); - - if !self.request_transcoder(update_channel, global).await { - return false; + if let Err(err) = global + .nats + .publish( + format!( + "{}.{}", + global.config.ingest.events_subject, self.organization_id + ), + OrganizationEvent { + timestamp: Utc::now().timestamp_micros(), + id: Some(self.organization_id.into()), + event: Some(organization_event::Event::RoomLive( + organization_event::RoomLive { + connection_id: Some(self.id.into()), + room_id: Some(self.room_id.into()), + }, + )), + } + .encode_to_vec() + .into(), + ) + .await + { + tracing::error!(error = %err, "failed to publish disconnect event"); } - // Respond to the rest of the session that we have a stream id and are ready to start streaming. - self.stream_id_sender.send(self.api_resp.id).is_ok() + self.request_transcoder(global).await } - async fn on_data( - &mut self, - update_channel: &mpsc::Sender>, - global: &Arc, - data: ChannelData, - ) -> bool { - if self.bytes_since_keyframe > MAX_BYTES_BETWEEN_KEYFRAMES { - tracing::error!("keyframe interval exceeded"); - - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Keyframe Interval Reached".to_string(), - level: event::Level::Error as i32, - message: "Waited too long without a keyframe, dropping stream".to_string(), - })), - }]) - .is_err() - { - tracing::error!("failed to keyframe interval reached"); - } + async fn on_data(&mut self, global: &Arc, data: ChannelData) -> bool { + self.bytes_tracker.add(&data); + + if self.bytes_tracker.since_keyframe() >= global.config.ingest.max_bytes_between_keyframes { + self.error = Some(IngestError::KeyframeBitrateDistance( + self.bytes_tracker.since_keyframe(), + global.config.ingest.max_bytes_between_keyframes, + )); return false; } - if (self.total_video_bytes + self.total_audio_bytes + self.total_metadata_bytes) - >= MAX_BITRATE * BITRATE_UPDATE_INTERVAL / 8 + if self.bytes_tracker.total() * 8 + >= global.config.ingest.max_bitrate + * global.config.ingest.bitrate_update_interval.as_secs() { - tracing::error!("bitrate limit reached"); - - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Bitrate Limit Reached".to_string(), - level: event::Level::Error as i32, - message: format!( - "Reached bitrate limit of {}kbps for stream", - (self.total_video_bytes - + self.total_audio_bytes - + self.total_metadata_bytes) - / 1024, - ), - })), - }]) - .is_err() - { - tracing::error!("failed to send bitrate limit reached event"); - } + self.error = Some(IngestError::BitrateLimit( + self.bytes_tracker.total() / global.config.ingest.bitrate_update_interval.as_secs() + * 8, + global.config.ingest.max_bitrate, + )); return false; } match data { ChannelData::Video { data, timestamp } => { - self.total_video_bytes += data.len() as u64; - self.bytes_since_keyframe += data.len() as u64; - let data = match FlvTagData::demux(FlvTagType::Video as u8, data) { Ok(data) => data, Err(e) => { tracing::error!(error = %e, "demux error"); + + self.error = Some(IngestError::VideoDemux); + return false; } }; @@ -812,13 +798,13 @@ impl Connection { }); } ChannelData::Audio { data, timestamp } => { - self.total_audio_bytes += data.len() as u64; - self.bytes_since_keyframe += data.len() as u64; - let data = match FlvTagData::demux(FlvTagType::Audio as u8, data) { Ok(data) => data, Err(e) => { tracing::error!(error = %e, "demux error"); + + self.error = Some(IngestError::AudioDemux); + return false; } }; @@ -829,14 +815,14 @@ impl Connection { stream_id: 0, }); } - ChannelData::MetaData { data, timestamp } => { - self.total_metadata_bytes += data.len() as u64; - self.bytes_since_keyframe += data.len() as u64; - + ChannelData::Metadata { data, timestamp } => { let data = match FlvTagData::demux(FlvTagType::ScriptData as u8, data) { Ok(data) => data, Err(e) => { tracing::error!(error = %e, "demux error"); + + self.error = Some(IngestError::MetadataDemux); + return false; } }; @@ -856,44 +842,28 @@ impl Connection { audio_settings, data, })) => { - if video_settings.bitrate as u64 + audio_settings.bitrate as u64 >= MAX_BITRATE { - tracing::error!("bitrate limit reached"); - - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Bitrate Limit Reached".to_string(), - level: event::Level::Error as i32, - message: format!( - "Reached bitrate limit of {}kbps for stream", - video_settings.bitrate + audio_settings.bitrate - ), - })), - }]) - .is_err() - { - tracing::error!("failed to send bitrate limit reached event"); - } + let bitrate = video_settings.bitrate as u64 + audio_settings.bitrate as u64; + if bitrate >= global.config.ingest.max_bitrate { + self.error = Some(IngestError::BitrateLimit( + bitrate, + global.config.ingest.max_bitrate, + )); return false; } - self.on_init_segment( - update_channel, - global, - &video_settings, - &audio_settings, - data, - ) - .await + self.on_init_segment(global, &video_settings, &audio_settings, data) + .await } Ok(Some(TransmuxResult::MediaSegment(segment))) => { - self.on_media_segment(update_channel, global, segment).await + self.on_media_segment(global, segment).await } Ok(None) => true, Err(e) => { - tracing::error!("error muxing packet: {}", e); + tracing::error!(error = %e, "error muxing packet"); + + self.error = Some(IngestError::Mux); + false } } @@ -901,152 +871,249 @@ impl Connection { pub async fn on_media_segment( &mut self, - update_channel: &mpsc::Sender>, global: &Arc, segment: MediaSegment, ) -> bool { if segment.keyframe { - self.bytes_since_keyframe = 0; + self.last_keyframe = Instant::now(); + + self.bytes_tracker.keyframe(); if let Some(transcoder) = self.next_transcoder.take() { - let Some(uuid) = self.next_transcoder_id.take() else { - tracing::error!("next transcoder id is missing"); - return false; - }; + if let Some(transcoder) = self.current_transcoder.take() { + transcoder + .send + .send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Transcoder as i32, + )), + }) + .await + .ok(); + self.old_transcoder = Some(transcoder); + } - if transcoder - .send(WatchStreamEvent::MediaSegment(segment.clone())) - .await - .is_ok() + if self.old_transcoder.is_none() + && transcoder + .send + .send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready as i32, + )), + }) + .await + .is_err() { - if let Some(current_transcoder) = self.current_transcoder.take() { - current_transcoder - .send(WatchStreamEvent::ShuttingDown(false)) - .await - .ok(); + tracing::error!("transcoder disconnected while sending fragment"); + if !self.request_transcoder(global).await { + return false; } - - self.last_transcoder_publish = Instant::now(); - self.current_transcoder = Some(transcoder); - self.current_transcoder_id = Some(uuid); - - return true; } - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "New Transcoder Disconnected".to_string(), - level: event::Level::Warning as i32, - message: format!( - "New Transcoder {} disconnected before sending first fragment", - uuid - ), - })), - }]) + self.current_transcoder = Some(transcoder); + self.current_transcoder_id = self.next_transcoder_id.take().unwrap(); + }; + } + + if let Some(transcoder) = &mut self.current_transcoder { + let mut failed = false; + for fragment in &self.fragment_list { + if transcoder + .send + .send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + r#type: match fragment.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio as i32 + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video as i32 + } + }, + data: fragment.data.clone(), + keyframe: fragment.keyframe, + }, + )), + }) + .await .is_err() { - tracing::error!("api update channel blocked"); - return false; + failed = true; + break; } - tracing::error!("new transcoder disconnected before sending first fragment"); - - // The next transcoder has disconnected somehow so we need to find a new one. - if !self.request_transcoder(update_channel, global).await { - return false; - } - }; - } + self.last_transcoder_publish = Instant::now(); + } - if let Some(transcoder) = &mut self.current_transcoder { - if transcoder - .send(WatchStreamEvent::MediaSegment(segment.clone())) - .await - .is_ok() + if !failed + && transcoder + .send + .send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + r#type: match segment.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio as i32 + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video as i32 + } + }, + keyframe: segment.keyframe, + data: segment.data.clone(), + }, + )), + }) + .await + .is_ok() { self.last_transcoder_publish = Instant::now(); + self.fragment_list.clear(); + return true; } tracing::error!("transcoder disconnected while sending fragment"); - - let current_id = self.current_transcoder_id.take().unwrap_or_default(); - - self.current_transcoder = None; - - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Event(Event { - title: "Transcoder Disconnected".to_string(), - level: event::Level::Warning as i32, - message: format!( - "Transcoder {} disconnected without graceful shutdown", - current_id - ), - })), - }]) - .is_err() - { - tracing::error!("api update channel blocked"); + if !self.request_transcoder(global).await { return false; } + } - // This means the current transcoder has disconnected so we need to find a new one. - if !self.request_transcoder(update_channel, global).await { - return false; - } + if Instant::now() - self.last_keyframe >= global.config.ingest.max_time_between_keyframes { + self.error = Some(IngestError::KeyframeTimeLimit( + global.config.ingest.max_time_between_keyframes.as_secs(), + )); + + return false; } - if Instant::now() - self.last_transcoder_publish - >= Duration::from_secs(MAX_TRANSCODER_WAIT_TIME) + if Instant::now() - self.last_transcoder_publish >= global.config.ingest.transcoder_timeout { tracing::error!("no transcoder available to publish to"); + + self.error = Some(IngestError::NoTranscoderAvailable); + return false; } - if segment.keyframe { + if segment.keyframe && segment.ty == transmuxer::MediaType::Video { self.fragment_list.clear(); self.fragment_list.push(segment); - } else if self - .fragment_list - .first() - .map(|f| f.keyframe) - .unwrap_or_default() - { + } else if !self.fragment_list.is_empty() { self.fragment_list.push(segment); } true } - fn on_bitrate_update(&mut self, update_channel: &mpsc::Sender>) -> bool { - let video_bitrate = (self.total_video_bytes * 8) / BITRATE_UPDATE_INTERVAL; - let audio_bitrate = (self.total_audio_bytes * 8) / BITRATE_UPDATE_INTERVAL; - let metadata_bitrate = (self.total_metadata_bytes * 8) / BITRATE_UPDATE_INTERVAL; - - self.total_video_bytes = 0; - self.total_audio_bytes = 0; - self.total_metadata_bytes = 0; - - // We need to make sure that the update future is still running - if update_channel - .try_send(vec![Update { - timestamp: Utc::now().timestamp() as u64, - update: Some(update::Update::Bitrate(Bitrate { - video_bitrate, - audio_bitrate, - metadata_bitrate, - })), - }]) - .is_err() + fn on_bitrate_update(&mut self, global: &Arc) -> bool { + let bitrate = + self.bytes_tracker.total() / global.config.ingest.bitrate_update_interval.as_secs(); + + self.bytes_tracker.clear(); + + if !self.send_update(Update { + bitrate: bitrate as i32, + }) { + self.error = Some(IngestError::FailedToUpdateBitrate); + false + } else { + true + } + } + + async fn cleanup(&mut self, global: &Arc, clean_disconnect: bool) -> Result<()> { + if let Some(next_id) = self.next_transcoder_id.take() { + global.requests.lock().await.remove(&next_id); + } + + if !self.current_transcoder_id.is_nil() { + global + .requests + .lock() + .await + .remove(&self.current_transcoder_id); + } + + if let Some(transcoder) = self.next_transcoder.take() { + transcoder + .send + .try_send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Stream as i32, + )), + }) + .ok(); + } + + if let Some(transcoder) = self.current_transcoder.take() { + transcoder + .send + .try_send(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Stream as i32, + )), + }) + .ok(); + } + + if let Err(err) = global + .nats + .publish( + format!( + "{}.{}", + global.config.ingest.events_subject, self.organization_id + ), + OrganizationEvent { + timestamp: Utc::now().timestamp_micros(), + id: Some(self.organization_id.into()), + event: Some(organization_event::Event::RoomDisconnect( + organization_event::RoomDisconnect { + connection_id: Some(self.id.into()), + room_id: Some(self.room_id.into()), + clean: clean_disconnect, + error: self.error.as_ref().map(|e| e.to_string()), + }, + )), + } + .encode_to_vec() + .into(), + ) + .await { - tracing::error!("api update channel blocked"); - return false; + tracing::error!(error = %err, "failed to publish room disconnect event"); } - true + sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + last_disconnected_at = NOW(), + active_ingest_connection_id = NULL, + video_input = NULL, + audio_input = NULL, + ingest_bitrate = NULL, + video_output = NULL, + audio_output = NULL, + active_recording_id = NULL, + active_recording_config = NULL, + active_transcoding_config = NULL, + status = $1 + WHERE + organization_id = $2 AND + id = $3 AND + active_ingest_connection_id = $4 + "#, + ) + .bind(RoomStatus::Offline) + .bind(Uuid::from(self.organization_id)) + .bind(Uuid::from(self.room_id)) + .bind(Uuid::from(self.id)) + .execute(global.db.as_ref()) + .await?; + + Ok(()) } } diff --git a/video/ingest/src/ingest/errors.rs b/video/ingest/src/ingest/errors.rs new file mode 100644 index 00000000..debe7c74 --- /dev/null +++ b/video/ingest/src/ingest/errors.rs @@ -0,0 +1,43 @@ +pub enum IngestError { + KeyframeBitrateDistance(u64, u64), + BitrateLimit(u64, u64), + VideoDemux, + AudioDemux, + MetadataDemux, + Mux, + KeyframeTimeLimit(u64), + NoTranscoderAvailable, + FailedToUpdateBitrate, + FailedToSubscribe, + IngestShutdown, + RtmpConnectionError, + RtmpConnectionTimeout, + DisconnectRequested, + SubscriptionClosedUnexpectedly, + FailedToRequestTranscoder, + FailedToUpdateRoom, +} + +impl std::fmt::Display for IngestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::KeyframeBitrateDistance(a, b) => write!(f, "I01: Keyframe byte distance too large, the bitrate is too high for the keyframe interval: {}KB >= {}KB", a/1024, b/1024), + Self::BitrateLimit(a, b) => write!(f, "I02: Bitrate limit reached, the bitrate is too high: {}Kbps >= {}Kbps", a/1024, b/1024), + Self::VideoDemux => write!(f, "I03: Video Demux Error"), + Self::AudioDemux => write!(f, "I04: Audio Demux Error"), + Self::MetadataDemux => write!(f, "I05: Metadata Demux Error"), + Self::Mux => write!(f, "I06: Mux Error"), + Self::KeyframeTimeLimit(a) => write!(f, "I07: Keyframe time distance too large, the keyframe interval is larger than: {}s", a), + Self::NoTranscoderAvailable => write!(f, "I08: No transcoder available"), + Self::FailedToUpdateBitrate => write!(f, "I09: Failed to update bitrate"), + Self::FailedToSubscribe => write!(f, "I10: Failed to subscribe"), + Self::IngestShutdown => write!(f, "I11: Ingest shutdown"), + Self::RtmpConnectionError => write!(f, "I12: RTMP connection error"), + Self::RtmpConnectionTimeout => write!(f, "I13: RTMP connection timeout"), + Self::DisconnectRequested => write!(f, "I14: Disconnect requested"), + Self::SubscriptionClosedUnexpectedly => write!(f, "I15: Subscription closed unexpectedly"), + Self::FailedToRequestTranscoder => write!(f, "I16: Failed to request transcoder"), + Self::FailedToUpdateRoom => write!(f, "I17: Failed to update room"), + } + } +} diff --git a/video/ingest/src/ingest/mod.rs b/video/ingest/src/ingest/mod.rs index 178c4785..df728797 100644 --- a/video/ingest/src/ingest/mod.rs +++ b/video/ingest/src/ingest/mod.rs @@ -5,8 +5,11 @@ use tokio::{net::TcpSocket, select}; use crate::global::GlobalState; +mod bytes_tracker; mod connection; -mod variants; +mod errors; +mod rtmp_session; +mod update; pub async fn run(global: Arc) -> Result<()> { tracing::info!("Listening on {}", global.config.rtmp.bind_address); @@ -49,6 +52,7 @@ pub async fn run(global: Arc) -> Result<()> { let Ok(Ok(socket)) = tls_acceptor.accept(socket).timeout(Duration::from_secs(5)).await else { return; }; + tracing::debug!("TLS handshake complete"); connection::handle(global, socket, addr.ip()).await; } else { diff --git a/video/ingest/src/ingest/rtmp_session.rs b/video/ingest/src/ingest/rtmp_session.rs new file mode 100644 index 00000000..262ce255 --- /dev/null +++ b/video/ingest/src/ingest/rtmp_session.rs @@ -0,0 +1,44 @@ +use std::pin::Pin; + +use futures_util::Future; +use rtmp::{ChannelData, PublishRequest, SessionError}; +use tokio::{select, sync::mpsc}; + +pub struct RtmpSession<'a, F> { + future: Pin<&'a mut F>, + publish: mpsc::Receiver, + data: mpsc::Receiver, +} + +pub enum Data { + Data(Option), + Closed(bool), +} + +impl<'a, F: Future>> RtmpSession<'a, F> { + pub fn new( + future: Pin<&'a mut F>, + publish: mpsc::Receiver, + data: mpsc::Receiver, + ) -> Self { + Self { + future, + publish, + data, + } + } + + pub async fn publish(&mut self) -> Result, SessionError> { + select! { + r = self.future.as_mut() => Ok(r.map(|_| None)?), + publish = self.publish.recv() => Ok(publish), + } + } + + pub async fn data(&mut self) -> Result { + select! { + r = self.future.as_mut() => Ok(r.map(Data::Closed)?), + data = self.data.recv() => Ok(Data::Data(data)), + } + } +} diff --git a/video/ingest/src/ingest/update.rs b/video/ingest/src/ingest/update.rs new file mode 100644 index 00000000..257eb8ff --- /dev/null +++ b/video/ingest/src/ingest/update.rs @@ -0,0 +1,70 @@ +use std::{sync::Arc, time::Duration}; + +use common::prelude::FutureTimeout; +use tokio::sync::mpsc; +use ulid::Ulid; +use uuid::Uuid; + +use crate::global::GlobalState; + +pub struct Update { + pub bitrate: i32, +} + +pub async fn update_db( + global: Arc, + id: Ulid, + organization_id: Ulid, + room_id: Ulid, + mut update_reciever: mpsc::Receiver, +) { + while let Some(update) = update_reciever.recv().await { + let mut success = false; + + for _ in 0..5 { + match sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + ingest_bitrate = $1 + WHERE + organization_id = $2 AND + id = $3 AND + active_ingest_connection_id = $4 + "#, + ) + .bind(update.bitrate) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(Uuid::from(id)) + .execute(global.db.as_ref()) + .timeout(Duration::from_secs(3)) + .await + { + Ok(Ok(r)) => { + if r.rows_affected() != 1 { + tracing::error!("failed to update api with bitrate - no rows affected"); + return; + } else { + success = true; + break; + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "failed to update api with bitrate"); + } + Err(_) => { + tracing::error!("failed to update api with bitrate timedout"); + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + if !success { + tracing::error!("failed to update api with bitrate after 5 retries - giving up"); + return; + } + } +} diff --git a/video/ingest/src/ingest/variants.rs b/video/ingest/src/ingest/variants.rs deleted file mode 100644 index aa223cd2..00000000 --- a/video/ingest/src/ingest/variants.rs +++ /dev/null @@ -1,193 +0,0 @@ -use aac::AudioObjectType; -use mp4::codec::{AudioCodec, VideoCodec}; -use transmuxer::{AudioSettings, VideoSettings}; -use uuid::Uuid; - -use crate::pb::scuffle::types::{stream_state, StreamState}; - -pub fn generate_variants( - video_settings: &VideoSettings, - _audio_settings: &AudioSettings, - transcode: bool, -) -> StreamState { - let mut stream_state = StreamState::default(); - - let mut audio_tracks = vec![]; - - if transcode { - let id = Uuid::new_v4().to_string(); - - stream_state.transcodes.push(stream_state::Transcode { - id: id.clone(), - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - bitrate: 96 * 1024, - codec: AudioCodec::Opus.to_string(), - copy: false, - }); - - stream_state.groups.push(stream_state::Group { - name: "opus".to_string(), - priority: 1, - }); - - audio_tracks.push((id, "opus")); - }; - - { - let id = Uuid::new_v4().to_string(); - - stream_state.transcodes.push(stream_state::Transcode { - id: id.clone(), - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - bitrate: 128 * 1024, - codec: AudioCodec::Aac { - object_type: AudioObjectType::AacLowComplexity, - } - .to_string(), - copy: false, - }); - - stream_state.groups.push(stream_state::Group { - name: "aac".to_string(), - priority: stream_state.groups.len() as i32 + 1, - }); - - audio_tracks.push((id, "aac")); - }; - - stream_state.variants.extend( - audio_tracks - .iter() - .map(|(id, group)| stream_state::Variant { - name: "audio-only".to_string(), - group: group.to_string(), - transcode_ids: vec![id.clone()], - }), - ); - - { - let id = Uuid::new_v4().to_string(); - - stream_state.transcodes.push(stream_state::Transcode { - id: id.clone(), - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: video_settings.framerate as u32, - height: video_settings.height, - width: video_settings.width, - }, - )), - bitrate: video_settings.bitrate, - codec: video_settings.codec.to_string(), - copy: true, - }); - - stream_state - .variants - .extend( - audio_tracks - .iter() - .map(|(track_id, group)| stream_state::Variant { - name: "source".to_string(), - group: group.to_string(), - transcode_ids: vec![id.clone(), track_id.clone()], - }), - ); - } - - if transcode { - let aspect_ratio = video_settings.width as f64 / video_settings.height as f64; - - struct Resolution { - side: u32, - framerate: u32, - bitrate: u32, - } - - let resolutions = [ - Resolution { - bitrate: 4000 * 1024, - framerate: video_settings.framerate.min(60.0) as u32, - side: 720, - }, - Resolution { - bitrate: 2000 * 1024, - framerate: video_settings.framerate.min(30.0) as u32, - side: 480, - }, - Resolution { - bitrate: 1000 * 1024, - framerate: video_settings.framerate.min(30.0) as u32, - side: 360, - }, - ]; - - for res in resolutions { - // This prevents us from upscaling the video - // We only want to downscale the video - let (width, height) = if aspect_ratio > 1.0 && video_settings.height > res.side { - ((res.side as f64 * aspect_ratio).round() as u32, res.side) - } else if aspect_ratio < 1.0 && video_settings.width > res.side { - (res.side, (res.side as f64 / aspect_ratio).round() as u32) - } else { - continue; - }; - - // We dont want to transcode video with resolutions less than 100px on either side - // We also do not want to transcode anything more expensive than 720p on a 16:9 aspect ratio (720 * 1280) - // This prevents us from transcoding a "720p" with an aspect ratio of 4:1 (720 * 2880) which is extremely expensive. - // Just some insight, 2880 / 1280 = 2.25, so this video is 2.25 times more expensive than a normal 720p video. - // 1080 * 1920 = 2073600 - // 720 * 2880 = 2073600 - // So a 720p video with an aspect ratio of 4:1 is just as expensive as a 1080p video with a 16:9 aspect ratio. - if width < 100 || height < 100 || width * height > 720 * 1280 { - continue; - } - - let id = Uuid::new_v4().to_string(); - - stream_state.transcodes.push(stream_state::Transcode { - id: id.clone(), - bitrate: res.bitrate, - codec: VideoCodec::Avc { - profile: 100, // High - level: 51, // 5.1 - constraint_set: 0, - } - .to_string(), - copy: false, - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: res.framerate, - height, - width, - }, - )), - }); - - stream_state - .variants - .extend( - audio_tracks - .iter() - .map(|(track_id, group)| stream_state::Variant { - name: format!("{}p", res.side), - group: group.to_string(), - transcode_ids: vec![id.clone(), track_id.clone()], - }), - ); - } - } - - stream_state -} diff --git a/video/ingest/src/main.rs b/video/ingest/src/main.rs index 7aa3161d..e246c7a7 100644 --- a/video/ingest/src/main.rs +++ b/video/ingest/src/main.rs @@ -1,15 +1,17 @@ -use std::{sync::Arc, time::Duration}; +use std::{str::FromStr, sync::Arc, time::Duration}; -use anyhow::{Context as _, Result}; -use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use anyhow::Result; +use async_nats::ServerAddr; +use common::{context::Context, logging, signal}; +use sqlx::ConnectOptions; +use sqlx_postgres::PgConnectOptions; use tokio::{select, signal::unix::SignalKind, time}; mod config; -mod connection_manager; +mod define; mod global; mod grpc; mod ingest; -mod pb; #[tokio::main] async fn main() -> Result<()> { @@ -23,18 +25,50 @@ async fn main() -> Result<()> { let (ctx, handler) = Context::new(); - let rmq = common::rmq::ConnectionPool::connect( - config.rmq.uri.clone(), - lapin::ConnectionProperties::default(), - Duration::from_secs(30), - 1, - ) - .timeout(Duration::from_secs(5)) - .await - .context("failed to connect to rabbitmq, timedout")? - .context("failed to connect to rabbitmq")?; - - let global = Arc::new(global::GlobalState::new(config, ctx, rmq)); + let db = Arc::new( + sqlx::PgPool::connect_with( + PgConnectOptions::from_str(&config.database.uri)? + .disable_statement_logging() + .to_owned(), + ) + .await?, + ); + + let nats = { + let mut options = async_nats::ConnectOptions::new() + .connection_timeout(Duration::from_secs(5)) + .name(&config.name) + .retry_on_initial_connect(); + + if let Some(user) = &config.nats.username { + options = options.user_and_password( + user.clone(), + config.nats.password.clone().unwrap_or_default(), + ) + } else if let Some(token) = &config.nats.token { + options = options.token(token.clone()) + } + + if let Some(tls) = &config.nats.tls { + options = options + .require_tls(true) + .add_root_certificates((&tls.ca_cert).into()) + .add_client_certificate((&tls.cert).into(), (&tls.key).into()); + } + + options + .connect( + config + .nats + .servers + .iter() + .map(|s| s.parse::()) + .collect::, _>>()?, + ) + .await? + }; + + let global = Arc::new(global::GlobalState::new(config, db, nats, ctx)); let ingest_future = tokio::spawn(ingest::run(global.clone())); let grpc_future = tokio::spawn(grpc::run(global.clone())); @@ -47,7 +81,6 @@ async fn main() -> Result<()> { select! { r = ingest_future => tracing::error!("api stopped unexpectedly: {:?}", r), r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), - r = global.rmq.handle_reconnects() => tracing::error!("rmq stopped unexpectedly: {:?}", r), _ = signal_handler.recv() => tracing::info!("shutting down"), } diff --git a/video/ingest/src/pb.rs b/video/ingest/src/pb.rs deleted file mode 100644 index c76f275b..00000000 --- a/video/ingest/src/pb.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod scuffle { - pub mod backend { - tonic::include_proto!("scuffle.backend"); - } - - pub mod types { - tonic::include_proto!("scuffle.types"); - } - - pub mod video { - tonic::include_proto!("scuffle.video"); - } - - pub mod events { - tonic::include_proto!("scuffle.events"); - } -} - -pub mod health { - tonic::include_proto!("grpc.health.v1"); -} diff --git a/video/ingest/src/tests/config.rs b/video/ingest/src/tests/config.rs deleted file mode 100644 index c032a912..00000000 --- a/video/ingest/src/tests/config.rs +++ /dev/null @@ -1,132 +0,0 @@ -use serial_test::serial; - -use crate::config::AppConfig; - -fn clear_env() { - for (key, _) in std::env::vars() { - if key.starts_with("SCUF_") { - std::env::remove_var(key); - } - } -} - -#[serial] -#[test] -fn test_parse() { - clear_env(); - - let config = AppConfig::parse().expect("Failed to parse config"); - assert_eq!( - config, - AppConfig { - config_file: None, - ..Default::default() - } - ); -} - -#[serial] -#[test] -fn test_parse_env() { - clear_env(); - - std::env::set_var("SCUF_LOGGING_LEVEL", "ingest=debug"); - std::env::set_var( - "SCUF_DATABASE_URI", - "postgres://postgres:postgres@localhost:5433/postgres", - ); - - let config = AppConfig::parse().expect("Failed to parse config"); - assert_eq!(config.logging.level, "ingest=debug"); -} - -#[serial] -#[test] -fn test_parse_file() { - clear_env(); - - let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let config_file = tmp_dir.path().join("config.toml"); - - std::fs::write( - &config_file, - r#" -[logging] -level = "ingest=debug" - -[api] -addresses = [ - "test", - "test2" -] -"#, - ) - .expect("Failed to write config file"); - - std::env::set_var( - "SCUF_CONFIG_FILE", - config_file.to_str().expect("Failed to get str"), - ); - - let config = AppConfig::parse().expect("Failed to parse config"); - - assert_eq!(config.logging.level, "ingest=debug"); - assert_eq!(config.api.addresses, vec!["test", "test2"]); - assert_eq!( - config.config_file, - Some( - std::fs::canonicalize(config_file) - .unwrap() - .display() - .to_string() - ) - ); -} - -#[serial] -#[test] -fn test_parse_file_env() { - clear_env(); - - let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let config_file = tmp_dir.path().join("config.toml"); - - std::fs::write( - &config_file, - r#" -[logging] -level = "ingest=debug" - -[rtmp] -bind_address = "[::]:8081" - -[api] -addresses = [ - "test", - "test2" -] -"#, - ) - .expect("Failed to write config file"); - - std::env::set_var( - "SCUF_CONFIG_FILE", - config_file.to_str().expect("Failed to get str"), - ); - std::env::set_var("SCUF_LOGGING_LEVEL", "ingest=info"); - - let config = AppConfig::parse().expect("Failed to parse config"); - - assert_eq!(config.logging.level, "ingest=info"); - assert_eq!(config.rtmp.bind_address, "[::]:8081".parse().unwrap()); - assert_eq!(config.api.addresses, vec!["test", "test2"]); - assert_eq!( - config.config_file, - Some( - std::fs::canonicalize(config_file) - .unwrap() - .display() - .to_string() - ) - ); -} diff --git a/video/ingest/src/tests/global.rs b/video/ingest/src/tests/global.rs index 41992dde..71388214 100644 --- a/video/ingest/src/tests/global.rs +++ b/video/ingest/src/tests/global.rs @@ -5,7 +5,6 @@ use common::{ logging, prelude::FutureTimeout, }; -use tokio::select; use crate::{config::AppConfig, global::GlobalState}; @@ -17,26 +16,19 @@ pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) logging::init(&config.logging.level, config.logging.mode) .expect("failed to initialize logging"); - let rmq = common::rmq::ConnectionPool::connect( - std::env::var("RMQ_URL").expect("RMQ_URL not set"), - lapin::ConnectionProperties::default(), - Duration::from_secs(30), - 1, - ) - .timeout(Duration::from_secs(5)) - .await - .expect("failed to connect to rabbitmq") - .expect("failed to connect to rabbitmq"); - - let global = Arc::new(GlobalState::new(config, ctx, rmq)); - - let global2 = global.clone(); - tokio::spawn(async move { - select! { - _ = global2.rmq.handle_reconnects() => {}, - _ = global2.ctx.done() => {}, - } - }); + let db = Arc::new( + sqlx::PgPool::connect(&std::env::var("DATABASE_URL").expect("DATABASE_URL not set")) + .await + .expect("failed to connect to database"), + ); + + let nats = async_nats::connect(std::env::var("NATS_URL").expect("NATS_URL not set")) + .timeout(Duration::from_secs(5)) + .await + .expect("failed to connect to rabbitmq") + .expect("failed to connect to rabbitmq"); + + let global = Arc::new(GlobalState::new(config, db, nats, ctx)); (global, handler) } diff --git a/video/ingest/src/tests/grpc/health.rs b/video/ingest/src/tests/grpc/health.rs index a174980e..5b0544ce 100644 --- a/video/ingest/src/tests/grpc/health.rs +++ b/video/ingest/src/tests/grpc/health.rs @@ -28,14 +28,14 @@ async fn test_grpc_health_check() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -71,10 +71,10 @@ async fn test_grpc_health_watch() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .watch(crate::pb::health::HealthCheckRequest::default()) + .watch(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); @@ -82,7 +82,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); let cancel = handler.cancel(); @@ -90,7 +90,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::NotServing as i32 ); cancel diff --git a/video/ingest/src/tests/grpc/ingest.rs b/video/ingest/src/tests/grpc/ingest.rs deleted file mode 100644 index 0b5fb1a2..00000000 --- a/video/ingest/src/tests/grpc/ingest.rs +++ /dev/null @@ -1,272 +0,0 @@ -use bytes::Bytes; -use common::grpc::make_channel; -use common::prelude::FutureTimeout; -use std::time::Duration; -use transmuxer::{MediaSegment, MediaType}; -use uuid::Uuid; - -use crate::{ - config::{AppConfig, GrpcConfig}, - connection_manager::{GrpcRequest, WatchStreamEvent}, - grpc::run, - pb::scuffle::video::{transcoder_event_request, watch_stream_response, TranscoderEventRequest}, - tests::global::mock_global_state, -}; - -#[tokio::test] -async fn test_grpc_ingest_transcoder_event() { - let port = portpicker::pick_unused_port().expect("failed to pick port"); - let (global, handler) = mock_global_state(AppConfig { - grpc: GrpcConfig { - bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), - ..Default::default() - }, - ..Default::default() - }) - .await; - - let handle = tokio::spawn(run(global.clone())); - - let channel = make_channel( - vec![format!("http://localhost:{}", port)], - Duration::from_secs(0), - None, - ) - .unwrap(); - - let stream_id = Uuid::new_v4(); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - - global - .connection_manager - .register_stream(stream_id, Uuid::new_v4(), tx) - .await; - - let mut client = crate::pb::scuffle::video::ingest_client::IngestClient::new(channel); - - let request_id = Uuid::new_v4(); - - client - .transcoder_event(TranscoderEventRequest { - stream_id: stream_id.to_string(), - request_id: request_id.to_string(), - event: Some(transcoder_event_request::Event::Started(true)), - }) - .await - .unwrap(); - - let event = rx - .recv() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to receive event") - .expect("failed to receive event"); - - match event { - GrpcRequest::TranscoderStarted { id } => { - assert_eq!(id, request_id); - } - _ => panic!("wrong request"), - } - - client - .transcoder_event(TranscoderEventRequest { - stream_id: stream_id.to_string(), - request_id: request_id.to_string(), - event: Some(transcoder_event_request::Event::ShuttingDown(true)), - }) - .await - .unwrap(); - - let event = rx - .recv() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to receive event") - .expect("failed to receive event"); - - match event { - GrpcRequest::TranscoderShuttingDown { id } => { - assert_eq!(id, request_id); - } - _ => panic!("wrong request"), - } - - client - .transcoder_event(TranscoderEventRequest { - stream_id: stream_id.to_string(), - request_id: request_id.to_string(), - event: Some(transcoder_event_request::Event::Error( - transcoder_event_request::Error { - message: "test".to_string(), - fatal: false, - }, - )), - }) - .await - .unwrap(); - - let event = rx - .recv() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to receive event") - .expect("failed to receive event"); - - match event { - GrpcRequest::TranscoderError { - id, - message, - fatal: _, - } => { - assert_eq!(id, request_id); - assert_eq!(message, "test"); - } - _ => panic!("wrong request"), - } - - drop(global); - - handler - .cancel() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to cancel context"); - - handle - .timeout(Duration::from_secs(1)) - .await - .expect("failed to cancel grpc") - .expect("grpc failed") - .expect("grpc failed"); -} - -#[tokio::test] -async fn test_grpc_ingest_watch_stream() { - let port = portpicker::pick_unused_port().expect("failed to pick port"); - let (global, handler) = mock_global_state(AppConfig { - grpc: GrpcConfig { - bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), - ..Default::default() - }, - ..Default::default() - }) - .await; - - let handle = tokio::spawn(run(global.clone())); - - let channel = make_channel( - vec![format!("http://localhost:{}", port)], - Duration::from_secs(0), - None, - ) - .unwrap(); - - let stream_id = Uuid::new_v4(); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - - global - .connection_manager - .register_stream(stream_id, Uuid::new_v4(), tx) - .await; - - let mut client = crate::pb::scuffle::video::ingest_client::IngestClient::new(channel); - - let request_id = Uuid::new_v4(); - - let mut revc_stream = client - .watch_stream(crate::pb::scuffle::video::WatchStreamRequest { - stream_id: stream_id.to_string(), - request_id: request_id.to_string(), - }) - .await - .unwrap() - .into_inner(); - - let event = rx - .recv() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to receive event") - .expect("failed to receive event"); - - let ch = match event { - GrpcRequest::WatchStream { id, channel } => { - assert_eq!(id, request_id); - channel - } - _ => panic!("wrong request"), - }; - - ch.send(WatchStreamEvent::InitSegment(Bytes::from_static( - b"testing 123", - ))) - .await - .unwrap(); - let resp = revc_stream.message().await.unwrap().unwrap(); - assert_eq!( - resp.data, - Some(watch_stream_response::Data::InitSegment( - b"testing 123".to_vec().into() - )) - ); - - ch.send(WatchStreamEvent::MediaSegment(MediaSegment { - data: Bytes::from_static(b"fragment"), - keyframe: true, - timestamp: 123, - ty: MediaType::Video, - })) - .await - .unwrap(); - let resp = revc_stream.message().await.unwrap().unwrap(); - assert_eq!( - resp.data, - Some(watch_stream_response::Data::MediaSegment( - watch_stream_response::MediaSegment { - data: b"fragment".to_vec().into(), - keyframe: true, - timestamp: 123, - data_type: watch_stream_response::media_segment::DataType::Video as i32, - } - )) - ); - - ch.send(WatchStreamEvent::MediaSegment(MediaSegment { - data: Bytes::from_static(b"fragment2"), - keyframe: false, - timestamp: 456, - ty: MediaType::Audio, - })) - .await - .unwrap(); - let resp = revc_stream.message().await.unwrap().unwrap(); - assert_eq!( - resp.data, - Some(watch_stream_response::Data::MediaSegment( - watch_stream_response::MediaSegment { - data: b"fragment2".to_vec().into(), - keyframe: false, - timestamp: 456, - data_type: watch_stream_response::media_segment::DataType::Audio as i32, - } - )) - ); - - drop(global); - - handler - .cancel() - .timeout(Duration::from_secs(1)) - .await - .expect("failed to cancel context"); - - handle - .timeout(Duration::from_secs(1)) - .await - .expect("failed to cancel grpc") - .expect("grpc failed") - .expect("grpc failed"); -} diff --git a/video/ingest/src/tests/grpc/mod.rs b/video/ingest/src/tests/grpc/mod.rs index 7b85aec1..a72ae5cf 100644 --- a/video/ingest/src/tests/grpc/mod.rs +++ b/video/ingest/src/tests/grpc/mod.rs @@ -1,3 +1,2 @@ mod health; -mod ingest; mod tls; diff --git a/video/ingest/src/tests/grpc/tls.rs b/video/ingest/src/tests/grpc/tls.rs index d4d810f4..b170980b 100644 --- a/video/ingest/src/tests/grpc/tls.rs +++ b/video/ingest/src/tests/grpc/tls.rs @@ -38,7 +38,7 @@ async fn test_grpc_tls_rsa() { vec![format!("https://localhost:{}", port)], Duration::from_secs(0), Some(TlsSettings { - domain: "localhost".to_string(), + domain: Some("localhost".to_string()), ca_cert: ca_content, identity: client_identity, }), @@ -56,15 +56,15 @@ async fn test_grpc_tls_rsa() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -109,7 +109,7 @@ async fn test_grpc_tls_ec() { vec![format!("https://localhost:{}", port)], Duration::from_secs(0), Some(TlsSettings { - domain: "localhost".to_string(), + domain: Some("localhost".to_string()), ca_cert: ca_content, identity: client_identity, }), @@ -127,15 +127,15 @@ async fn test_grpc_tls_ec() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() diff --git a/video/ingest/src/tests/ingest.rs b/video/ingest/src/tests/ingest.rs index 35b98128..5d0f6eeb 100644 --- a/video/ingest/src/tests/ingest.rs +++ b/video/ingest/src/tests/ingest.rs @@ -1,117 +1,51 @@ use std::path::{Path, PathBuf}; -use std::pin::{pin, Pin}; +use std::pin::Pin; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use async_stream::stream; -use async_trait::async_trait; -use common::config::TlsConfig; +use base64::Engine; +use bytes::Bytes; +use common::config::{LoggingConfig, TlsConfig}; use common::prelude::FutureTimeout; use futures::StreamExt; -use lapin::options::QueueDeclareOptions; +use pb::ext::UlidExt; +use pb::scuffle::video::internal::events::{ + organization_event, OrganizationEvent, TranscoderRequest, +}; +use pb::scuffle::video::internal::ingest_client::IngestClient; +use pb::scuffle::video::internal::{ + ingest_watch_request, ingest_watch_response, IngestWatchRequest, IngestWatchResponse, +}; +use pb::scuffle::video::v1::types::{RenditionAudio, RenditionVideo}; use prost::Message; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::select; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use tokio::task::JoinHandle; -use tonic::{Request, Response, Status}; -use transmuxer::MediaType; +use ulid::Ulid; use uuid::Uuid; +use video_database::room::Room; -use crate::config::{ApiConfig, AppConfig, RtmpConfig, TranscoderConfig}; -use crate::connection_manager::{GrpcRequest, WatchStreamEvent}; +use crate::config::{AppConfig, GrpcConfig, IngestConfig, RtmpConfig}; use crate::global; -use crate::pb::scuffle::backend::update_live_stream_request::event::Level; -use crate::pb::scuffle::backend::{ - api_server, update_live_stream_request, AuthenticateLiveStreamRequest, - AuthenticateLiveStreamResponse, NewLiveStreamRequest, NewLiveStreamResponse, StreamReadyState, - UpdateLiveStreamRequest, UpdateLiveStreamResponse, -}; -use crate::pb::scuffle::events::{transcoder_message, TranscoderMessage}; -use crate::pb::scuffle::types::{stream_state, StreamState}; use crate::tests::global::mock_global_state; -#[derive(Debug)] -enum IncomingRequest { - Authenticate( - ( - AuthenticateLiveStreamRequest, - oneshot::Sender>, - ), - ), - Update( - ( - UpdateLiveStreamRequest, - oneshot::Sender>, - ), - ), - New( - ( - NewLiveStreamRequest, - oneshot::Sender>, - ), - ), -} - -struct ApiServer(mpsc::Sender); - -fn new_api_server(port: u16) -> mpsc::Receiver { - let (tx, rx) = mpsc::channel(1); - let service = api_server::ApiServer::new(ApiServer(tx)); - - tokio::spawn( - tonic::transport::Server::builder() - .add_service(service) - .serve(format!("0.0.0.0:{}", port).parse().unwrap()), - ); - - rx -} - -type Result = std::result::Result; - -#[async_trait] -impl crate::pb::scuffle::backend::api_server::Api for ApiServer { - async fn authenticate_live_stream( - &self, - request: Request, - ) -> Result> { - let (send, recv) = oneshot::channel(); - self.0 - .send(IncomingRequest::Authenticate((request.into_inner(), send))) - .await - .unwrap(); - Ok(Response::new(recv.await.unwrap()?)) - } - - async fn update_live_stream( - &self, - request: Request, - ) -> Result> { - let (send, recv) = oneshot::channel(); - self.0 - .send(IncomingRequest::Update((request.into_inner(), send))) - .await - .unwrap(); - Ok(Response::new(recv.await.unwrap()?)) - } - - async fn new_live_stream( - &self, - request: Request, - ) -> Result> { - let (send, recv) = oneshot::channel(); - self.0 - .send(IncomingRequest::New((request.into_inner(), send))) - .await - .unwrap(); - Ok(Response::new(recv.await.unwrap()?)) - } +fn generate_key(org_id: Ulid, room_id: Ulid) -> String { + format!( + "live_{}_{}", + org_id, + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!( + "{}+{}", + room_id, + Uuid::from(room_id).simple() + )) + ) } -fn stream_with_ffmpeg(rtmp_port: u16, file: &str) -> tokio::process::Child { +fn stream_with_ffmpeg(rtmp_port: u16, file: &str, key: &str) -> tokio::process::Child { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); Command::new("ffmpeg") @@ -123,7 +57,7 @@ fn stream_with_ffmpeg(rtmp_port: u16, file: &str) -> tokio::process::Child { "copy", "-f", "flv", - &format!("rtmp://127.0.0.1:{}/live/stream-key", rtmp_port), + &format!("rtmp://127.0.0.1:{}/live/{}", rtmp_port, key), ]) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) @@ -131,7 +65,12 @@ fn stream_with_ffmpeg(rtmp_port: u16, file: &str) -> tokio::process::Child { .expect("failed to execute ffmpeg") } -fn stream_with_ffmpeg_tls(rtmp_port: u16, file: &str, tls_dir: &Path) -> tokio::process::Child { +fn stream_with_ffmpeg_tls( + rtmp_port: u16, + file: &str, + tls_dir: &Path, + key: &str, +) -> tokio::process::Child { let video_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); Command::new("ffmpeg") @@ -151,7 +90,7 @@ fn stream_with_ffmpeg_tls(rtmp_port: u16, file: &str, tls_dir: &Path) -> tokio:: tls_dir.join("client.key").to_str().unwrap(), "-f", "flv", - &format!("rtmps://localhost:{}/live/stream-key", rtmp_port), + &format!("rtmps://localhost:{}/live/{}", rtmp_port, key), ]) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) @@ -178,43 +117,60 @@ fn spawn_ffprobe() -> tokio::process::Child { } struct Watcher { - pub rx: tokio::sync::mpsc::Receiver, + pub send: mpsc::Sender, + pub recv: tonic::Streaming, } impl Watcher { - async fn new(state: &TestState, stream_id: Uuid, request_id: Uuid) -> Self { - let (tx, rx) = tokio::sync::mpsc::channel(128); - assert!( - state - .global - .connection_manager - .submit_request( - stream_id, - GrpcRequest::WatchStream { - id: request_id, - channel: tx, - } - ) - .await - ); - Self { rx } + async fn new(request_id: Ulid, advertise_addr: String) -> Self { + let (send, rx) = mpsc::channel(10); + + send.send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Open( + ingest_watch_request::Open { + request_id: Some(request_id.into()), + }, + )), + }) + .await + .unwrap(); + + tracing::info!("connecting to ingest server at {}", advertise_addr); + + let channel = + common::grpc::make_channel(vec![advertise_addr], Duration::from_secs(30), None) + .unwrap(); + + let mut client = IngestClient::new(channel); + + let recv = client + .watch(tokio_stream::wrappers::ReceiverStream::new(rx)) + .await + .unwrap() + .into_inner(); + + Self { send, recv } } - async fn recv(&mut self) -> WatchStreamEvent { - tokio::time::timeout(Duration::from_secs(2), self.rx.recv()) + async fn recv(&mut self) -> IngestWatchResponse { + tokio::time::timeout(Duration::from_secs(2), self.recv.message()) .await .expect("failed to receive event") .expect("failed to receive event") + .expect("failed to receive event") } } struct TestState { pub rtmp_port: u16, + pub org_id: Ulid, + pub room_id: Ulid, pub global: Arc, pub handler: common::context::Handler, - pub api_rx: mpsc::Receiver, - pub transcoder_stream: Pin>>, + pub transcoder_requests: Pin>>, + pub organization_events: Pin>>, pub ingest_handle: JoinHandle>, + pub grpc_handle: JoinHandle>, } impl TestState { @@ -233,61 +189,70 @@ impl TestState { } async fn setup_new(tls: Option) -> Self { - let api_port = portpicker::pick_unused_port().unwrap(); + let grpc_port = portpicker::pick_unused_port().unwrap(); let rtmp_port = portpicker::pick_unused_port().unwrap(); - let api_rx = new_api_server(api_port); - let (global, handler) = mock_global_state(AppConfig { - api: ApiConfig { - addresses: vec![format!("http://localhost:{}", api_port)], - resolve_interval: 1, - tls: None, + logging: LoggingConfig { + level: "video_ingest=debug".to_string(), + ..Default::default() + }, + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", grpc_port).parse().unwrap(), + ..Default::default() }, rtmp: RtmpConfig { bind_address: format!("0.0.0.0:{}", rtmp_port).parse().unwrap(), tls, }, - transcoder: TranscoderConfig { + ingest: IngestConfig { events_subject: Uuid::new_v4().to_string(), + transcoder_request_subject: Uuid::new_v4().to_string(), + bitrate_update_interval: Duration::from_secs(1), + ..Default::default() }, ..Default::default() }) .await; - global - .rmq - .aquire() - .await - .unwrap() - .queue_declare( - &global.config.transcoder.events_subject.clone(), - QueueDeclareOptions { - auto_delete: true, - durable: false, - ..Default::default() - }, - Default::default(), - ) - .await - .unwrap(); - let ingest_handle = tokio::spawn(crate::ingest::run(global.clone())); + let grpc_handle = tokio::spawn(crate::grpc::run(global.clone())); + + let transcoder_requests = { + let global = global.clone(); + let mut stream = global + .nats + .subscribe(global.config.ingest.transcoder_request_subject.clone()) + .await + .unwrap(); + stream!({ + loop { + select! { + message = stream.next() => { + let message = message.unwrap(); + yield TranscoderRequest::decode(message.payload).unwrap(); + } + _ = global.ctx.done() => { + break; + } + } + } + }) + }; - let stream = { + let organization_events = { let global = global.clone(); + let mut stream = global + .nats + .subscribe(format!("{}.*", global.config.ingest.events_subject)) + .await + .unwrap(); stream!({ - let mut stream = pin!(global.rmq.basic_consume( - global.config.transcoder.events_subject.clone(), - "", - Default::default(), - Default::default() - )); loop { select! { message = stream.next() => { - let message = message.unwrap().unwrap(); - yield TranscoderMessage::decode(message.data.as_slice()).unwrap(); + let message = message.unwrap(); + yield OrganizationEvent::decode(message.payload).unwrap(); } _ = global.ctx.done() => { break; @@ -297,25 +262,47 @@ impl TestState { }) }; + let org_id = Ulid::new(); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, $2)") + .bind(Uuid::from(org_id)) + .bind("test") + .execute(global.db.as_ref()) + .await + .unwrap(); + + let room_id = Ulid::new(); + + sqlx::query("INSERT INTO rooms (organization_id, id, stream_key) VALUES ($1, $2, $3)") + .bind(Uuid::from(org_id)) + .bind(Uuid::from(room_id)) + .bind(Uuid::from(room_id).simple().to_string()) + .execute(global.db.as_ref()) + .await + .unwrap(); + Self { + org_id, + room_id, rtmp_port, global, handler, - api_rx, - transcoder_stream: Box::pin(stream), + organization_events: Box::pin(organization_events), + transcoder_requests: Box::pin(transcoder_requests), ingest_handle, + grpc_handle, } } - async fn transcoder_message(&mut self) -> TranscoderMessage { - tokio::time::timeout(Duration::from_secs(2), self.transcoder_stream.next()) + async fn transcoder_request(&mut self) -> TranscoderRequest { + tokio::time::timeout(Duration::from_secs(2), self.transcoder_requests.next()) .await .expect("failed to receive event") .expect("failed to receive event") } - async fn api_recv(&mut self) -> IncomingRequest { - tokio::time::timeout(Duration::from_secs(2), self.api_rx.recv()) + async fn organization_event(&mut self) -> OrganizationEvent { + tokio::time::timeout(Duration::from_secs(2), self.organization_events.next()) .await .expect("failed to receive event") .expect("failed to receive event") @@ -324,1713 +311,487 @@ impl TestState { fn finish(self) -> impl futures::Future { let handler = self.handler; let ingest_handle = self.ingest_handle; + let grpc_handle = self.grpc_handle; async move { handler.cancel().await; assert!(ingest_handle.is_finished()); + assert!(grpc_handle.is_finished()); } } - - async fn api_assert_authenticate(&mut self, response: Result) { - match self.api_recv().await { - IncomingRequest::Authenticate((request, send)) => { - assert_eq!(request.stream_key, "stream-key"); - assert_eq!(request.app_name, "live"); - assert!(!request.connection_id.is_empty()); - assert!(!request.ingest_address.is_empty()); - send.send(response).unwrap(); - } - _ => panic!("unexpected event"), - } - } - - async fn api_assert_authenticate_ok(&mut self, record: bool, transcode: bool) -> Uuid { - let stream_id = Uuid::new_v4(); - self.api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { - stream_id: stream_id.to_string(), - record, - transcode, - state: None, - })) - .await; - stream_id - } } #[tokio::test] async fn test_ingest_stream() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, false).await; - - let stream_state; - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - assert_eq!(v.transcodes.len(), 2); // We are not transcoding so this is source and audio only - assert_eq!(v.variants.len(), 2); // We are not transcoding so this is source and audio only - - let source_variant = v.variants.iter().find(|v| v.name == "source").unwrap(); - assert_eq!(source_variant.group, "aac"); - assert_eq!(source_variant.transcode_ids.len(), 2); - - let audio_only_variant = - v.variants.iter().find(|v| v.name == "audio-only").unwrap(); - assert_eq!(audio_only_variant.group, "aac"); - assert_eq!(audio_only_variant.transcode_ids.len(), 1); - - let audio_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == audio_only_variant.transcode_ids[0]) - .unwrap(); - - assert_eq!(audio_transcode_state.bitrate, 128 * 1024); - assert_eq!(audio_transcode_state.codec, "mp4a.40.2"); - assert_eq!( - audio_transcode_state.settings, - Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - } - )) - ); - assert!(!audio_transcode_state.copy); - - let source_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == source_variant.transcode_ids[0]) - .unwrap(); - - assert_eq!(source_transcode_state.codec, "avc1.64001f"); - assert_eq!(source_transcode_state.bitrate, 1276158); - assert_eq!( - source_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - width: 468, - height: 864, - framerate: 30, - } - )) - ); - assert!(source_transcode_state.copy); - - stream_state = Some(v.clone()); - - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), + ); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); + match update.event { + Some(organization_event::Event::RoomLive(room_live)) => { + assert_eq!(room_live.room_id.to_ulid(), state.room_id); + assert!(!room_live.connection_id.to_ulid().is_nil()); } _ => panic!("unexpected event"), } - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; + let room: video_database::room::Room = + sqlx::query_as("SELECT * FROM rooms WHERE organization_id = $1 AND id = $2") + .bind(Uuid::from(state.org_id)) + .bind(Uuid::from(state.room_id)) + .fetch_one(state.global.db.as_ref()) + .await + .unwrap(); - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); + assert!(room.last_live_at.is_some()); + assert!(room.last_disconnected_at.is_none()); + assert!(room.video_input.is_some()); + assert!(room.audio_input.is_some()); + + let video_input = room.video_input.unwrap(); + let audio_input = room.audio_input.unwrap(); + + assert_eq!(video_input.rendition, RenditionVideo::SourceVideo as i32); + assert_eq!(video_input.codec, "avc1.64001f"); + assert_eq!(video_input.width, 468); + assert_eq!(video_input.height, 864); + assert_eq!(video_input.fps, 30); + assert_eq!(video_input.bitrate, 1276158); + + assert_eq!(audio_input.rendition, RenditionAudio::SourceAudio as i32); + assert_eq!(audio_input.codec, "mp4a.40.2"); + assert_eq!(audio_input.sample_rate, 44100); + assert_eq!(audio_input.channels, 2); + assert_eq!(audio_input.bitrate, 69568); + + assert_eq!( + room.status, + video_database::room_status::RoomStatus::WaitingForTranscoder + ); + + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert!(!msg.room_id.to_ulid().is_nil()); + assert!(!msg.connection_id.to_ulid().is_nil()); + assert!(!msg.organization_id.to_ulid().is_nil()); + assert!(!msg.grpc_endpoint.is_empty()); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + } _ => panic!("unexpected event"), } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - assert_eq!(ms.ty, MediaType::Video); - assert_eq!(ms.timestamp, 0); - } + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - state - .global - .connection_manager - .submit_request( - stream_id, - GrpcRequest::TranscoderShuttingDown { id: request_id }, - ) - .await; - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - // It should now create a new transcoder to handle the stream - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; + watcher + .send + .send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Request as i32, + )), + }) + .await + .unwrap(); - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); + // It should now create a new transcoder to handle the stream + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert!(!msg.room_id.to_ulid().is_nil()); + assert!(!msg.connection_id.to_ulid().is_nil()); + assert!(!msg.organization_id.to_ulid().is_nil()); + assert!(!msg.grpc_endpoint.is_empty()); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut new_watcher = Watcher::new(&state, stream_id, request_id).await; + let mut new_watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - let mut previous_audio_ts = 0; - let mut previous_video_ts = 0; let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(!ms.keyframe); - match ms.ty { - MediaType::Audio => { - assert!(ms.timestamp >= previous_audio_ts); - previous_audio_ts = ms.timestamp; - } - MediaType::Video => { - assert!(ms.timestamp >= previous_video_ts); - previous_video_ts = ms.timestamp; - } - } + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); } - WatchStreamEvent::ShuttingDown(false) => { + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); + } + ingest_watch_response::Message::Shutdown(s) => { + assert_eq!(s, ingest_watch_response::Shutdown::Transcoder as i32); got_shutting_down = true; break; } - _ => panic!("unexpected event"), - } - } - - assert!(got_shutting_down); - - match new_watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), - _ => panic!("unexpected event"), - } - - match new_watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - assert_eq!(ms.timestamp, 1000); - assert_eq!(ms.ty, MediaType::Video); - previous_video_ts = 1000; - } - _ => panic!("unexpected event"), - } - - while let Ok(msg) = new_watcher.rx.try_recv() { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - match ms.ty { - MediaType::Audio => { - assert!(ms.timestamp >= previous_audio_ts); - previous_audio_ts = ms.timestamp; - } - MediaType::Video => { - assert!(ms.timestamp >= previous_video_ts); - previous_video_ts = ms.timestamp; - } - } - } - _ => panic!("unexpected event"), } } - // Assert that no messages with keyframes made it to the old channel - - ffmpeg.kill().await.unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); + watcher + .send + .send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete as i32, + )), + }) + .await + .unwrap(); - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::StoppedResumable as i32); - } - u => { - panic!("unexpected update: {:?}", u); - } - } + assert!(got_shutting_down); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match new_watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - tracing::info!("waiting for transcoder to exit"); - - state.finish().await; -} - -#[tokio::test] -async fn test_ingest_stream_transcoder_disconnect() { - let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, true).await; - - let stream_state; - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - assert_eq!(v.transcodes.len(), 4); // We are not transcoding so this is source and audio only - assert_eq!(v.variants.len(), 6); // We are not transcoding so this is source and audio only - - let audio_only_aac = v - .variants - .iter() - .find(|v| v.name == "audio-only" && v.group == "aac") - .unwrap(); - assert_eq!(audio_only_aac.transcode_ids.len(), 1); - - let audio_only_opus = v - .variants - .iter() - .find(|v| v.name == "audio-only" && v.group == "opus") - .unwrap(); - assert_eq!(audio_only_opus.transcode_ids.len(), 1); - - let source_aac = v - .variants - .iter() - .find(|v| v.name == "source" && v.group == "aac") - .unwrap(); - assert_eq!(source_aac.transcode_ids.len(), 2); - - let source_opus = v - .variants - .iter() - .find(|v| v.name == "source" && v.group == "opus") - .unwrap(); - assert_eq!(source_opus.transcode_ids.len(), 2); - - let _360p_aac = v - .variants - .iter() - .find(|v| v.name == "360p" && v.group == "aac") - .unwrap(); - assert_eq!(_360p_aac.transcode_ids.len(), 2); - - let _360p_opus = v - .variants - .iter() - .find(|v| v.name == "360p" && v.group == "opus") - .unwrap(); - assert_eq!(_360p_opus.transcode_ids.len(), 2); - - let audio_aac_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == audio_only_aac.transcode_ids[0]) - .unwrap(); - assert!(!audio_aac_transcode_state.copy); - assert_eq!(audio_aac_transcode_state.codec, "mp4a.40.2"); - assert_eq!(audio_aac_transcode_state.bitrate, 128 * 1024); - assert_eq!( - audio_aac_transcode_state.settings, - Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - } - )) - ); - - let audio_opus_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == audio_only_opus.transcode_ids[0]) - .unwrap(); - assert!(!audio_opus_transcode_state.copy); - assert_eq!(audio_opus_transcode_state.codec, "opus"); - assert_eq!(audio_opus_transcode_state.bitrate, 96 * 1024); - assert_eq!( - audio_opus_transcode_state.settings, - Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - } - )) - ); - - let source_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == source_aac.transcode_ids[0]) - .unwrap(); - assert!(source_video_transcode_state.copy); - assert_eq!(source_video_transcode_state.codec, "avc1.64001f"); - assert_eq!(source_video_transcode_state.bitrate, 1276158); - assert_eq!( - source_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - width: 468, - height: 864, - framerate: 30, - } - )) - ); - - assert_eq!(source_aac.transcode_ids[0], source_opus.transcode_ids[0]); - assert_eq!(source_aac.transcode_ids[1], audio_aac_transcode_state.id); - assert_eq!(source_opus.transcode_ids[1], audio_opus_transcode_state.id); - - let _360p_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == _360p_aac.transcode_ids[0]) - .unwrap(); - assert!(!_360p_video_transcode_state.copy); - assert_eq!(_360p_video_transcode_state.codec, "avc1.640033"); - assert_eq!(_360p_video_transcode_state.bitrate, 1024000); - assert_eq!( - _360p_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - width: 360, - height: 665, - framerate: 30, - } - )) - ); - - assert_eq!(_360p_aac.transcode_ids[0], _360p_opus.transcode_ids[0]); - assert_eq!(_360p_aac.transcode_ids[1], audio_aac_transcode_state.id); - assert_eq!(_360p_opus.transcode_ids[1], audio_opus_transcode_state.id); - - stream_state = Some(v.clone()); - - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } + let mut got_ready = false; - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } + while let Ok(Some(msg)) = new_watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; - - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), - _ => panic!("unexpected event"), - } - - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - } - _ => panic!("unexpected event"), - } - - // Force disconnect the transcoder - drop(watcher); - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Transcoder Disconnected"); - assert_eq!(event.level, Level::Warning as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } + ingest_watch_response::Message::Ready(_) => { + got_ready = true; + break; } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } + ingest_watch_response::Message::Shutdown(_) => { + panic!("unexpected shutdown"); } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); } - _ => panic!("unexpected event"), } - // It should now create a new transcoder to handle the stream - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + assert!(got_ready); - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), - _ => panic!("unexpected event"), + while let Ok(Some(msg)) = watcher.recv.message().await { + panic!("unexpected message: {:?}", msg); } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - } - _ => panic!("unexpected event"), - } + // Assert that no messages with keyframes made it to the old channel ffmpeg.kill().await.unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::StoppedResumable as i32); - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); + match update.event { + Some(organization_event::Event::RoomDisconnect(room_disconnect)) => { + assert_eq!(room_disconnect.room_id.to_ulid(), state.room_id); + assert!(!room_disconnect.connection_id.to_ulid().is_nil()); + assert!(!room_disconnect.clean); + assert_eq!(room_disconnect.error, None); } _ => panic!("unexpected event"), } - state.finish().await; -} - -#[tokio::test] -async fn test_ingest_stream_shutdown() { - let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, false).await; - - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - assert_eq!(v.variants.len(), 2); - assert_eq!(v.transcodes.len(), 2); - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } + tracing::info!("waiting for transcoder to exit"); - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::ShutdownStream) + let room: video_database::room::Room = + sqlx::query_as("SELECT * FROM rooms WHERE organization_id = $1 AND id = $2") + .bind(Uuid::from(state.org_id)) + .bind(Uuid::from(state.room_id)) + .fetch_one(state.global.db.as_ref()) .await - ); - - tracing::info!("waiting for transcoder to exit"); + .unwrap(); - assert!(ffmpeg.wait().timeout(Duration::from_secs(1)).await.is_ok()); + assert_eq!( + room.status, + video_database::room_status::RoomStatus::Offline + ); + assert!(room.last_disconnected_at.is_some()); + assert!(room.last_live_at.is_some()); + assert!(room.video_input.is_none()); + assert!(room.audio_input.is_none()); state.finish().await; } #[tokio::test] -async fn test_ingest_stream_transcoder_full() { +async fn test_ingest_stream_transcoder_disconnect() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, true).await; - - let stream_state; - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - let aac_audio_only = v - .variants - .iter() - .find(|v| v.name == "audio-only" && v.group == "aac") - .unwrap(); - assert_eq!(aac_audio_only.transcode_ids.len(), 1); - - let opus_audio_only = v - .variants - .iter() - .find(|v| v.name == "audio-only" && v.group == "opus") - .unwrap(); - assert_eq!(opus_audio_only.transcode_ids.len(), 1); - - let aac_source = v - .variants - .iter() - .find(|v| v.name == "source" && v.group == "aac") - .unwrap(); - assert_eq!(aac_source.transcode_ids.len(), 2); - - let opus_source = v - .variants - .iter() - .find(|v| v.name == "source" && v.group == "opus") - .unwrap(); - assert_eq!(opus_source.transcode_ids.len(), 2); - - let aac_720p = v - .variants - .iter() - .find(|v| v.name == "720p" && v.group == "aac") - .unwrap(); - assert_eq!(aac_720p.transcode_ids.len(), 2); - - let opus_720p = v - .variants - .iter() - .find(|v| v.name == "720p" && v.group == "opus") - .unwrap(); - assert_eq!(opus_720p.transcode_ids.len(), 2); - - let aac_480p = v - .variants - .iter() - .find(|v| v.name == "480p" && v.group == "aac") - .unwrap(); - assert_eq!(aac_480p.transcode_ids.len(), 2); - - let opus_480p = v - .variants - .iter() - .find(|v| v.name == "480p" && v.group == "opus") - .unwrap(); - assert_eq!(opus_480p.transcode_ids.len(), 2); - - let aac_360p = v - .variants - .iter() - .find(|v| v.name == "360p" && v.group == "aac") - .unwrap(); - assert_eq!(aac_360p.transcode_ids.len(), 2); - - let opus_360p = v - .variants - .iter() - .find(|v| v.name == "360p" && v.group == "opus") - .unwrap(); - assert_eq!(opus_360p.transcode_ids.len(), 2); - - let aac_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == aac_audio_only.transcode_ids[0]) - .unwrap(); - assert_eq!(aac_transcode_state.codec, "mp4a.40.2".to_string()); - assert_eq!(aac_transcode_state.bitrate, 128 * 1024); - assert!(!aac_transcode_state.copy); - assert_eq!( - aac_transcode_state.settings, - Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48_000, - } - )) - ); - - let opus_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == opus_audio_only.transcode_ids[0]) - .unwrap(); - assert_eq!(opus_transcode_state.codec, "opus".to_string()); - assert_eq!(opus_transcode_state.bitrate, 96 * 1024); - assert!(!opus_transcode_state.copy); - assert_eq!( - opus_transcode_state.settings, - Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48_000, - } - )) - ); - - // Now for the video source - let source_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == aac_source.transcode_ids[0]) - .unwrap(); - assert_eq!( - source_video_transcode_state.codec, - "avc1.640034".to_string() - ); - assert_eq!(source_video_transcode_state.bitrate, 1740285); - assert!(source_video_transcode_state.copy); - assert_eq!( - source_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 60, - height: 2160, - width: 3840, - } - )) - ); - - assert_eq!(aac_source.transcode_ids[0], opus_source.transcode_ids[0]); - assert_eq!(aac_source.transcode_ids[1], aac_transcode_state.id); - assert_eq!(opus_source.transcode_ids[1], opus_transcode_state.id); - - let _720p_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == aac_720p.transcode_ids[0]) - .unwrap(); - assert_eq!(_720p_video_transcode_state.codec, "avc1.640033".to_string()); - assert_eq!(_720p_video_transcode_state.bitrate, 4000 * 1024); - assert!(!_720p_video_transcode_state.copy); - assert_eq!( - _720p_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 60, - height: 720, - width: 1280, - } - )) - ); - - assert_eq!(aac_720p.transcode_ids[0], opus_720p.transcode_ids[0]); - assert_eq!(aac_720p.transcode_ids[1], aac_transcode_state.id); - assert_eq!(opus_720p.transcode_ids[1], opus_transcode_state.id); - - let _480p_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == aac_480p.transcode_ids[0]) - .unwrap(); - assert_eq!(_480p_video_transcode_state.codec, "avc1.640033".to_string()); - assert_eq!(_480p_video_transcode_state.bitrate, 2000 * 1024); - assert!(!_480p_video_transcode_state.copy); - assert_eq!( - _480p_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 30, - height: 480, - width: 853, - } - )) - ); - - assert_eq!(aac_480p.transcode_ids[0], opus_480p.transcode_ids[0]); - assert_eq!(aac_480p.transcode_ids[1], aac_transcode_state.id); - assert_eq!(opus_480p.transcode_ids[1], opus_transcode_state.id); - - let _360p_video_transcode_state = v - .transcodes - .iter() - .find(|s| s.id == aac_360p.transcode_ids[0]) - .unwrap(); - assert_eq!(_360p_video_transcode_state.codec, "avc1.640033".to_string()); - assert_eq!(_360p_video_transcode_state.bitrate, 1000 * 1024); - assert!(!_360p_video_transcode_state.copy); - assert_eq!( - _360p_video_transcode_state.settings, - Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 30, - height: 360, - width: 640, - } - )) - ); - - assert_eq!(aac_360p.transcode_ids[0], opus_360p.transcode_ids[0]); - assert_eq!(aac_360p.transcode_ids[1], aac_transcode_state.id); - assert_eq!(opus_360p.transcode_ids[1], opus_transcode_state.id); - - stream_state = Some(v.clone()); - - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), + ); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); + match update.event { + Some(organization_event::Event::RoomLive(room_live)) => { + assert_eq!(room_live.room_id.to_ulid(), state.room_id); + assert!(!room_live.connection_id.to_ulid().is_nil()); } _ => panic!("unexpected event"), } - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert!(!msg.room_id.to_ulid().is_nil()); + assert!(!msg.connection_id.to_ulid().is_nil()); + assert!(!msg.organization_id.to_ulid().is_nil()); + assert!(!msg.grpc_endpoint.is_empty()); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; - - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), - _ => panic!("unexpected event"), - } + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Ready as i32); // Stream is ready - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - // Finish the stream - let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - } - WatchStreamEvent::ShuttingDown(true) => { - got_shutting_down = true; - break; - } - _ => panic!("unexpected event"), - } - } - - assert!(got_shutting_down); - - tokio::time::sleep(Duration::from_millis(200)).await; - - assert!(ffmpeg.try_wait().is_ok()); - - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - state.finish().await; -} - -#[tokio::test] -async fn test_ingest_stream_reject() { - let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); - - let stream_id = Uuid::new_v4(); - state - .api_assert_authenticate(Err(Status::permission_denied("invalid stream key"))) - .await; - - assert!( - tokio::time::timeout(Duration::from_secs(1), state.transcoder_stream.next()) - .await - .is_err() - ); - - tokio::time::sleep(Duration::from_millis(200)).await; - - assert!(ffmpeg.try_wait().is_ok()); - - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request( - stream_id, - GrpcRequest::TranscoderStarted { id: Uuid::new_v4() } - ) - .await - ); - - state.finish().await; -} + // Force disconnect the transcoder + drop(watcher); -#[tokio::test] -async fn test_ingest_stream_transcoder_error() { - let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, true).await; - - let stream_state; - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - stream_state = v.clone(); - - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert!(!msg.room_id.to_ulid().is_nil()); + assert!(!msg.connection_id.to_ulid().is_nil()); + assert!(!msg.organization_id.to_ulid().is_nil()); + assert!(!msg.grpc_endpoint.is_empty()); - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, Some(stream_state)); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; - - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); } - _ => panic!("unexpected event"), + r => panic!("unexpected event: {:?}", r), } - assert!( - state - .global - .connection_manager - .submit_request( - stream_id, - GrpcRequest::TranscoderError { - id: request_id, - message: "test".to_string(), - fatal: false, - } - ) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 2); - - let u = &update.updates[0]; - assert!(u.timestamp > 0); - - match &u.update { - Some(update_live_stream_request::update::Update::Event(ev)) => { - assert_eq!(ev.title, "Transcoder Error"); - assert_eq!(ev.message, "test"); - assert_eq!(ev.level, Level::Error as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - let u = &update.updates[1]; - assert!(u.timestamp > 0); - - match &u.update { - Some(update_live_stream_request::update::Update::ReadyState(s)) => { - assert_eq!(*s, StreamReadyState::Failed as i32); - } - u => { - panic!("unexpected update: {:?}", u); - } - } + ffmpeg.kill().await.unwrap(); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); + match update.event { + Some(organization_event::Event::RoomDisconnect(room_disconnect)) => { + assert_eq!(room_disconnect.room_id.to_ulid(), state.room_id); + assert!(!room_disconnect.connection_id.to_ulid().is_nil()); + assert!(!room_disconnect.clean); + assert_eq!(room_disconnect.error, None); } _ => panic!("unexpected event"), } - // Finish the stream - let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - } - WatchStreamEvent::ShuttingDown(true) => { - got_shutting_down = true; - break; - } - _ => {} - } - } - - assert!(got_shutting_down); - - tokio::time::sleep(Duration::from_millis(200)).await; - - assert!(ffmpeg.try_wait().is_ok()); - - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - assert!( - tokio::time::timeout(Duration::from_secs(1), state.api_rx.recv()) - .await - .is_err() - ); - state.finish().await; } #[tokio::test] -async fn test_ingest_stream_try_resume_success() { +async fn test_ingest_stream_shutdown() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); - - let stream_id = Uuid::new_v4(); - - let audio_transcode_id = Uuid::new_v4(); - let source_transcode_id = Uuid::new_v4(); - - let stream_state = StreamState { - variants: vec![ - stream_state::Variant { - group: "aac".to_string(), - name: "source".to_string(), - transcode_ids: vec![ - source_transcode_id.to_string(), - audio_transcode_id.to_string(), - ], - }, - stream_state::Variant { - group: "aac".to_string(), - name: "audio-only".to_string(), - transcode_ids: vec![audio_transcode_id.to_string()], - }, - ], - transcodes: vec![ - stream_state::Transcode { - id: source_transcode_id.to_string(), - codec: "avc1.640034".to_string(), - bitrate: 1740285, - copy: true, - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - width: 3840, - height: 2160, - framerate: 60, - }, - )), - }, - stream_state::Transcode { - id: audio_transcode_id.to_string(), - codec: "mp4a.40.2".to_string(), - bitrate: 128 * 1024, - copy: false, - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - }, - ], - groups: vec![stream_state::Group { - name: "aac".to_string(), - priority: 1, - }], - }; - - state - .api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { - stream_id: stream_id.to_string(), - record: false, - transcode: false, - state: Some(stream_state.clone()), - })) - .await; - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, Some(stream_state)); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; - - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), - _ => panic!("unexpected event"), - } - - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - } - _ => panic!("unexpected event"), - } - - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), ); - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Ready as i32); // Stream is ready - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let connection_id = match update.event { + Some(organization_event::Event::RoomLive(room_live)) => { + assert_eq!(room_live.room_id.to_ulid(), state.room_id); + assert!(!room_live.connection_id.to_ulid().is_nil()); + room_live.connection_id.to_ulid() } _ => panic!("unexpected event"), - } - - // Finish the stream - let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - } - WatchStreamEvent::ShuttingDown(true) => { - got_shutting_down = true; - break; - } - _ => panic!("unexpected event"), - } - } - - assert!(got_shutting_down); + }; - tokio::time::sleep(Duration::from_millis(200)).await; + state + .global + .nats + .publish(format!("ingest.{}.disconnect", connection_id), Bytes::new()) + .await + .unwrap(); - assert!(ffmpeg.try_wait().is_ok()); + tracing::info!("waiting for transcoder to exit"); - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); + assert!(ffmpeg.wait().timeout(Duration::from_secs(1)).await.is_ok()); - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); + let update = state.organization_event().await; - let update = &update.updates[0]; - assert!(update.timestamp > 0); + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match update.event { + Some(organization_event::Event::RoomDisconnect(room_disconnect)) => { + assert_eq!(room_disconnect.room_id.to_ulid(), state.room_id); + assert_eq!(room_disconnect.connection_id.to_ulid(), connection_id); + assert!(room_disconnect.clean); + assert_eq!( + room_disconnect.error, + Some("I14: Disconnect requested".into()) + ); } _ => panic!("unexpected event"), } - state.finish().await; -} - -#[tokio::test] -async fn test_ingest_stream_try_resume_failed() { - let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); - - let mut stream_id = Uuid::new_v4(); - - let audio_transcode_id = Uuid::new_v4(); - let source_transcode_id = Uuid::new_v4(); - - state - .api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { - stream_id: stream_id.to_string(), - record: false, - transcode: false, - state: Some(StreamState { - variants: vec![ - stream_state::Variant { - group: "aac".to_string(), - name: "source".to_string(), - transcode_ids: vec![ - source_transcode_id.to_string(), - audio_transcode_id.to_string(), - ], - }, - stream_state::Variant { - group: "aac".to_string(), - name: "audio-only".to_string(), - transcode_ids: vec![audio_transcode_id.to_string()], - }, - ], - transcodes: vec![ - stream_state::Transcode { - id: source_transcode_id.to_string(), - codec: "avc1.640034".to_string(), - bitrate: 1740285, - copy: true, - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - width: 3840, - height: 2160, - framerate: 30, // Note we changed this to 30fps from 60 so that we could cause the stream to fail - }, - )), - }, - stream_state::Transcode { - id: audio_transcode_id.to_string(), - codec: "mp4a.40.2".to_string(), - bitrate: 128 * 1024, - copy: false, - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - }, - ], - groups: vec![stream_state::Group { - name: "aac".to_string(), - priority: 1, - }], - }), - })) - .await; - - let stream_state; - match state.api_recv().await { - IncomingRequest::New((new, response)) => { - assert_eq!(new.old_stream_id, stream_id.to_string()); + let room: Room = sqlx::query_as("SELECT * FROM rooms WHERE organization_id = $1 AND id = $2") + .bind(Uuid::from(state.org_id)) + .bind(Uuid::from(state.room_id)) + .fetch_one(state.global.db.as_ref()) + .await + .unwrap(); - stream_state = Some(new.state.unwrap()); + assert_eq!( + room.status, + video_database::room_status::RoomStatus::Offline + ); + assert!(room.last_disconnected_at.is_some()); + assert!(room.last_live_at.is_some()); + assert!(room.active_ingest_connection_id.is_none()); - stream_id = Uuid::new_v4(); + state.finish().await; +} - response - .send(Ok(NewLiveStreamResponse { - stream_id: stream_id.to_string(), - })) - .unwrap(); - } - _ => panic!("unexpected event"), - } +#[tokio::test] +async fn test_ingest_stream_transcoder_full() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_large.mp4", + &generate_key(state.org_id, state.room_id), + ); - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let update = state.organization_event().await; + assert!(update.timestamp > 0); + assert_eq!(update.id.to_ulid(), state.org_id); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + let connection_id = match update.event { + Some(organization_event::Event::RoomLive(room_live)) => { + assert_eq!(room_live.room_id.to_ulid(), state.room_id); + assert!(!room_live.connection_id.to_ulid().is_nil()); + room_live.connection_id } _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), }; - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); + let room: Room = sqlx::query_as("SELECT * FROM rooms WHERE organization_id = $1 AND id = $2") + .bind(Uuid::from(state.org_id)) + .bind(Uuid::from(state.room_id)) + .fetch_one(state.global.db.as_ref()) + .await + .unwrap(); + + assert_eq!( + room.status, + video_database::room_status::RoomStatus::WaitingForTranscoder + ); + assert!(room.last_disconnected_at.is_none()); + assert!(room.last_live_at.is_some()); + assert!(room.active_ingest_connection_id.is_some()); + assert!(room.video_input.is_some()); + assert!(room.audio_input.is_some()); + + let video_input = room.video_input.unwrap(); + let audio_input = room.audio_input.unwrap(); + assert_eq!(video_input.codec, "avc1.640034"); + assert_eq!(audio_input.codec, "mp4a.40.2"); + assert_eq!(video_input.width, 3840); + assert_eq!(video_input.height, 2160); + assert_eq!(video_input.fps, 60); + assert_eq!(audio_input.sample_rate, 48000); + assert_eq!(audio_input.channels, 2); + assert_eq!(video_input.bitrate, 1740285); + assert_eq!(audio_input.bitrate, 140304); + + let msg = state.transcoder_request().await; + assert_eq!( + msg.connection_id.to_uuid(), + room.active_ingest_connection_id.unwrap() + ); + assert!(!msg.request_id.to_ulid().is_nil()); + assert_eq!(msg.organization_id.to_ulid(), state.org_id); + assert_eq!(msg.room_id.to_ulid(), state.room_id); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + } _ => panic!("unexpected event"), } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - } + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Ready as i32); // Stream is ready - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - // Finish the stream let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); + + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); } - WatchStreamEvent::ShuttingDown(true) => { + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); + } + ingest_watch_response::Message::Shutdown(_) => { got_shutting_down = true; break; } - _ => panic!("unexpected event"), } } @@ -2040,36 +801,40 @@ async fn test_ingest_stream_try_resume_failed() { assert!(ffmpeg.try_wait().is_ok()); - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); + let room_disconnect = state.organization_event().await; + assert!(room_disconnect.timestamp > 0); + assert_eq!(room_disconnect.id.to_ulid(), state.org_id); - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); + match room_disconnect.event { + Some(organization_event::Event::RoomDisconnect(room_disconnect)) => { + assert_eq!(room_disconnect.room_id.to_ulid(), state.room_id); + assert_eq!(room_disconnect.connection_id, connection_id); + assert!(room_disconnect.clean); + assert!(room_disconnect.error.is_none()); + } + _ => panic!("unexpected event"), + } - let update = &update.updates[0]; - assert!(update.timestamp > 0); + state.finish().await; +} - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } +#[tokio::test] +async fn test_ingest_stream_reject() { + let state = TestState::setup().await; - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), + let bad_keys = vec![ + "bad_key".into(), + "live_bad_key".into(), + generate_key(state.org_id, state.org_id), + generate_key(state.room_id, state.room_id), + ]; + + for bad_key in bad_keys { + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4", &bad_key); + + tokio::time::sleep(Duration::from_millis(200)).await; + + assert!(ffmpeg.try_wait().is_ok()); } state.finish().await; @@ -2077,144 +842,69 @@ async fn test_ingest_stream_try_resume_failed() { async fn test_ingest_stream_transcoder_full_tls(tls_dir: PathBuf) { let mut state = TestState::setup_with_tls(&tls_dir).await; - let mut ffmpeg = stream_with_ffmpeg_tls(state.rtmp_port, "avc_aac_large.mp4", &tls_dir); - - let stream_id = Uuid::new_v4(); - match state.api_recv().await { - IncomingRequest::Authenticate((request, send)) => { - assert_eq!(request.stream_key, "stream-key"); - assert_eq!(request.app_name, "live"); - assert!(!request.connection_id.is_empty()); - assert!(!request.ingest_address.is_empty()); - send.send(Ok(AuthenticateLiveStreamResponse { - stream_id: stream_id.to_string(), - record: false, - transcode: true, - state: None, - })) - .unwrap(); - } - _ => panic!("unexpected event"), - } - - let stream_state; - match state.api_recv().await { - IncomingRequest::Update((request, send)) => { - assert_eq!(request.stream_id, stream_id.to_string()); - match &request.updates[0].update { - Some( - crate::pb::scuffle::backend::update_live_stream_request::update::Update::State( - v, - ), - ) => { - stream_state = Some(v.clone()); - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected update"), - } - } - _ => panic!("unexpected event"), - } + let mut ffmpeg = stream_with_ffmpeg_tls( + state.rtmp_port, + "avc_aac_large.mp4", + &tls_dir, + &generate_key(state.org_id, state.room_id), + ); - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::Event(event)) => { - assert_eq!(event.title, "Requested Transcoder"); - assert_eq!( - event.message, - "Requested a transcoder to be assigned to this stream" - ); - assert_eq!(event.level, Level::Info as i32) - } - u => { - panic!("unexpected update: {:?}", u); - } - } + let live = state.organization_event().await; + assert!(live.timestamp > 0); + assert_eq!(live.id.to_ulid(), state.org_id); - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match live.event { + Some(organization_event::Event::RoomLive(live)) => { + assert_eq!(live.room_id.to_ulid(), state.room_id); + assert!(!live.connection_id.to_ulid().is_nil()); } _ => panic!("unexpected event"), } - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - assert_eq!(data.state, stream_state); + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert!(!msg.room_id.to_ulid().is_nil()); + assert!(!msg.connection_id.to_ulid().is_nil()); + assert!(!msg.organization_id.to_ulid().is_nil()); + assert!(!msg.grpc_endpoint.is_empty()); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + } _ => panic!("unexpected event"), } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); - assert!(ms.keyframe); - } + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Ready as i32); // Stream is ready - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); } _ => panic!("unexpected event"), } - // Finish the stream let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - assert!(!ms.data.is_empty()); + + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); } - WatchStreamEvent::ShuttingDown(true) => { + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); + } + ingest_watch_response::Message::Shutdown(_) => { got_shutting_down = true; break; } - _ => panic!("unexpected event"), } } @@ -2224,39 +914,21 @@ async fn test_ingest_stream_transcoder_full_tls(tls_dir: PathBuf) { assert!(ffmpeg.try_wait().is_ok()); - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); + let room_disconnect = state.organization_event().await; + assert!(room_disconnect.timestamp > 0); + assert_eq!(room_disconnect.id.to_ulid(), state.org_id); - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match room_disconnect.event { + Some(organization_event::Event::RoomDisconnect(room_disconnect)) => { + assert_eq!(room_disconnect.room_id.to_ulid(), state.room_id); + assert!(!room_disconnect.connection_id.to_ulid().is_nil()); + assert!(room_disconnect.clean); + assert!(room_disconnect.error.is_none()); } _ => panic!("unexpected event"), } - // state.finish().await; + state.finish().await; } #[tokio::test] @@ -2278,82 +950,72 @@ async fn test_ingest_stream_transcoder_full_tls_ec() { #[tokio::test] async fn test_ingest_stream_transcoder_probe() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, false).await; + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), + ); - match state.api_recv().await { - IncomingRequest::Update((_, send)) => { - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } + let live = state.organization_event().await; + assert!(live.timestamp > 0); + assert_eq!(live.id.to_ulid(), state.org_id); - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match live.event { + Some(organization_event::Event::RoomLive(live)) => { + assert_eq!(live.room_id.to_ulid(), state.room_id); + assert!(!live.connection_id.to_ulid().is_nil()); } _ => panic!("unexpected event"), } - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); + let msg = state.transcoder_request().await; + assert!(!msg.request_id.to_ulid().is_nil()); + assert_eq!(msg.organization_id.to_ulid(), state.org_id); + assert_eq!(msg.room_id.to_ulid(), state.room_id); // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; - - let mut ffprobe = spawn_ffprobe(); - let writer = ffprobe.stdin.as_mut().unwrap(); + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); + } _ => panic!("unexpected event"), } - match watcher.recv().await { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); - } + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } // Finish the stream let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); + } + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); } - WatchStreamEvent::ShuttingDown(true) => { + ingest_watch_response::Message::Shutdown(_) => { got_shutting_down = true; break; } - _ => panic!("unexpected event"), } } @@ -2398,117 +1060,66 @@ async fn test_ingest_stream_transcoder_probe() { assert_eq!(audio_stream["profile"], "LC"); } - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - state.finish().await; } #[tokio::test] async fn test_ingest_stream_transcoder_probe_reconnect() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, false).await; - - match state.api_recv().await { - IncomingRequest::Update((_, send)) => { - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), + ); let mut ffprobe = spawn_ffprobe(); let writer = ffprobe.stdin.as_mut().unwrap(); - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), - _ => panic!("unexpected event"), - } + let msg = state.transcoder_request().await; - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } - // Finish the stream + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} + _ => panic!("unexpected event"), + } + let mut i = 0; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } + i += 1; - if i > 10 { + if i == 10 { break; } } + watcher + .send + .send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Request.into(), + )), + }) + .await + .unwrap(); + let output = ffprobe.wait_with_output().await.unwrap(); assert!(output.status.success()); @@ -2544,93 +1155,68 @@ async fn test_ingest_stream_transcoder_probe_reconnect() { assert_eq!(audio_stream["profile"], "LC"); } - assert!( - state - .global - .connection_manager - .submit_request( - stream_id, - GrpcRequest::TranscoderShuttingDown { id: request_id } - ) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); + let msg = state.transcoder_request().await; - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut new_watcher = Watcher::new(&state, stream_id, request_id).await; + let mut new_watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(_) => {} - WatchStreamEvent::ShuttingDown(false) => { + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + } + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); + } + ingest_watch_response::Message::Shutdown(_) => { got_shutting_down = true; break; } - _ => panic!("unexpected event: {:?}", msg), } } assert!(got_shutting_down); + watcher + .send + .send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete.into(), + )), + }) + .await + .unwrap(); + let mut ffprobe = spawn_ffprobe(); let writer = ffprobe.stdin.as_mut().unwrap(); - match new_watcher.recv().await { - WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), - _ => panic!("unexpected event"), - } - - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match new_watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } - // Finish the stream - let mut got_shutting_down = false; - while let Some(msg) = new_watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); + let mut got_ready = false; + + while let Ok(Some(msg)) = new_watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } - WatchStreamEvent::ShuttingDown(true) => { - got_shutting_down = true; + ingest_watch_response::Message::Ready(_) => { + got_ready = true; + } + ingest_watch_response::Message::Shutdown(_) => { break; } - _ => panic!("unexpected event"), } } - assert!(got_shutting_down); + assert!(got_ready); let output = ffprobe.wait_with_output().await.unwrap(); assert!(output.status.success()); @@ -2671,117 +1257,58 @@ async fn test_ingest_stream_transcoder_probe_reconnect() { assert!(ffmpeg.try_wait().is_ok()); - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - state.finish().await; } #[tokio::test] async fn test_ingest_stream_transcoder_probe_reconnect_unexpected() { let mut state = TestState::setup().await; - let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); - - let stream_id = state.api_assert_authenticate_ok(false, false).await; - - match state.api_recv().await { - IncomingRequest::Update((_, send)) => { - send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); - - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut ffmpeg = stream_with_ffmpeg( + state.rtmp_port, + "avc_aac_keyframes.mp4", + &generate_key(state.org_id, state.room_id), + ); let mut ffprobe = spawn_ffprobe(); let writer = ffprobe.stdin.as_mut().unwrap(); - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), - _ => panic!("unexpected event"), - } + let msg = state.transcoder_request().await; - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); + let mut watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } - // Finish the stream + match watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} + _ => panic!("unexpected event"), + } + let mut i = 0; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); + while let Ok(Some(msg)) = watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } _ => panic!("unexpected event"), } + i += 1; - if i > 10 { + if i == 10 { break; } } + drop(watcher); + let output = ffprobe.wait_with_output().await.unwrap(); assert!(output.status.success()); @@ -2817,79 +1344,42 @@ async fn test_ingest_stream_transcoder_probe_reconnect_unexpected() { assert_eq!(audio_stream["profile"], "LC"); } - // Now drop the stream - drop(watcher); - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - - let msg = state.transcoder_message().await; - assert!(!msg.id.is_empty()); - assert!(msg.timestamp > 0); - let data = match msg.data { - Some(transcoder_message::Data::NewStream(data)) => data, - _ => panic!("unexpected message"), - }; - - assert!(!data.request_id.is_empty()); - assert_eq!(data.stream_id, stream_id.to_string()); + let msg = state.transcoder_request().await; - // We should now be able to join the stream - let stream_id = data.stream_id.parse().unwrap(); - let request_id = data.request_id.parse().unwrap(); - let mut watcher = Watcher::new(&state, stream_id, request_id).await; + let mut new_watcher = Watcher::new(msg.request_id.to_ulid(), msg.grpc_endpoint).await; let mut ffprobe = spawn_ffprobe(); let writer = ffprobe.stdin.as_mut().unwrap(); - match watcher.recv().await { - WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + match new_watcher.recv().await.message { + Some(ingest_watch_response::Message::Media(media)) => { + assert_eq!(media.r#type(), ingest_watch_response::media::Type::Init); + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); + } _ => panic!("unexpected event"), } - assert!( - state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - match state.api_recv().await { - IncomingRequest::Update((_, response)) => { - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } + match new_watcher.recv().await.message { + Some(ingest_watch_response::Message::Ready(_)) => {} _ => panic!("unexpected event"), } - // Finish the stream - let mut got_shutting_down = false; - while let Some(msg) = watcher.rx.recv().await { - match msg { - WatchStreamEvent::MediaSegment(ms) => { - writer.write_all(&ms.data).await.unwrap(); + while let Ok(Some(msg)) = new_watcher.recv.message().await { + match msg.message.unwrap() { + ingest_watch_response::Message::Media(media) => { + assert!(!media.data.is_empty()); + writer.write_all(&media.data).await.unwrap(); } - WatchStreamEvent::ShuttingDown(true) => { - got_shutting_down = true; + ingest_watch_response::Message::Ready(_) => { + panic!("unexpected ready"); + } + ingest_watch_response::Message::Shutdown(_) => { break; } - _ => panic!("unexpected event"), } } - assert!(got_shutting_down); - let output = ffprobe.wait_with_output().await.unwrap(); assert!(output.status.success()); @@ -2929,37 +1419,5 @@ async fn test_ingest_stream_transcoder_probe_reconnect_unexpected() { assert!(ffmpeg.try_wait().is_ok()); - // Assert that the stream is removed - assert!( - !state - .global - .connection_manager - .submit_request(stream_id, GrpcRequest::TranscoderStarted { id: request_id }) - .await - ); - - // Assert that the stream is removed - match state.api_recv().await { - IncomingRequest::Update((update, response)) => { - assert_eq!(update.stream_id, stream_id.to_string()); - assert_eq!(update.updates.len(), 1); - - let update = &update.updates[0]; - assert!(update.timestamp > 0); - - match &update.update { - Some(update_live_stream_request::update::Update::ReadyState(state)) => { - assert_eq!(*state, StreamReadyState::Stopped as i32); // graceful stop - } - u => { - panic!("unexpected update: {:?}", u); - } - } - - response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); - } - _ => panic!("unexpected event"), - } - state.finish().await; } diff --git a/video/ingest/src/tests/mod.rs b/video/ingest/src/tests/mod.rs index 36c36dae..8b5e1cdf 100644 --- a/video/ingest/src/tests/mod.rs +++ b/video/ingest/src/tests/mod.rs @@ -1,4 +1,3 @@ -mod config; mod global; mod grpc; mod ingest; diff --git a/video/lib/aac/Cargo.toml b/video/lib/aac/Cargo.toml index df931e44..28f3b21d 100644 --- a/video/lib/aac/Cargo.toml +++ b/video/lib/aac/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "aac" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -byteorder = "1" -num-traits = "0" -num-derive = "0" -bytesio = { path = "../../bytesio", default-features = false } +bytes = "1.4.0" +byteorder = "1.4.3" +num-traits = "0.2.16" +num-derive = "0.4.0" +bytesio = { workspace = true } diff --git a/video/lib/aac/LICENSE.md b/video/lib/aac/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/aac/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/amf0/Cargo.toml b/video/lib/amf0/Cargo.toml index 556abaf0..91bf2312 100644 --- a/video/lib/amf0/Cargo.toml +++ b/video/lib/amf0/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "amf0" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -byteorder = "1" -num-traits = "0" -num-derive = "0" -bytesio = { path = "../../bytesio", default-features = false } +bytes = "1.4.0" +byteorder = "1.4.3" +num-traits = "0.2.16" +num-derive = "0.4.0" +bytesio = { workspace = true } diff --git a/video/lib/amf0/LICENSE.md b/video/lib/amf0/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/amf0/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/amf0/src/tests.rs b/video/lib/amf0/src/tests.rs index 4f20f951..16d5965b 100644 --- a/video/lib/amf0/src/tests.rs +++ b/video/lib/amf0/src/tests.rs @@ -116,7 +116,11 @@ fn test_read_error_display() { assert_eq!(Amf0ReadError::WrongType.to_string(), "wrong type"); assert_eq!( - Amf0ReadError::StringParseError(std::str::from_utf8(b"\xFF\xFF").unwrap_err()).to_string(), + Amf0ReadError::StringParseError( + #[allow(unknown_lints, invalid_from_utf8)] + std::str::from_utf8(b"\xFF\xFF").unwrap_err() + ) + .to_string(), "string parse error: invalid utf-8 sequence of 1 bytes from index 0" ); diff --git a/video/lib/av1/Cargo.toml b/video/lib/av1/Cargo.toml index 2c938011..fcb59f41 100644 --- a/video/lib/av1/Cargo.toml +++ b/video/lib/av1/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "av1" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -byteorder = "1" -bytesio = { path = "../../bytesio", default-features = false } +bytes = "1.4.0" +byteorder = "1.4.3" +bytesio = { workspace = true } diff --git a/video/lib/av1/LICENSE.md b/video/lib/av1/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/av1/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/av1/src/config.rs b/video/lib/av1/src/config.rs index 2a5f72d8..caf21b28 100644 --- a/video/lib/av1/src/config.rs +++ b/video/lib/av1/src/config.rs @@ -71,7 +71,7 @@ impl AV1CodecConfigurationRecord { chroma_subsampling_y, chroma_sample_position, initial_presentation_delay_minus_one, - config_obu: reader.get_remaining(), + config_obu: reader.extract_remaining(), }) } diff --git a/video/lib/bytesio/Cargo.toml b/video/lib/bytesio/Cargo.toml index 7bfd30c9..3d49f376 100644 --- a/video/lib/bytesio/Cargo.toml +++ b/video/lib/bytesio/Cargo.toml @@ -8,14 +8,14 @@ tokio = ["dep:tokio-util", "dep:tokio-stream", "dep:tokio", "dep:futures", "dep: default = ["tokio"] [dependencies] -byteorder = "1" -bytes = "1" +byteorder = "1.4.3" +bytes = "1.4.0" -futures = { version = "0", optional = true } -tokio-util = { version = "0", features = ["codec"], optional = true } -tokio-stream = { version = "0", optional = true } -tokio = { version = "1", optional = true } -common = { path = "../../common", default-features = false, features = ["prelude"], optional = true } +futures = { version = "0.3.28", optional = true } +tokio-util = { version = "0.7.8", features = ["codec"], optional = true } +tokio-stream = { version = "0.1.14", optional = true } +tokio = { version = "1.29.1", optional = true } +common = { workspace = true, default-features = false, features = ["prelude"], optional = true } [dev-dependencies] -tokio = { version = "1", features = ["full"] } +tokio = { version = "1.29.1", features = ["full"] } diff --git a/video/lib/bytesio/LICENSE.md b/video/lib/bytesio/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/bytesio/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/bytesio/src/bytes_reader.rs b/video/lib/bytesio/src/bytes_reader.rs index 5723ebb7..cb534f7c 100644 --- a/video/lib/bytesio/src/bytes_reader.rs +++ b/video/lib/bytesio/src/bytes_reader.rs @@ -79,14 +79,16 @@ impl io::Read for BytesReader { } pub trait BytesCursor { - fn get_remaining(&self) -> Bytes; + fn extract_remaining(&mut self) -> Bytes; fn read_slice(&mut self, size: usize) -> io::Result; } impl BytesCursor for io::Cursor { - fn get_remaining(&self) -> Bytes { + fn extract_remaining(&mut self) -> Bytes { let position = self.position() as usize; - self.get_ref().slice(position..) + let remaining = self.get_ref().slice(position..); + self.set_position(self.get_ref().len() as u64); + remaining } fn read_slice(&mut self, size: usize) -> io::Result { diff --git a/video/lib/exp_golomb/Cargo.toml b/video/lib/exp_golomb/Cargo.toml index dbf6dede..045d0c59 100644 --- a/video/lib/exp_golomb/Cargo.toml +++ b/video/lib/exp_golomb/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "exp_golomb" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -bytesio = { path = "../../bytesio", default-features = false } +bytes = "1.4.0" +bytesio = { workspace = true } diff --git a/video/lib/exp_golomb/LICENSE.md b/video/lib/exp_golomb/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/exp_golomb/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/flv/Cargo.toml b/video/lib/flv/Cargo.toml index 4972dbea..bbc467b8 100644 --- a/video/lib/flv/Cargo.toml +++ b/video/lib/flv/Cargo.toml @@ -4,14 +4,14 @@ version = "0.0.1" edition = "2021" [dependencies] -byteorder = "1" -bytes = "1" -num-traits = "0" -num-derive = "0" +byteorder = "1.4.3" +bytes = "1.4.0" +num-traits = "0.2.16" +num-derive = "0.4.0" -bytesio = { path = "../../bytesio" } -av1 = { path = "../../codec/av1" } -h264 = { path = "../../codec/h264" } -h265 = { path = "../../codec/h265" } -aac = { path = "../../codec/aac" } -amf0 = { path = "../../utils/amf0" } +bytesio = { workspace = true } +av1 = { workspace = true } +h264 = { workspace = true } +h265 = { workspace = true } +aac = { workspace = true } +amf0 = { workspace = true } diff --git a/video/lib/flv/LICENSE.md b/video/lib/flv/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/flv/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/flv/src/flv.rs b/video/lib/flv/src/flv.rs index f4c3e6ea..f4dc6170 100644 --- a/video/lib/flv/src/flv.rs +++ b/video/lib/flv/src/flv.rs @@ -148,7 +148,7 @@ impl FlvTagData { }) } Some(FlvTagType::ScriptData) => { - let values = Amf0Reader::new(reader.get_remaining()).read_all()?; + let values = Amf0Reader::new(reader.extract_remaining()).read_all()?; let name = match values.get(0) { Some(Amf0Value::String(name)) => name, @@ -162,7 +162,7 @@ impl FlvTagData { } None => Ok(FlvTagData::Unknown { tag_type, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), } } @@ -180,7 +180,7 @@ impl FlvTagAudioData { } _ => Ok(Self::Unknown { sound_format, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), } } @@ -192,11 +192,11 @@ impl AacPacket { reader: &mut io::Cursor, ) -> Result { match AacPacketType::from_u8(aac_packet_type) { - Some(AacPacketType::SeqHdr) => Ok(Self::SequenceHeader(reader.get_remaining())), - Some(AacPacketType::Raw) => Ok(Self::Raw(reader.get_remaining())), + Some(AacPacketType::SeqHdr) => Ok(Self::SequenceHeader(reader.extract_remaining())), + Some(AacPacketType::Raw) => Ok(Self::Raw(reader.extract_remaining())), _ => Ok(Self::Unknown { aac_packet_type, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), } } @@ -211,7 +211,7 @@ impl FlvTagVideoData { } _ => Ok(Self::Unknown { codec_id, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), } } @@ -233,7 +233,7 @@ impl FlvTagVideoData { } EnhancedPacketType::Metadata => { return Ok(Self::Enhanced(EnhancedPacket::Metadata( - reader.get_remaining(), + reader.extract_remaining(), ))) } _ => {} @@ -246,7 +246,7 @@ impl FlvTagVideoData { ))) } (VideoFourCC::Av1, EnhancedPacketType::CodedFrames) => Ok(Self::Enhanced( - EnhancedPacket::Av1(Av1Packet::Raw(reader.get_remaining())), + EnhancedPacket::Av1(Av1Packet::Raw(reader.extract_remaining())), )), (VideoFourCC::Hevc, EnhancedPacketType::SequenceStart) => { Ok(Self::Enhanced(EnhancedPacket::Hevc( @@ -257,19 +257,19 @@ impl FlvTagVideoData { let composition_time = reader.read_i24::()?; Ok(Self::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { composition_time: Some(composition_time), - data: reader.get_remaining(), + data: reader.extract_remaining(), }))) } (VideoFourCC::Hevc, EnhancedPacketType::CodedFramesX) => { Ok(Self::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { composition_time: None, - data: reader.get_remaining(), + data: reader.extract_remaining(), }))) } _ => Ok(Self::Enhanced(EnhancedPacket::Unknown { packet_type: packet_type as u8, video_codec: video_codec.into(), - data: reader.get_remaining(), + data: reader.extract_remaining(), })), } } @@ -289,13 +289,13 @@ impl AvcPacket { } Some(AvcPacketType::Nalu) => Ok(Self::Nalu { composition_time: reader.read_u24::()?, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), Some(AvcPacketType::EndOfSequence) => Ok(Self::EndOfSequence), _ => Ok(Self::Unknown { avc_packet_type, composition_time: reader.read_u24::()?, - data: reader.get_remaining(), + data: reader.extract_remaining(), }), } } diff --git a/video/lib/h264/Cargo.toml b/video/lib/h264/Cargo.toml index 17312ef6..4e933c81 100644 --- a/video/lib/h264/Cargo.toml +++ b/video/lib/h264/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "h264" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -byteorder = "1" -bytesio = { path = "../../bytesio", default-features = false } -exp_golomb = { path = "../../utils/exp_golomb" } +bytes = "1.4.0" +byteorder = "1.4.3" +bytesio = { workspace = true } +exp_golomb = { workspace = true } diff --git a/video/lib/h264/LICENSE.md b/video/lib/h264/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/h264/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/h265/Cargo.toml b/video/lib/h265/Cargo.toml index ec5a7164..17b30739 100644 --- a/video/lib/h265/Cargo.toml +++ b/video/lib/h265/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "h265" -version = "0.1.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = "1" -byteorder = "1" -bytesio = { path = "../../bytesio", default-features = false } -exp_golomb = { path = "../../utils/exp_golomb" } +bytes = "1.4.0" +byteorder = "1.4.3" +bytesio = { workspace = true } +exp_golomb = { workspace = true } diff --git a/video/lib/h265/LICENSE.md b/video/lib/h265/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/h265/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/mp4/Cargo.toml b/video/lib/mp4/Cargo.toml index 73cf7ed5..01b31587 100644 --- a/video/lib/mp4/Cargo.toml +++ b/video/lib/mp4/Cargo.toml @@ -4,18 +4,18 @@ version = "0.0.1" edition = "2021" [dependencies] -byteorder = "1" -bytes = "1" -fixed = "1" -casey = "0" -paste = "1" +byteorder = "1.4.3" +bytes = "1.4.0" +fixed = "1.23.1" +casey = "0.4.0" +paste = "1.0.14" -bytesio = { path = "../../bytesio/", default-features = false, features = []} -h264 = { path = "../../codec/h264" } -h265 = { path = "../../codec/h265" } -av1 = { path = "../../codec/av1" } -aac = { path = "../../codec/aac" } +bytesio = { workspace = true, default-features = false, features = []} +h264 = { workspace = true } +h265 = { workspace = true } +av1 = { workspace = true } +aac = { workspace = true } [dev-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde = { version = "1.0.183", features = ["derive"] } +serde_json = "1.0.104" diff --git a/video/lib/mp4/LICENSE.md b/video/lib/mp4/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/mp4/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/mp4/src/boxes/header.rs b/video/lib/mp4/src/boxes/header.rs index ae0c51ff..9e5be959 100644 --- a/video/lib/mp4/src/boxes/header.rs +++ b/video/lib/mp4/src/boxes/header.rs @@ -41,8 +41,13 @@ impl BoxHeader { size }; - // We already read 8 bytes, so we need to subtract that from the size. - let data = reader.read_slice((size - offset) as usize)?; + let data = if size == 0 { + // As per spec this means the box extends to the end of the file. + reader.extract_remaining() + } else { + // We already read 8 bytes, so we need to subtract that from the size. + reader.read_slice((size - offset) as usize)? + }; Ok((Self { box_type }, data)) } diff --git a/video/lib/rtmp/Cargo.toml b/video/lib/rtmp/Cargo.toml index 54f3ebee..d9dabd92 100644 --- a/video/lib/rtmp/Cargo.toml +++ b/video/lib/rtmp/Cargo.toml @@ -4,26 +4,26 @@ version = "0.0.1" edition = "2021" [dependencies] -byteorder = "1" -bytes = "1" -rand = "0" -hmac = "0" -sha2 = "0" -uuid = { version = "1", features = ["v4"] } -chrono = { version = "0", default-features = false, features = ["clock"] } -num-traits = "0" -num-derive = "0" -tokio = "1" -futures = "0" -async-trait = "0" -tracing = "0" +byteorder = "1.4.3" +bytes = "1.4.0" +rand = "0.8.5" +hmac = "0.12.1" +sha2 = "0.10.7" +uuid = { version = "1.4.1", features = ["v4"] } +chrono = { version = "0.4.26", default-features = false, features = ["clock"] } +num-traits = "0.2.16" +num-derive = "0.4.0" +tokio = "1.29.1" +futures = "0.3.28" +async-trait = "0.1.72" +tracing = "0.1.37" -bytesio = {path = "../../bytesio" } -flv = { path = "../../container/flv" } -h264 = { path = "../../codec/h264" } -amf0 = { path = "../../utils/amf0" } +bytesio = { workspace = true, features = ["default"] } +flv = { workspace = true } +h264 = { workspace = true } +amf0 = { workspace = true } [dev-dependencies] -tokio = { version = "1", features = ["full"] } -serde_json = "1" -common = { path = "../../../common" } \ No newline at end of file +tokio = { version = "1.29.1", features = ["full"] } +serde_json = "1.0.104" +common = { workspace = true } diff --git a/video/lib/rtmp/LICENSE.md b/video/lib/rtmp/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/rtmp/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/rtmp/src/channels/mod.rs b/video/lib/rtmp/src/channels/mod.rs index db7a4498..7f5e4d3d 100644 --- a/video/lib/rtmp/src/channels/mod.rs +++ b/video/lib/rtmp/src/channels/mod.rs @@ -7,7 +7,25 @@ pub type UniqueID = uuid::Uuid; pub enum ChannelData { Video { timestamp: u32, data: Bytes }, Audio { timestamp: u32, data: Bytes }, - MetaData { timestamp: u32, data: Bytes }, + Metadata { timestamp: u32, data: Bytes }, +} + +impl ChannelData { + pub fn timestamp(&self) -> u32 { + match self { + ChannelData::Video { timestamp, .. } => *timestamp, + ChannelData::Audio { timestamp, .. } => *timestamp, + ChannelData::Metadata { timestamp, .. } => *timestamp, + } + } + + pub fn data(&self) -> &Bytes { + match self { + ChannelData::Video { data, .. } => data, + ChannelData::Audio { data, .. } => data, + ChannelData::Metadata { data, .. } => data, + } + } } #[derive(Debug)] diff --git a/video/lib/rtmp/src/session/server_session.rs b/video/lib/rtmp/src/session/server_session.rs index 38d996a1..5b5d2665 100644 --- a/video/lib/rtmp/src/session/server_session.rs +++ b/video/lib/rtmp/src/session/server_session.rs @@ -229,7 +229,7 @@ impl Session { .await?; } RtmpMessageData::AmfData { data } => { - self.on_data(stream_id, ChannelData::MetaData { timestamp, data }) + self.on_data(stream_id, ChannelData::Metadata { timestamp, data }) .await?; } } diff --git a/video/lib/rtmp/src/tests/rtmp.rs b/video/lib/rtmp/src/tests/rtmp.rs index 14c7bc72..5efbaffc 100644 --- a/video/lib/rtmp/src/tests/rtmp.rs +++ b/video/lib/rtmp/src/tests/rtmp.rs @@ -91,7 +91,7 @@ async fn test_basic_rtmp_clean() { match data { ChannelData::Video { .. } => got_video = true, ChannelData::Audio { .. } => got_audio = true, - ChannelData::MetaData { .. } => got_metadata = true, + ChannelData::Metadata { .. } => got_metadata = true, } } @@ -192,7 +192,7 @@ async fn test_basic_rtmp_unclean() { match data { ChannelData::Video { .. } => got_video = true, ChannelData::Audio { .. } => got_audio = true, - ChannelData::MetaData { .. } => got_metadata = true, + ChannelData::Metadata { .. } => got_metadata = true, } if got_video && got_audio && got_metadata { diff --git a/video/lib/transmuxer/Cargo.toml b/video/lib/transmuxer/Cargo.toml index 47e74016..dbd844a7 100644 --- a/video/lib/transmuxer/Cargo.toml +++ b/video/lib/transmuxer/Cargo.toml @@ -4,18 +4,18 @@ version = "0.0.1" edition = "2021" [dependencies] -byteorder = "1" -bytes = "1" +byteorder = "1.4.3" +bytes = "1.4.0" -bytesio = { path = "../bytesio" } -h264 = { path = "../codec/h264" } -h265 = { path = "../codec/h265" } -av1 = { path = "../codec/av1" } -aac = { path = "../codec/aac" } -amf0 = { path = "../utils/amf0" } -flv = { path = "../container/flv" } -mp4 = { path = "../container/mp4" } +bytesio = { workspace = true } +h264 = { workspace = true } +h265 = { workspace = true } +av1 = { workspace = true } +aac = { workspace = true } +amf0 = { workspace = true } +flv = { workspace = true } +mp4 = { workspace = true } [dev-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde = { version = "1.0.183", features = ["derive"] } +serde_json = "1.0.104" diff --git a/video/lib/transmuxer/LICENSE.md b/video/lib/transmuxer/LICENSE.md new file mode 120000 index 00000000..368ec7e5 --- /dev/null +++ b/video/lib/transmuxer/LICENSE.md @@ -0,0 +1 @@ +../../../LICENSE.md \ No newline at end of file diff --git a/video/lib/transmuxer/src/lib.rs b/video/lib/transmuxer/src/lib.rs index ca613a09..4a1463d3 100644 --- a/video/lib/transmuxer/src/lib.rs +++ b/video/lib/transmuxer/src/lib.rs @@ -243,27 +243,20 @@ impl Transmuxer { } let trafs = { - let (main_duration, second_duration, main_id, second_id) = if is_audio { - (self.audio_duration, self.video_duration, 2, 1) + let (main_duration, main_id) = if is_audio { + (self.audio_duration, 2) } else { - (self.video_duration, self.audio_duration, 1, 2) + (self.video_duration, 1) }; - let mut first_traf = Traf::new( + let mut traf = Traf::new( Tfhd::new(main_id, None, None, None, None, None), Some(Trun::new(vec![trun_sample], None)), Some(Tfdt::new(main_duration)), ); - first_traf.optimize(); + traf.optimize(); - let mut second_traf = Traf::new( - Tfhd::new(second_id, None, None, None, None, None), - Some(Trun::new(vec![], None)), - Some(Tfdt::new(second_duration)), - ); - second_traf.optimize(); - - vec![first_traf, second_traf] + vec![traf] }; let mut moof = Moof::new(Mfhd::new(self.sequence_number), trafs); diff --git a/video/lib/transmuxer/src/tests/mod.rs b/video/lib/transmuxer/src/tests/mod.rs index 55f16dab..be04d8ea 100644 --- a/video/lib/transmuxer/src/tests/mod.rs +++ b/video/lib/transmuxer/src/tests/mod.rs @@ -16,7 +16,7 @@ use crate::{ #[test] fn test_transmuxer_avc_aac() { - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); let data = std::fs::read(dir.join("avc_aac.flv").to_str().unwrap()).unwrap(); let mut transmuxer = Transmuxer::new(); @@ -127,7 +127,7 @@ fn test_transmuxer_avc_aac() { #[test] fn test_transmuxer_av1_aac() { - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); let data = std::fs::read(dir.join("av1_aac.flv").to_str().unwrap()).unwrap(); let mut transmuxer = Transmuxer::new(); @@ -249,7 +249,7 @@ fn test_transmuxer_av1_aac() { #[test] fn test_transmuxer_hevc_aac() { - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); let data = std::fs::read(dir.join("hevc_aac.flv").to_str().unwrap()).unwrap(); let mut transmuxer = Transmuxer::new(); diff --git a/video/migrations/0001_initial.down.sql b/video/migrations/0001_initial.down.sql new file mode 100644 index 00000000..641e3673 --- /dev/null +++ b/video/migrations/0001_initial.down.sql @@ -0,0 +1,18 @@ +-- Add down migration script here + +DROP TABLE recording_renditions CASCADE; +DROP TABLE recordings CASCADE; +DROP TABLE organizations CASCADE; +DROP TABLE access_tokens CASCADE; +DROP TABLE s3_buckets CASCADE; +DROP TABLE transcoding_configs CASCADE; +DROP TABLE recording_configs CASCADE; +DROP TABLE rooms CASCADE; +DROP TABLE playback_key_pairs CASCADE; +DROP TABLE playback_sessions CASCADE; +DROP TYPE rendition_video; +DROP TYPE rendition_audio; +DROP TYPE playback_session_device; +DROP TYPE playback_session_platform; +DROP TYPE playback_session_browser; +DROP TYPE room_status; diff --git a/video/migrations/0001_initial.up.sql b/video/migrations/0001_initial.up.sql new file mode 100644 index 00000000..9a9d8b3f --- /dev/null +++ b/video/migrations/0001_initial.up.sql @@ -0,0 +1,222 @@ +-- Table Definitions + +CREATE TYPE rendition AS ENUM ('VIDEO_SOURCE', 'VIDEO_HD', 'VIDEO_SD', 'VIDEO_LD', 'AUDIO_SOURCE'); +CREATE TYPE playback_session_device AS ENUM ('UNKNOWN'); +CREATE TYPE playback_session_platform AS ENUM ('UNKNOWN'); +CREATE TYPE playback_session_browser AS ENUM ('UNKNOWN'); +CREATE TYPE room_status AS ENUM ('OFFLINE', 'WAITING_FOR_TRANSCODER', 'READY'); + +CREATE TABLE organizations ( + id UUID NOT NULL PRIMARY KEY, + name VARCHAR(32) NOT NULL, + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE access_tokens ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + version INT NOT NULL DEFAULT 0, + last_access_at TIMESTAMPTZ(3), + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ(3), + scopes bytes[] NOT NULL, + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE s3_buckets ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + name VARCHAR(64) NOT NULL, + region VARCHAR(64), + endpoint VARCHAR(256), + access_key VARCHAR(256) NOT NULL, + secret_key VARCHAR(256) NOT NULL, + public_url VARCHAR(256) NOT NULL, + + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE transcoding_configs ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + renditions rendition[] NOT NULL DEFAULT ARRAY['VIDEO_SOURCE', 'AUDIO_SOURCE'], + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE recording_configs ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + renditions rendition[] NOT NULL DEFAULT ARRAY['VIDEO_SOURCE', 'VIDEO_HD', 'VIDEO_SD', 'VIDEO_LD', 'AUDIO_SOURCE'], + lifecycle_policies bytes[] NOT NULL DEFAULT ARRAY[], + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + s3_bucket_id UUID NOT NULL, + + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE rooms ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + transcoding_config_id UUID, + recording_config_id UUID, + + private BOOLEAN NOT NULL DEFAULT FALSE, + + stream_key CHAR(32) NOT NULL, + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + + last_live_at TIMESTAMPTZ(3), + last_disconnected_at TIMESTAMPTZ(3), + + -- Room Session Stuff + + status room_status NOT NULL DEFAULT 'OFFLINE', + + video_input bytes, + audio_input bytes, + + active_ingest_connection_id UUID, + active_recording_config bytes, + active_transcoding_config bytes, + active_recording_id UUID, + + ingest_bitrate INT, + + video_output bytes[], + audio_output bytes[], + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE recordings ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + + room_id UUID, + recording_config_id UUID, + + public BOOLEAN NOT NULL DEFAULT FALSE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + allow_dvr BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW() +); + +CREATE TABLE recording_renditions ( + recording_id UUID NOT NULL, + rendition rendition, + + organization_id UUID NOT NULL, + segment_ids UUID[] NOT NULL, + segment_durations INT4[] NOT NULL, + timescale INT4 NOT NULL, + size_bytes BIGINT NOT NULL, + s3_bucket_id UUID NOT NULL, + + PRIMARY KEY (recording_id, rendition) +); + +CREATE TABLE playback_key_pairs ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + + public_key bytes NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + tags JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE playback_sessions ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + + room_id UUID, + recording_id UUID, + + user_id VARCHAR(128), + playback_key_pair_id UUID, + issued_at TIMESTAMPTZ(3), + expires_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW() + INTERVAL '10 minutes', + ip_address INET NOT NULL, + user_agent VARCHAR(256), + referer VARCHAR(256), + origin VARCHAR(256), + device playback_session_device NOT NULL DEFAULT 'UNKNOWN', + platform playback_session_platform NOT NULL DEFAULT 'UNKNOWN', + browser playback_session_browser NOT NULL DEFAULT 'UNKNOWN', + player_version VARCHAR(32) +) WITH (ttl_expiration_expression = 'expires_at'); + +-- Relations + +ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; + +ALTER TABLE s3_buckets ADD CONSTRAINT s3_buckets_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; + +ALTER TABLE transcoding_configs ADD CONSTRAINT transcoding_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; + +ALTER TABLE recording_configs ADD CONSTRAINT recording_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; +ALTER TABLE recording_configs ADD CONSTRAINT recording_configs_s3_bucket_id_fkey FOREIGN KEY (s3_bucket_id) REFERENCES s3_buckets (id) ON DELETE CASCADE; + +ALTER TABLE rooms ADD CONSTRAINT rooms_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; +ALTER TABLE rooms ADD CONSTRAINT rooms_transcoding_config_id_fkey FOREIGN KEY (transcoding_config_id) REFERENCES transcoding_configs (id) ON DELETE SET NULL; +ALTER TABLE rooms ADD CONSTRAINT rooms_recording_config_id_fkey FOREIGN KEY (recording_config_id) REFERENCES recording_configs (id) ON DELETE SET NULL; + +ALTER TABLE recordings ADD CONSTRAINT recordings_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; +ALTER TABLE recordings ADD CONSTRAINT recordings_room_id_fkey FOREIGN KEY (room_id) REFERENCES rooms (id) ON DELETE SET NULL; +ALTER TABLE recordings ADD CONSTRAINT recordings_recording_config_id_fkey FOREIGN KEY (recording_config_id) REFERENCES recording_configs (id) ON DELETE SET NULL; + +ALTER TABLE recording_renditions ADD CONSTRAINT recording_renditions_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id); +ALTER TABLE recording_renditions ADD CONSTRAINT recording_renditions_recording_id_fkey FOREIGN KEY (recording_id) REFERENCES recordings (id); +ALTER TABLE recording_renditions ADD CONSTRAINT recording_renditions_s3_bucket_id_fkey FOREIGN KEY (s3_bucket_id) REFERENCES s3_buckets (id); + +ALTER TABLE playback_key_pairs ADD CONSTRAINT playback_key_pairs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; + +ALTER TABLE playback_sessions ADD CONSTRAINT playback_sessions_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE; +ALTER TABLE playback_sessions ADD CONSTRAINT playback_sessions_room_id_fkey FOREIGN KEY (room_id) REFERENCES rooms (id) ON DELETE CASCADE; +ALTER TABLE playback_sessions ADD CONSTRAINT playback_sessions_recording_id_fkey FOREIGN KEY (recording_id) REFERENCES recordings (id) ON DELETE CASCADE; +ALTER TABLE playback_sessions ADD CONSTRAINT playback_sessions_playback_key_pair_id_fkey FOREIGN KEY (playback_key_pair_id) REFERENCES playback_key_pairs (id) ON DELETE CASCADE; + +-- Indexes + +CREATE INDEX access_tokens_organization_id_idx ON access_tokens (organization_id); +CREATE INVERTED INDEX access_tokens_tags ON access_tokens (tags); + +CREATE INDEX s3_buckets_organization_id_idx ON s3_buckets (organization_id); +CREATE INVERTED INDEX s3_buckets_tags ON s3_buckets (tags); + +CREATE INDEX transcoding_configs_organization_id_idx ON transcoding_configs (organization_id); +CREATE INVERTED INDEX transcoding_configs_tags ON transcoding_configs (tags); + +CREATE INDEX recording_configs_organization_id_idx ON recording_configs (organization_id); +CREATE INDEX recording_configs_s3_bucket_id_idx ON recording_configs (s3_bucket_id); +CREATE INVERTED INDEX recording_configs_tags ON recording_configs (tags); + +CREATE INDEX rooms_organization_id_idx ON rooms (organization_id); +CREATE INDEX rooms_transcoding_config_id_idx ON rooms (transcoding_config_id); +CREATE INDEX rooms_recording_config_id_idx ON rooms (recording_config_id); +CREATE INVERTED INDEX rooms_tags ON rooms (tags); + +CREATE INDEX recordings_organization_id_idx ON recordings (organization_id); +CREATE INDEX recordings_room_id_idx ON recordings (room_id); +CREATE INDEX recordings_recording_config_id_idx ON recordings (recording_config_id); + +CREATE INDEX recording_renditions_organization_id_idx ON recording_renditions (organization_id); +CREATE INDEX recording_renditions_s3_bucket_id_idx ON recording_renditions (s3_bucket_id); + +CREATE INDEX playback_key_pairs_organization_id_idx ON playback_key_pairs (organization_id); +CREATE INVERTED INDEX playback_key_pairs_tags ON playback_key_pairs (tags); + +CREATE INDEX playback_sessions_organization_id_idx ON playback_sessions (organization_id); +CREATE INDEX playback_sessions_room_id_idx ON playback_sessions (room_id); +CREATE INDEX playback_sessions_recording_id_idx ON playback_sessions (recording_id); +CREATE INDEX playback_sessions_playback_key_pair_id_idx ON playback_sessions (playback_key_pair_id); +CREATE INDEX playback_sessions_user_id ON playback_sessions (user_id); +CREATE INDEX playback_sessions_ip_address ON playback_sessions (ip_address); +CREATE INDEX playback_sessions_expires_at ON playback_sessions (expires_at); diff --git a/video/migrations/INSERT INTO organization (id, name) VALU.sql b/video/migrations/INSERT INTO organization (id, name) VALU.sql new file mode 100644 index 00000000..c750da3e --- /dev/null +++ b/video/migrations/INSERT INTO organization (id, name) VALU.sql @@ -0,0 +1,3 @@ +INSERT INTO organizations (id, name) VALUES ('018A24BB-B729-9775-B1F6-3FBC3A3254CA', 'test') ON CONFLICT DO NOTHING; + +INSERT INTO rooms (organization_id, id, stream_key) VALUES ('018A24BB-B729-9775-B1F6-3FBC3A3254CA', '018A24BB-B729-9775-B1F6-3FBC3A3254CA', '018A24BBB7299775B1F63FBC3A3254CA') ON CONFLICT DO NOTHING; diff --git a/video/player/.gitignore b/video/player/.gitignore index 49757377..55ec599a 100644 --- a/video/player/.gitignore +++ b/video/player/.gitignore @@ -20,3 +20,4 @@ dist-ssr *.sw? wasm.d.ts demo-dist +pkg/ diff --git a/video/player/.prettierignore b/video/player/.prettierignore index 3277ba6e..5b6f4b46 100644 --- a/video/player/.prettierignore +++ b/video/player/.prettierignore @@ -15,4 +15,4 @@ wasm.d.ts target/ dist/ demo-dist/ -pkg/ \ No newline at end of file +pkg/ diff --git a/video/player/Cargo.lock b/video/player/Cargo.lock deleted file mode 100644 index 4118c71f..00000000 --- a/video/player/Cargo.lock +++ /dev/null @@ -1,947 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "aac" -version = "0.1.0" -dependencies = [ - "byteorder", - "bytes", - "bytesio", - "num-derive", - "num-traits", -] - -[[package]] -name = "addr2line" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "anyhow" -version = "1.0.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "av1" -version = "0.1.0" -dependencies = [ - "byteorder", - "bytes", - "bytesio", -] - -[[package]] -name = "az" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" - -[[package]] -name = "backtrace" -version = "0.3.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "base64" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" - -[[package]] -name = "bumpalo" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" - -[[package]] -name = "bytemuck" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" - -[[package]] -name = "bytesio" -version = "0.0.1" -dependencies = [ - "byteorder", - "bytes", -] - -[[package]] -name = "casey" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614586263949597dcc18675da12ef9b429135e13628d92eb8b8c6fa50ca5656b" -dependencies = [ - "syn 1.0.109", -] - -[[package]] -name = "cc" -version = "1.0.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "crunchy" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" - -[[package]] -name = "exp_golomb" -version = "0.1.0" -dependencies = [ - "bytes", - "bytesio", -] - -[[package]] -name = "fixed" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79386fdcec5e0fde91b1a6a5bcd89677d1f9304f7f986b154a1b9109038854d9" -dependencies = [ - "az", - "bytemuck", - "half", - "typenum", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" - -[[package]] -name = "futures-executor" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" - -[[package]] -name = "futures-macro" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "futures-sink" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" - -[[package]] -name = "futures-task" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" - -[[package]] -name = "futures-util" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "gimli" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" - -[[package]] -name = "gloo-timers" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "gloo-utils" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "h264" -version = "0.1.0" -dependencies = [ - "byteorder", - "bytes", - "bytesio", - "exp_golomb", -] - -[[package]] -name = "h265" -version = "0.1.0" -dependencies = [ - "byteorder", - "bytes", - "bytesio", - "exp_golomb", -] - -[[package]] -name = "half" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc52e53916c08643f1b56ec082790d1e86a32e58dc5268f897f313fbae7b4872" -dependencies = [ - "cfg-if", - "crunchy", -] - -[[package]] -name = "idna" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "itoa" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" - -[[package]] -name = "js-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.147" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" - -[[package]] -name = "log" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "miniz_oxide" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" -dependencies = [ - "adler", -] - -[[package]] -name = "mp4" -version = "0.0.1" -dependencies = [ - "aac", - "av1", - "byteorder", - "bytes", - "bytesio", - "casey", - "fixed", - "h264", - "h265", - "paste", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-derive" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e6a0fd4f737c707bd9086cc16c925f294943eb62eb71499e9fd4cf71f8b9f4e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "num-traits" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "paste" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" - -[[package]] -name = "percent-encoding" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" - -[[package]] -name = "pin-project-lite" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "player" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "bytes", - "bytesio", - "console_error_panic_hook", - "futures", - "gloo-timers", - "h264", - "js-sys", - "mp4", - "serde", - "serde-wasm-bindgen", - "serde_json", - "tokio", - "tokio-stream", - "tracing", - "tracing-subscriber", - "tsify", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "web-sys", -] - -[[package]] -name = "proc-macro2" -version = "1.0.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - -[[package]] -name = "ryu" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "serde" -version = "1.0.171" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "serde_derive" -version = "1.0.171" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "serde_derive_internals" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "serde_json" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sharded-slab" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "slab" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thread_local" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" -dependencies = [ - "cfg-if", - "once_cell", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" -dependencies = [ - "autocfg", - "backtrace", - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "tokio-stream" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tracing" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" -dependencies = [ - "cfg-if", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", -] - -[[package]] -name = "tracing-core" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" -dependencies = [ - "lazy_static", - "log", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "tsify" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" -dependencies = [ - "gloo-utils", - "serde", - "serde_json", - "tsify-macros", - "wasm-bindgen", -] - -[[package]] -name = "tsify-macros" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.26", -] - -[[package]] -name = "typenum" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" - -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" - -[[package]] -name = "unicode-ident" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "url" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "wasm-bindgen" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.26", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.26", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" - -[[package]] -name = "wasm-bindgen-test" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6e302a7ea94f83a6d09e78e7dc7d9ca7b186bc2829c24a22d0753efd680671" -dependencies = [ - "console_error_panic_hook", - "js-sys", - "scoped-tls", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", -] - -[[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecb993dd8c836930ed130e020e77d9b2e65dd0fbab1b67c790b0f5d80b11a575" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "web-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/video/player/Cargo.toml b/video/player/Cargo.toml index 261523ff..7c74e2a1 100644 --- a/video/player/Cargo.toml +++ b/video/player/Cargo.toml @@ -1,50 +1,44 @@ # You must change these to your own details. [package] -name = "player" +name = "video-player" description = "Scuffle Video Player" -version = "0.1.0" +version = "0.0.1" authors = ["Troy Benson "] categories = ["wasm"] readme = "README.md" license = "LICENSE.md" edition = "2021" -[profile.release] -lto = true -opt-level = "z" -codegen-units = 1 -strip = true -panic = "abort" - [lib] crate-type = ["cdylib"] [dependencies] -wasm-bindgen = "0" -console_error_panic_hook = "0" -tracing = "0" -tracing-subscriber = "0" -bytes = "1" -anyhow = "1" -wasm-bindgen-futures = "0" -gloo-timers = { version = "0", features = ["futures"] } -js-sys = "0" -url = { version = "2", features = ["serde"] } -futures = "0" -tokio = { version = "1", features = ["sync", "macros"] } -tokio-stream = "0" -serde = { version = "1", features = ["derive"] } -serde-wasm-bindgen = "0" -tsify = "0" -serde_json = "1" -h264 = { path = "../../video/codec/h264" } -base64 = "0" +wasm-bindgen = "0.2.87" +console_error_panic_hook = "0.1.7" +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +bytes = "1.4.0" +anyhow = "1.0.72" +wasm-bindgen-futures = "0.4.37" +gloo-timers = { version = "0.2.6", features = ["futures"] } +js-sys = "0.3.64" +url = { version = "2.4.0", features = ["serde"] } +futures = "0.3.28" +tokio = { version = "1.29.1", features = ["sync", "macros"] } +tokio-stream = "0.1.14" +serde = { version = "1.0.183", features = ["derive"] } +serde-wasm-bindgen = "0.5.0" +tsify = "0.4.5" +serde_json = "1.0.104" +base64 = "0.21.2" +tracing-core = "0.1.31" -bytesio = { path = "../../video/bytesio", default-features = false } -mp4 = { path = "../../video/container/mp4" } +bytesio = { workspace = true } +h264 = { workspace = true } +mp4 = { workspace = true } [dependencies.web-sys] -version = "0" +version = "0.3.64" features = [ "console", "Headers", @@ -74,7 +68,7 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0" -futures = "0" -js-sys = "0" -wasm-bindgen-futures = "0" +wasm-bindgen-test = "0.3.37" +futures = "0.3.28" +js-sys = "0.3.64" +wasm-bindgen-futures = "0.4.37" diff --git a/video/player/demo/index.ts b/video/player/demo/index.ts index 1827724e..b93f3e35 100644 --- a/video/player/demo/index.ts +++ b/video/player/demo/index.ts @@ -1,4 +1,4 @@ -import init, { Player } from "../pkg/player"; +import init, { Player } from "../pkg/video_player"; await init(); diff --git a/video/player/js/main.ts b/video/player/js/main.ts new file mode 100644 index 00000000..d266f089 --- /dev/null +++ b/video/player/js/main.ts @@ -0,0 +1,4 @@ +import initFn from "../pkg/video_player"; + +export * from "../pkg/video_player"; +export const init = initFn; diff --git a/video/player/package.json b/video/player/package.json index a11057c8..6097ef7c 100644 --- a/video/player/package.json +++ b/video/player/package.json @@ -4,32 +4,35 @@ "version": "0.0.0", "type": "module", "scripts": { - "wasm:build": "wasm-pack build . --target web --weak-refs --reference-types", - "wasm:watch": "cargo watch --watch src --watch Cargo.toml -s \"pnpm run wasm:build --dev\"", + "wasm:build": "cargo build --target wasm32-unknown-unknown --profile wasm && wasm-bindgen --out-dir pkg --target web --weak-refs --reference-types --split-linked-modules ../../target/wasm32-unknown-unknown/wasm/video_player.wasm && wasm-opt -Oz -o ./pkg/video_player_bg.wasm ./pkg/video_player_bg.wasm --enable-reference-types", + "wasm:build:dev": "cargo build --target wasm32-unknown-unknown && wasm-bindgen --out-dir pkg --target web --weak-refs --reference-types --split-linked-modules ../../target/wasm32-unknown-unknown/debug/video_player.wasm", + "wasm:watch": "cargo watch --watch src --watch Cargo.toml -s \"pnpm run wasm:build:dev\"", "watch": "pnpm run wasm:watch & vite build --watch", - "update": "pnpm update && cargo update", + "update": "pnpm update", + "build": "pnpm run clean && pnpm run wasm:build && tsc && vite build && vite build -c vite.demo.config.ts", + "build:dev": "pnpm run clean && pnpm run wasm:build:dev && tsc && vite build && vite build -c vite.demo.config.ts", "lint": "prettier --check \"**/*\" -u && eslint . --ext .js,.ts && cargo fmt --check && cargo clippy -- -D warnings", "format": "prettier --write \"**/*\" -u && cargo fmt && cargo clippy --fix --allow-dirty --allow-staged", - "dev": "pnpm run wasm:watch & vite", - "build": "pnpm run wasm:build --release && tsc && vite build", - "preview": "vite preview", - "clean": "rimraf pkg" + "demo:dev": "pnpm run wasm:watch & vite", + "demo:build": "pnpm run wasm:build --release && tsc && vite build -c vite.demo.config.ts", + "demo:preview": "vite preview -c vite.demo.config.ts", + "clean": "rimraf dist pkg" }, - "module": "./pkg/player.js", - "types": "./pkg/player.d.ts", + "module": "./pkg/video_player.js", + "types": "./pkg/video_player.d.ts", "files": [ "pkg" ], "devDependencies": { - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", "astring": "^1.8.6", - "eslint": "^8.45.0", - "eslint-config-prettier": "^8.8.0", - "prettier": "^3.0.0", + "eslint": "^8.46.0", + "eslint-config-prettier": "^8.10.0", + "prettier": "^3.0.1", "rimraf": "^5.0.1", "typescript": "^5.1.6", - "vite": "^4.4.4", - "vite-plugin-dts": "^3.3.0" + "vite": "^4.4.9", + "vite-plugin-dts": "^3.5.1" } } diff --git a/video/player/pnpm-lock.yaml b/video/player/pnpm-lock.yaml index 10a634c5..21e868df 100644 --- a/video/player/pnpm-lock.yaml +++ b/video/player/pnpm-lock.yaml @@ -6,23 +6,23 @@ settings: devDependencies: '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6) + specifier: ^6.3.0 + version: 6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.46.0)(typescript@5.1.6) '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.0.0(eslint@8.45.0)(typescript@5.1.6) + specifier: ^6.3.0 + version: 6.3.0(eslint@8.46.0)(typescript@5.1.6) astring: specifier: ^1.8.6 version: 1.8.6 eslint: - specifier: ^8.45.0 - version: 8.45.0 + specifier: ^8.46.0 + version: 8.46.0 eslint-config-prettier: - specifier: ^8.8.0 - version: 8.8.0(eslint@8.45.0) + specifier: ^8.10.0 + version: 8.10.0(eslint@8.46.0) prettier: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.0.1 + version: 3.0.1 rimraf: specifier: ^5.0.1 version: 5.0.1 @@ -30,11 +30,11 @@ devDependencies: specifier: ^5.1.6 version: 5.1.6 vite: - specifier: ^4.4.4 - version: 4.4.4 + specifier: ^4.4.9 + version: 4.4.9 vite-plugin-dts: - specifier: ^3.3.0 - version: 3.3.0(typescript@5.1.6)(vite@4.4.4) + specifier: ^3.5.1 + version: 3.5.1(typescript@5.1.6)(vite@4.4.9) packages: @@ -53,16 +53,16 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/parser@7.22.7: - resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} + /@babel/parser@7.22.10: + resolution: {integrity: sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.5 + '@babel/types': 7.22.10 dev: true - /@babel/types@7.22.5: - resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} + /@babel/types@7.22.10: + resolution: {integrity: sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.22.5 @@ -70,8 +70,8 @@ packages: to-fast-properties: 2.0.0 dev: true - /@esbuild/android-arm64@0.18.13: - resolution: {integrity: sha512-j7NhycJUoUAG5kAzGf4fPWfd17N6SM3o1X6MlXVqfHvs2buFraCJzos9vbeWjLxOyBKHyPOnuCuipbhvbYtTAg==} + /@esbuild/android-arm64@0.18.20: + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} cpu: [arm64] os: [android] @@ -79,8 +79,8 @@ packages: dev: true optional: true - /@esbuild/android-arm@0.18.13: - resolution: {integrity: sha512-KwqFhxRFMKZINHzCqf8eKxE0XqWlAVPRxwy6rc7CbVFxzUWB2sA/s3hbMZeemPdhN3fKBkqOaFhTbS8xJXYIWQ==} + /@esbuild/android-arm@0.18.20: + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} cpu: [arm] os: [android] @@ -88,8 +88,8 @@ packages: dev: true optional: true - /@esbuild/android-x64@0.18.13: - resolution: {integrity: sha512-M2eZkRxR6WnWfVELHmv6MUoHbOqnzoTVSIxgtsyhm/NsgmL+uTmag/VVzdXvmahak1I6sOb1K/2movco5ikDJg==} + /@esbuild/android-x64@0.18.20: + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} cpu: [x64] os: [android] @@ -97,8 +97,8 @@ packages: dev: true optional: true - /@esbuild/darwin-arm64@0.18.13: - resolution: {integrity: sha512-f5goG30YgR1GU+fxtaBRdSW3SBG9pZW834Mmhxa6terzcboz7P2R0k4lDxlkP7NYRIIdBbWp+VgwQbmMH4yV7w==} + /@esbuild/darwin-arm64@0.18.20: + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] @@ -106,8 +106,8 @@ packages: dev: true optional: true - /@esbuild/darwin-x64@0.18.13: - resolution: {integrity: sha512-RIrxoKH5Eo+yE5BtaAIMZaiKutPhZjw+j0OCh8WdvKEKJQteacq0myZvBDLU+hOzQOZWJeDnuQ2xgSScKf1Ovw==} + /@esbuild/darwin-x64@0.18.20: + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} cpu: [x64] os: [darwin] @@ -115,8 +115,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-arm64@0.18.13: - resolution: {integrity: sha512-AfRPhHWmj9jGyLgW/2FkYERKmYR+IjYxf2rtSLmhOrPGFh0KCETFzSjx/JX/HJnvIqHt/DRQD/KAaVsUKoI3Xg==} + /@esbuild/freebsd-arm64@0.18.20: + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] @@ -124,8 +124,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-x64@0.18.13: - resolution: {integrity: sha512-pGzWWZJBInhIgdEwzn8VHUBang8UvFKsvjDkeJ2oyY5gZtAM6BaxK0QLCuZY+qoj/nx/lIaItH425rm/hloETA==} + /@esbuild/freebsd-x64@0.18.20: + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] @@ -133,8 +133,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm64@0.18.13: - resolution: {integrity: sha512-hCzZbVJEHV7QM77fHPv2qgBcWxgglGFGCxk6KfQx6PsVIdi1u09X7IvgE9QKqm38OpkzaAkPnnPqwRsltvLkIQ==} + /@esbuild/linux-arm64@0.18.20: + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} cpu: [arm64] os: [linux] @@ -142,8 +142,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm@0.18.13: - resolution: {integrity: sha512-4iMxLRMCxGyk7lEvkkvrxw4aJeC93YIIrfbBlUJ062kilUUnAiMb81eEkVvCVoh3ON283ans7+OQkuy1uHW+Hw==} + /@esbuild/linux-arm@0.18.20: + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} cpu: [arm] os: [linux] @@ -151,8 +151,8 @@ packages: dev: true optional: true - /@esbuild/linux-ia32@0.18.13: - resolution: {integrity: sha512-I3OKGbynl3AAIO6onXNrup/ttToE6Rv2XYfFgLK/wnr2J+1g+7k4asLrE+n7VMhaqX+BUnyWkCu27rl+62Adug==} + /@esbuild/linux-ia32@0.18.20: + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] @@ -160,8 +160,8 @@ packages: dev: true optional: true - /@esbuild/linux-loong64@0.18.13: - resolution: {integrity: sha512-8pcKDApAsKc6WW51ZEVidSGwGbebYw2qKnO1VyD8xd6JN0RN6EUXfhXmDk9Vc4/U3Y4AoFTexQewQDJGsBXBpg==} + /@esbuild/linux-loong64@0.18.20: + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] @@ -169,8 +169,8 @@ packages: dev: true optional: true - /@esbuild/linux-mips64el@0.18.13: - resolution: {integrity: sha512-6GU+J1PLiVqWx8yoCK4Z0GnfKyCGIH5L2KQipxOtbNPBs+qNDcMJr9euxnyJ6FkRPyMwaSkjejzPSISD9hb+gg==} + /@esbuild/linux-mips64el@0.18.20: + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] @@ -178,8 +178,8 @@ packages: dev: true optional: true - /@esbuild/linux-ppc64@0.18.13: - resolution: {integrity: sha512-pfn/OGZ8tyR8YCV7MlLl5hAit2cmS+j/ZZg9DdH0uxdCoJpV7+5DbuXrR+es4ayRVKIcfS9TTMCs60vqQDmh+w==} + /@esbuild/linux-ppc64@0.18.20: + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] @@ -187,8 +187,8 @@ packages: dev: true optional: true - /@esbuild/linux-riscv64@0.18.13: - resolution: {integrity: sha512-aIbhU3LPg0lOSCfVeGHbmGYIqOtW6+yzO+Nfv57YblEK01oj0mFMtvDJlOaeAZ6z0FZ9D13oahi5aIl9JFphGg==} + /@esbuild/linux-riscv64@0.18.20: + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] @@ -196,8 +196,8 @@ packages: dev: true optional: true - /@esbuild/linux-s390x@0.18.13: - resolution: {integrity: sha512-Pct1QwF2sp+5LVi4Iu5Y+6JsGaV2Z2vm4O9Dd7XZ5tKYxEHjFtb140fiMcl5HM1iuv6xXO8O1Vrb1iJxHlv8UA==} + /@esbuild/linux-s390x@0.18.20: + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} cpu: [s390x] os: [linux] @@ -205,8 +205,8 @@ packages: dev: true optional: true - /@esbuild/linux-x64@0.18.13: - resolution: {integrity: sha512-zTrIP0KzYP7O0+3ZnmzvUKgGtUvf4+piY8PIO3V8/GfmVd3ZyHJGz7Ht0np3P1wz+I8qJ4rjwJKqqEAbIEPngA==} + /@esbuild/linux-x64@0.18.20: + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} cpu: [x64] os: [linux] @@ -214,8 +214,8 @@ packages: dev: true optional: true - /@esbuild/netbsd-x64@0.18.13: - resolution: {integrity: sha512-I6zs10TZeaHDYoGxENuksxE1sxqZpCp+agYeW039yqFwh3MgVvdmXL5NMveImOC6AtpLvE4xG5ujVic4NWFIDQ==} + /@esbuild/netbsd-x64@0.18.20: + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] @@ -223,8 +223,8 @@ packages: dev: true optional: true - /@esbuild/openbsd-x64@0.18.13: - resolution: {integrity: sha512-W5C5nczhrt1y1xPG5bV+0M12p2vetOGlvs43LH8SopQ3z2AseIROu09VgRqydx5qFN7y9qCbpgHLx0kb0TcW7g==} + /@esbuild/openbsd-x64@0.18.20: + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] @@ -232,8 +232,8 @@ packages: dev: true optional: true - /@esbuild/sunos-x64@0.18.13: - resolution: {integrity: sha512-X/xzuw4Hzpo/yq3YsfBbIsipNgmsm8mE/QeWbdGdTTeZ77fjxI2K0KP3AlhZ6gU3zKTw1bKoZTuKLnqcJ537qw==} + /@esbuild/sunos-x64@0.18.20: + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} cpu: [x64] os: [sunos] @@ -241,8 +241,8 @@ packages: dev: true optional: true - /@esbuild/win32-arm64@0.18.13: - resolution: {integrity: sha512-4CGYdRQT/ILd+yLLE5i4VApMPfGE0RPc/wFQhlluDQCK09+b4JDbxzzjpgQqTPrdnP7r5KUtGVGZYclYiPuHrw==} + /@esbuild/win32-arm64@0.18.20: + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} cpu: [arm64] os: [win32] @@ -250,8 +250,8 @@ packages: dev: true optional: true - /@esbuild/win32-ia32@0.18.13: - resolution: {integrity: sha512-D+wKZaRhQI+MUGMH+DbEr4owC2D7XnF+uyGiZk38QbgzLcofFqIOwFs7ELmIeU45CQgfHNy9Q+LKW3cE8g37Kg==} + /@esbuild/win32-ia32@0.18.20: + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} cpu: [ia32] os: [win32] @@ -259,8 +259,8 @@ packages: dev: true optional: true - /@esbuild/win32-x64@0.18.13: - resolution: {integrity: sha512-iVl6lehAfJS+VmpF3exKpNQ8b0eucf5VWfzR8S7xFve64NBNz2jPUgx1X93/kfnkfgP737O+i1k54SVQS7uVZA==} + /@esbuild/win32-x64@0.18.20: + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} cpu: [x64] os: [win32] @@ -268,23 +268,23 @@ packages: dev: true optional: true - /@eslint-community/eslint-utils@4.4.0(eslint@8.45.0): + /@eslint-community/eslint-utils@4.4.0(eslint@8.46.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.45.0 - eslint-visitor-keys: 3.4.1 + eslint: 8.46.0 + eslint-visitor-keys: 3.4.2 dev: true - /@eslint-community/regexpp@4.5.1: - resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} + /@eslint-community/regexpp@4.6.2: + resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/eslintrc@2.1.0: - resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==} + /@eslint/eslintrc@2.1.1: + resolution: {integrity: sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 @@ -300,8 +300,8 @@ packages: - supports-color dev: true - /@eslint/js@8.44.0: - resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==} + /@eslint/js@8.46.0: + resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true @@ -337,30 +337,30 @@ packages: wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: true - /@microsoft/api-extractor-model@7.27.4: - resolution: {integrity: sha512-HjqQFmuGPOS20rtnu+9Jj0QrqZyR59E+piUWXPMZTTn4jaZI+4UmsHSf3Id8vyueAhOBH2cgwBuRTE5R+MfSMw==} + /@microsoft/api-extractor-model@7.27.6: + resolution: {integrity: sha512-eiCnlayyum1f7fS2nA9pfIod5VCNR1G+Tq84V/ijDrKrOFVa598BLw145nCsGDMoFenV6ajNi2PR5WCwpAxW6Q==} dependencies: '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - '@rushstack/node-core-library': 3.59.5 + '@rushstack/node-core-library': 3.59.7 transitivePeerDependencies: - '@types/node' dev: true - /@microsoft/api-extractor@7.36.2: - resolution: {integrity: sha512-ONe/jOmTZtR3OjTkWKHmeSV1P5ozbHDxHr6FV3KoWyIl1AcPk2B3dmvVBM5eOlZB5bgM66nxcWQTZ6msQo2hHg==} + /@microsoft/api-extractor@7.36.4: + resolution: {integrity: sha512-21UECq8C/8CpHT23yiqTBQ10egKUacIpxkPyYR7hdswo/M5yTWdBvbq+77YC9uPKQJOUfOD1FImBQ1DzpsdeQQ==} hasBin: true dependencies: - '@microsoft/api-extractor-model': 7.27.4 + '@microsoft/api-extractor-model': 7.27.6 '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - '@rushstack/node-core-library': 3.59.5 - '@rushstack/rig-package': 0.4.0 - '@rushstack/ts-command-line': 4.15.1 + '@rushstack/node-core-library': 3.59.7 + '@rushstack/rig-package': 0.4.1 + '@rushstack/ts-command-line': 4.15.2 colors: 1.2.5 lodash: 4.17.21 - resolve: 1.22.2 - semver: 7.3.8 + resolve: 1.22.4 + semver: 7.5.4 source-map: 0.6.1 typescript: 5.0.4 transitivePeerDependencies: @@ -422,8 +422,8 @@ packages: picomatch: 2.3.1 dev: true - /@rushstack/node-core-library@3.59.5: - resolution: {integrity: sha512-1IpV7LufrI1EoVO8hYsb3t6L8L+yp40Sa0OaOV2CIu1zx4e6ZeVNaVIEXFgMXBKdGXkAh21MnCaIzlDNpG6ZQw==} + /@rushstack/node-core-library@3.59.7: + resolution: {integrity: sha512-ln1Drq0h+Hwa1JVA65x5mlSgUrBa1uHL+V89FqVWQgXd1vVIMhrtqtWGQrhTnFHxru5ppX+FY39VWELF/FjQCw==} peerDependencies: '@types/node': '*' peerDependenciesMeta: @@ -434,20 +434,20 @@ packages: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.2 - semver: 7.3.8 + resolve: 1.22.4 + semver: 7.5.4 z-schema: 5.0.5 dev: true - /@rushstack/rig-package@0.4.0: - resolution: {integrity: sha512-FnM1TQLJYwSiurP6aYSnansprK5l8WUK8VG38CmAaZs29ZeL1msjK0AP1VS4ejD33G0kE/2cpsPsS9jDenBMxw==} + /@rushstack/rig-package@0.4.1: + resolution: {integrity: sha512-AGRwpqlXNSp9LhUSz4HKI9xCluqQDt/obsQFdv/NYIekF3pTTPzc+HbQsIsjVjYnJ3DcmxOREVMhvrMEjpiq6g==} dependencies: - resolve: 1.22.2 + resolve: 1.22.4 strip-json-comments: 3.1.1 dev: true - /@rushstack/ts-command-line@4.15.1: - resolution: {integrity: sha512-EL4jxZe5fhb1uVL/P/wQO+Z8Rc8FMiWJ1G7VgnPDvdIt5GVjRfK7vwzder1CZQiX3x0PY6uxENYLNGTFd1InRQ==} + /@rushstack/ts-command-line@4.15.2: + resolution: {integrity: sha512-5+C2uoJY8b+odcZD6coEe2XNC4ZjGB4vCMESbqW/8DHRWC/qIHfANdmN9F1wz/lAgxz72i7xRoVtPY2j7e4gpQ==} dependencies: '@types/argparse': 1.0.38 argparse: 1.0.10 @@ -471,8 +471,8 @@ packages: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} dev: true - /@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==} + /@typescript-eslint/eslint-plugin@6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-IZYjYZ0ifGSLZbwMqIip/nOamFiWJ9AH+T/GYNZBWkVcyNQOFGtSMoWV7RvY4poYCMZ/4lHzNl796WOSNxmk8A==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -482,15 +482,14 @@ packages: typescript: optional: true dependencies: - '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 + '@eslint-community/regexpp': 4.6.2 + '@typescript-eslint/parser': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/type-utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 - eslint: 8.45.0 - grapheme-splitter: 1.0.4 + eslint: 8.46.0 graphemer: 1.4.0 ignore: 5.2.4 natural-compare: 1.4.0 @@ -502,8 +501,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==} + /@typescript-eslint/parser@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-ibP+y2Gr6p0qsUkhs7InMdXrwldjxZw66wpcQq9/PzAroM45wdwyu81T+7RibNCh8oc0AgrsyCwJByncY0Ongg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -512,27 +511,27 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 - eslint: 8.45.0 + eslint: 8.46.0 typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@6.0.0: - resolution: {integrity: sha512-o4q0KHlgCZTqjuaZ25nw5W57NeykZT9LiMEG4do/ovwvOcPnDO1BI5BQdCsUkjxFyrCL0cSzLjvIMfR9uo7cWg==} + /@typescript-eslint/scope-manager@6.3.0: + resolution: {integrity: sha512-WlNFgBEuGu74ahrXzgefiz/QlVb+qg8KDTpknKwR7hMH+lQygWyx0CQFoUmMn1zDkQjTBBIn75IxtWss77iBIQ==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/visitor-keys': 6.3.0 dev: true - /@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==} + /@typescript-eslint/type-utils@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-7Oj+1ox1T2Yc8PKpBvOKWhoI/4rWFd1j7FA/rPE0lbBPXTKjdbtC+7Ev0SeBjEKkIhKWVeZSP+mR7y1Db1CdfQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -541,23 +540,23 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - '@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + '@typescript-eslint/utils': 6.3.0(eslint@8.46.0)(typescript@5.1.6) debug: 4.3.4 - eslint: 8.45.0 + eslint: 8.46.0 ts-api-utils: 1.0.1(typescript@5.1.6) typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@6.0.0: - resolution: {integrity: sha512-Zk9KDggyZM6tj0AJWYYKgF0yQyrcnievdhG0g5FqyU3Y2DRxJn4yWY21sJC0QKBckbsdKKjYDV2yVrrEvuTgxg==} + /@typescript-eslint/types@6.3.0: + resolution: {integrity: sha512-K6TZOvfVyc7MO9j60MkRNWyFSf86IbOatTKGrpTQnzarDZPYPVy0oe3myTMq7VjhfsUAbNUW8I5s+2lZvtx1gg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.0.0(typescript@5.1.6): - resolution: {integrity: sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==} + /@typescript-eslint/typescript-estree@6.3.0(typescript@5.1.6): + resolution: {integrity: sha512-Xh4NVDaC4eYKY4O3QGPuQNp5NxBAlEvNQYOqJquR2MePNxO11E5K3t5x4M4Mx53IZvtpW+mBxIT0s274fLUocg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -565,8 +564,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/visitor-keys': 6.0.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/visitor-keys': 6.3.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -577,56 +576,55 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.1.6): - resolution: {integrity: sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==} + /@typescript-eslint/utils@6.3.0(eslint@8.46.0)(typescript@5.1.6): + resolution: {integrity: sha512-hLLg3BZE07XHnpzglNBG8P/IXq/ZVXraEbgY7FM0Cnc1ehM8RMdn9mat3LubJ3KBeYXXPxV1nugWbQPjGeJk6Q==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.46.0) '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.0.0 - '@typescript-eslint/types': 6.0.0 - '@typescript-eslint/typescript-estree': 6.0.0(typescript@5.1.6) - eslint: 8.45.0 - eslint-scope: 5.1.1 + '@typescript-eslint/scope-manager': 6.3.0 + '@typescript-eslint/types': 6.3.0 + '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + eslint: 8.46.0 semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@6.0.0: - resolution: {integrity: sha512-cvJ63l8c0yXdeT5POHpL0Q1cZoRcmRKFCtSjNGJxPkcP571EfZMcNbzWAc7oK3D1dRzm/V5EwtkANTZxqvuuUA==} + /@typescript-eslint/visitor-keys@6.3.0: + resolution: {integrity: sha512-kEhRRj7HnvaSjux1J9+7dBen15CdWmDnwrpyiHsFX6Qx2iW5LOBUgNefOFeh2PjWPlNwN8TOn6+4eBU3J/gupw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.0.0 - eslint-visitor-keys: 3.4.1 + '@typescript-eslint/types': 6.3.0 + eslint-visitor-keys: 3.4.2 dev: true - /@volar/language-core@1.9.0: - resolution: {integrity: sha512-+PTRrGanAD2PxqMty0ZC46xhgW5BWzb67RLHhZyB3Im4+eMXsKlYjFUt7Z8ZCwTWQQOnj8NQ6gSgUEoOTwAHrQ==} + /@volar/language-core@1.10.0: + resolution: {integrity: sha512-ddyWwSYqcbEZNFHm+Z3NZd6M7Ihjcwl/9B5cZd8kECdimVXUFdFi60XHWD27nrWtUQIsUYIG7Ca1WBwV2u2LSQ==} dependencies: - '@volar/source-map': 1.9.0 + '@volar/source-map': 1.10.0 dev: true - /@volar/source-map@1.9.0: - resolution: {integrity: sha512-TQWLY8ozUOHBHTMC2pHZsNbtM25Q9QCEwAL8JFR/gmR9Yv0d9qup/gQdd5sDI7RmoPYKD+gqjLrbM4Ib41QSJQ==} + /@volar/source-map@1.10.0: + resolution: {integrity: sha512-/ibWdcOzDGiq/GM1JU2eX8fH1bvAhl66hfe8yEgLEzg9txgr6qb5sQ/DEz5PcDL75tF5H5sCRRwn8Eu8ezi9mw==} dependencies: muggle-string: 0.3.1 dev: true - /@volar/typescript@1.9.0: - resolution: {integrity: sha512-B8X4/H6V93uD7zu5VCw05eB0Ukcc39SFKsZoeylkAk2sJ50oaJLpajnQ8Ov4c+FnVQ6iPA6Xy1qdWoWJjh6xEg==} + /@volar/typescript@1.10.0: + resolution: {integrity: sha512-OtqGtFbUKYC0pLNIk3mHQp5xWnvL1CJIUc9VE39VdZ/oqpoBh5jKfb9uJ45Y4/oP/WYTrif/Uxl1k8VTPz66Gg==} dependencies: - '@volar/language-core': 1.9.0 + '@volar/language-core': 1.10.0 dev: true /@vue/compiler-core@3.3.4: resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==} dependencies: - '@babel/parser': 7.22.7 + '@babel/parser': 7.22.10 '@vue/shared': 3.3.4 estree-walker: 2.0.2 source-map-js: 1.0.2 @@ -639,16 +637,16 @@ packages: '@vue/shared': 3.3.4 dev: true - /@vue/language-core@1.8.5(typescript@5.1.6): - resolution: {integrity: sha512-DKQNiNQzNV7nrkZQujvjfX73zqKdj2+KoM4YeKl+ft3f+crO3JB4ycPnmgaRMNX/ULJootdQPGHKFRl5cXxwaw==} + /@vue/language-core@1.8.8(typescript@5.1.6): + resolution: {integrity: sha512-i4KMTuPazf48yMdYoebTkgSOJdFraE4pQf0B+FTOFkbB+6hAfjrSou/UmYWRsWyZV6r4Rc6DDZdI39CJwL0rWw==} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@volar/language-core': 1.9.0 - '@volar/source-map': 1.9.0 + '@volar/language-core': 1.10.0 + '@volar/source-map': 1.10.0 '@vue/compiler-dom': 3.3.4 '@vue/reactivity': 3.3.4 '@vue/shared': 3.3.4 @@ -668,11 +666,11 @@ packages: resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} dev: true - /@vue/typescript@1.8.5(typescript@5.1.6): - resolution: {integrity: sha512-domFBbNr3PEcjGBeB+cmgUM3cI6pJsJezguIUKZ1rphkfIkICyoMjCd3TitoP32yo2KABLiaXcGFzgFfQf6B3w==} + /@vue/typescript@1.8.8(typescript@5.1.6): + resolution: {integrity: sha512-jUnmMB6egu5wl342eaUH236v8tdcEPXXkPgj+eI/F6JwW/lb+yAU6U07ZbQ3MVabZRlupIlPESB7ajgAGixhow==} dependencies: - '@volar/typescript': 1.9.0 - '@vue/language-core': 1.8.5(typescript@5.1.6) + '@volar/typescript': 1.10.0 + '@vue/language-core': 1.8.8(typescript@5.1.6) transitivePeerDependencies: - typescript dev: true @@ -861,34 +859,34 @@ packages: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: true - /esbuild@0.18.13: - resolution: {integrity: sha512-vhg/WR/Oiu4oUIkVhmfcc23G6/zWuEQKFS+yiosSHe4aN6+DQRXIfeloYGibIfVhkr4wyfuVsGNLr+sQU1rWWw==} + /esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} hasBin: true requiresBuild: true optionalDependencies: - '@esbuild/android-arm': 0.18.13 - '@esbuild/android-arm64': 0.18.13 - '@esbuild/android-x64': 0.18.13 - '@esbuild/darwin-arm64': 0.18.13 - '@esbuild/darwin-x64': 0.18.13 - '@esbuild/freebsd-arm64': 0.18.13 - '@esbuild/freebsd-x64': 0.18.13 - '@esbuild/linux-arm': 0.18.13 - '@esbuild/linux-arm64': 0.18.13 - '@esbuild/linux-ia32': 0.18.13 - '@esbuild/linux-loong64': 0.18.13 - '@esbuild/linux-mips64el': 0.18.13 - '@esbuild/linux-ppc64': 0.18.13 - '@esbuild/linux-riscv64': 0.18.13 - '@esbuild/linux-s390x': 0.18.13 - '@esbuild/linux-x64': 0.18.13 - '@esbuild/netbsd-x64': 0.18.13 - '@esbuild/openbsd-x64': 0.18.13 - '@esbuild/sunos-x64': 0.18.13 - '@esbuild/win32-arm64': 0.18.13 - '@esbuild/win32-ia32': 0.18.13 - '@esbuild/win32-x64': 0.18.13 + '@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 dev: true /escape-string-regexp@4.0.0: @@ -896,45 +894,37 @@ packages: engines: {node: '>=10'} dev: true - /eslint-config-prettier@8.8.0(eslint@8.45.0): - resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} + /eslint-config-prettier@8.10.0(eslint@8.46.0): + resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.45.0 + eslint: 8.46.0 dev: true - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: true - - /eslint-scope@7.2.1: - resolution: {integrity: sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==} + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 dev: true - /eslint-visitor-keys@3.4.1: - resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + /eslint-visitor-keys@3.4.2: + resolution: {integrity: sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.45.0: - resolution: {integrity: sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==} + /eslint@8.46.0: + resolution: {integrity: sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@eslint-community/regexpp': 4.5.1 - '@eslint/eslintrc': 2.1.0 - '@eslint/js': 8.44.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.46.0) + '@eslint-community/regexpp': 4.6.2 + '@eslint/eslintrc': 2.1.1 + '@eslint/js': 8.46.0 '@humanwhocodes/config-array': 0.11.10 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 @@ -944,8 +934,8 @@ packages: debug: 4.3.4 doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.1 - eslint-visitor-keys: 3.4.1 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.2 espree: 9.6.1 esquery: 1.5.0 esutils: 2.0.3 @@ -978,7 +968,7 @@ packages: dependencies: acorn: 8.10.0 acorn-jsx: 5.3.2(acorn@8.10.0) - eslint-visitor-keys: 3.4.1 + eslint-visitor-keys: 3.4.2 dev: true /esquery@1.5.0: @@ -995,11 +985,6 @@ packages: estraverse: 5.3.0 dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -1018,8 +1003,8 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true - /fast-glob@3.3.0: - resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} + /fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1082,7 +1067,7 @@ packages: engines: {node: '>=14'} dependencies: cross-spawn: 7.0.3 - signal-exit: 4.0.2 + signal-exit: 4.1.0 dev: true /fs-extra@7.0.1: @@ -1130,7 +1115,7 @@ packages: hasBin: true dependencies: foreground-child: 3.1.1 - jackspeak: 2.2.1 + jackspeak: 2.2.2 minimatch: 9.0.3 minipass: 7.0.2 path-scurry: 1.10.1 @@ -1160,7 +1145,7 @@ packages: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.0 + fast-glob: 3.3.1 ignore: 5.2.4 merge2: 1.4.1 slash: 3.0.0 @@ -1170,10 +1155,6 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: true - /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true @@ -1229,8 +1210,8 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} + /is-core-module@2.13.0: + resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} dependencies: has: 1.0.3 dev: true @@ -1266,8 +1247,8 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true - /jackspeak@2.2.1: - resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==} + /jackspeak@2.2.2: + resolution: {integrity: sha512-mgNtVv4vUuaKA97yxUHoA3+FkuhtxkjdXEWOyB/N76fjy0FjezEt34oy3epBtvCvS+7DyKwqCFWx/oJLV5+kCg==} engines: {node: '>=14'} dependencies: '@isaacs/cliui': 8.0.2 @@ -1480,8 +1461,8 @@ packages: engines: {node: '>=8.6'} dev: true - /postcss@8.4.26: - resolution: {integrity: sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==} + /postcss@8.4.27: + resolution: {integrity: sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==} engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.6 @@ -1494,8 +1475,8 @@ packages: engines: {node: '>= 0.8.0'} dev: true - /prettier@3.0.0: - resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} + /prettier@3.0.1: + resolution: {integrity: sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==} engines: {node: '>=14'} hasBin: true dev: true @@ -1517,15 +1498,15 @@ packages: /resolve@1.19.0: resolution: {integrity: sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==} dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 dev: true - /resolve@1.22.2: - resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + /resolve@1.22.4: + resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: true @@ -1550,8 +1531,8 @@ packages: glob: 10.3.3 dev: true - /rollup@3.26.2: - resolution: {integrity: sha512-6umBIGVz93er97pMgQO08LuH3m6PUb3jlDUUGFsNJB6VgTCUaDFpupf5JfU30529m/UKOgmiX+uY6Sx8cOYpLA==} + /rollup@3.28.0: + resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: @@ -1564,14 +1545,6 @@ packages: queue-microtask: 1.2.3 dev: true - /semver@7.3.8: - resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} - engines: {node: '>=10'} - hasBin: true - dependencies: - lru-cache: 6.0.0 - dev: true - /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -1592,8 +1565,8 @@ packages: engines: {node: '>=8'} dev: true - /signal-exit@4.0.2: - resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} dev: true @@ -1730,13 +1703,13 @@ packages: punycode: 2.3.0 dev: true - /validator@13.9.0: - resolution: {integrity: sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==} + /validator@13.11.0: + resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} engines: {node: '>= 0.10'} dev: true - /vite-plugin-dts@3.3.0(typescript@5.1.6)(vite@4.4.4): - resolution: {integrity: sha512-9jm7wV8fkA4JaKmZdeg/X71dMi8l9SbdmzQRafW4ea1fOfd/LHBDKuwFuxKpK8h1h8O7abKycXS087EP7EL8Hw==} + /vite-plugin-dts@3.5.1(typescript@5.1.6)(vite@4.4.9): + resolution: {integrity: sha512-wrrIvRTWq9xL0HKOUvJyJ+wivEoLsZ2GU2I2000v5tAAUtu9gE+5OUmUJ9yNkmyYz3tSPedkkiXHeb5jnnSXhg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: typescript: '*' @@ -1745,22 +1718,22 @@ packages: vite: optional: true dependencies: - '@microsoft/api-extractor': 7.36.2 + '@microsoft/api-extractor': 7.36.4 '@rollup/pluginutils': 5.0.2 - '@vue/language-core': 1.8.5(typescript@5.1.6) + '@vue/language-core': 1.8.8(typescript@5.1.6) debug: 4.3.4 kolorist: 1.8.0 typescript: 5.1.6 - vite: 4.4.4 - vue-tsc: 1.8.5(typescript@5.1.6) + vite: 4.4.9 + vue-tsc: 1.8.8(typescript@5.1.6) transitivePeerDependencies: - '@types/node' - rollup - supports-color dev: true - /vite@4.4.4: - resolution: {integrity: sha512-4mvsTxjkveWrKDJI70QmelfVqTm+ihFAb6+xf4sjEU2TmUCTlVX87tmg/QooPEMQb/lM9qGHT99ebqPziEd3wg==} + /vite@4.4.9: + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true peerDependencies: @@ -1787,9 +1760,9 @@ packages: terser: optional: true dependencies: - esbuild: 0.18.13 - postcss: 8.4.26 - rollup: 3.26.2 + esbuild: 0.18.20 + postcss: 8.4.27 + rollup: 3.28.0 optionalDependencies: fsevents: 2.3.2 dev: true @@ -1801,14 +1774,14 @@ packages: he: 1.2.0 dev: true - /vue-tsc@1.8.5(typescript@5.1.6): - resolution: {integrity: sha512-Jr8PTghJIwp69MFsEZoADDcv2l+lXA8juyN/5AYA5zxyZNvIHjSbgKgkYIYc1qnihrOyIG1VOnfk4ZE0jqn8bw==} + /vue-tsc@1.8.8(typescript@5.1.6): + resolution: {integrity: sha512-bSydNFQsF7AMvwWsRXD7cBIXaNs/KSjvzWLymq/UtKE36697sboX4EccSHFVxvgdBlI1frYPc/VMKJNB7DFeDQ==} hasBin: true peerDependencies: typescript: '*' dependencies: - '@vue/language-core': 1.8.5(typescript@5.1.6) - '@vue/typescript': 1.8.5(typescript@5.1.6) + '@vue/language-core': 1.8.8(typescript@5.1.6) + '@vue/typescript': 1.8.8(typescript@5.1.6) semver: 7.5.4 typescript: 5.1.6 dev: true @@ -1859,7 +1832,7 @@ packages: dependencies: lodash.get: 4.4.2 lodash.isequal: 4.5.0 - validator: 13.9.0 + validator: 13.11.0 optionalDependencies: commander: 9.5.0 dev: true diff --git a/video/player/src/hls/playlist/media.rs b/video/player/src/hls/playlist/media.rs index 567e8c10..4a21b712 100644 --- a/video/player/src/hls/playlist/media.rs +++ b/video/player/src/hls/playlist/media.rs @@ -250,7 +250,7 @@ impl MediaPlaylist { return Some(Err("no LAST-MSN attribute found")); }; let Ok(last_msn) = last_msn.parse() else { - return Some(Err("LAST-MSN attribute is not a number")); + return Some(Err("LAST-MSN attribute is not a number")); }; let Some(last_part) = attributes.get("LAST-PART") else { return Some(Err("no LAST-PART attribute found")); diff --git a/video/player/src/hls/playlist/utils.rs b/video/player/src/hls/playlist/utils.rs index 1040357d..5fd64004 100644 --- a/video/player/src/hls/playlist/utils.rs +++ b/video/player/src/hls/playlist/utils.rs @@ -91,9 +91,7 @@ fn parse_attributes(line: &str) -> Result, String> { value = String::new(); } ',' => { - let Some(key) = key.take() else { - continue - }; + let Some(key) = key.take() else { continue }; attributes.insert(key, value); value = String::new(); diff --git a/video/player/src/player/events.rs b/video/player/src/player/events.rs index 8081622c..18d916fb 100644 --- a/video/player/src/player/events.rs +++ b/video/player/src/player/events.rs @@ -142,10 +142,7 @@ impl EventManager { } pub fn add_event_listener(&mut self, event: &str, f: JsValue, once: bool) { - let listeners = self - .events - .entry(event.to_string()) - .or_insert_with(Vec::new); + let listeners = self.events.entry(event.to_string()).or_default(); listeners.push(EventListener { f: f.unchecked_into(), once, diff --git a/video/player/src/player/runner/mod.rs b/video/player/src/player/runner/mod.rs index d9c7d450..1217b9b8 100644 --- a/video/player/src/player/runner/mod.rs +++ b/video/player/src/player/runner/mod.rs @@ -555,7 +555,7 @@ impl PlayerRunner { ); self.fragment_buffer .entry(tid) - .or_insert_with(Vec::new) + .or_default() .extend(fragments); return Ok(()); } else if !self.active_track_ids().contains(&tid) { @@ -680,7 +680,10 @@ impl PlayerRunner { async fn fetch_playlist(&mut self) -> Result { let Ok(input_url) = Url::parse(&self.inner.url()) else { - return Err(JsValue::from_str(&format!("failed to parse url: {}", self.inner.url()))); + return Err(JsValue::from_str(&format!( + "failed to parse url: {}", + self.inner.url() + ))); }; let mut req = FetchRequest::new("GET", input_url.clone()) @@ -1059,7 +1062,10 @@ impl PlayerRunner { let mut group_to_id = HashMap::new(); for (group_idx, stream) in reference_streams.enumerate() { let Some(groups) = playlist.groups.get_mut(stream) else { - return Err(JsValue::from_str(&format!("failed to find group for stream: {}", stream))); + return Err(JsValue::from_str(&format!( + "failed to find group for stream: {}", + stream + ))); }; // If we have a default track we need to make sure only 1 track is default @@ -1176,7 +1182,6 @@ impl PlayerRunner { self.inner.set_tracks(tracks, variants, true); self.inner .set_active_group_track_ids(self.active_group_track_ids.clone()); - self.inner.set_active_variant_id(1); Ok(()) } @@ -1212,7 +1217,6 @@ impl PlayerRunner { self.track_states = vec![track_state]; self.inner.set_tracks(vec![track], vec![variant], false); - self.inner.set_active_variant_id(0); Ok(()) } diff --git a/video/player/src/tests/manifest.rs b/video/player/src/tests/manifest.rs new file mode 100644 index 00000000..88dee461 --- /dev/null +++ b/video/player/src/tests/manifest.rs @@ -0,0 +1,285 @@ +use wasm_bindgen_test::*; + +use crate::hls::{ + self, + master::{Media, MediaType, ScufGroup, Stream}, +}; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn parse_hls_master() { + const HLS_MASTER: &str = include_str!("./data/master_multi_transcode.m3u8"); + + let playlist = HLS_MASTER.parse::().unwrap(); + + let master = match playlist { + hls::Playlist::Master(master) => master, + _ => panic!("Expected master playlist"), + }; + + assert!(master.groups.len() == 6); + assert!(master.scuf_groups.len() == 2); + assert!(master.streams.len() == 10); + + assert_eq!( + master.scuf_groups.get("opus"), + Some(&ScufGroup { priority: 1 }) + ); + assert_eq!( + master.scuf_groups.get("aac"), + Some(&ScufGroup { priority: 2 }) + ); + + assert_eq!( + master.groups.get("043897a5-cda1-458b-84d6-ce7a879a6a1e"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Audio, + bandwidth: 98304, + codecs: "opus".to_string(), + group_id: "043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string(), + default: true, + forced: false, + frame_rate: None, + name: "043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string(), + resolution: None, + uri: "043897a5-cda1-458b-84d6-ce7a879a6a1e/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.groups.get("19c0a428-0925-40f7-ac28-979905df98a1"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Audio, + bandwidth: 131072, + codecs: "mp4a.40.2".to_string(), + group_id: "19c0a428-0925-40f7-ac28-979905df98a1".to_string(), + default: true, + forced: false, + frame_rate: None, + name: "19c0a428-0925-40f7-ac28-979905df98a1".to_string(), + resolution: None, + uri: "19c0a428-0925-40f7-ac28-979905df98a1/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.groups.get("541cf75a-ba88-4c91-b4a5-9023d7213f5d"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Video, + bandwidth: 8192000, + codecs: "av01.0.13M.08.0.110.01.01.01.0".to_string(), + group_id: "541cf75a-ba88-4c91-b4a5-9023d7213f5d".to_string(), + default: true, + forced: false, + frame_rate: Some(60.0), + name: "541cf75a-ba88-4c91-b4a5-9023d7213f5d".to_string(), + resolution: Some((3840, 2160)), + uri: "541cf75a-ba88-4c91-b4a5-9023d7213f5d/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.groups.get("e32eb6be-fd84-4bbe-8cd3-67f25be03845"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Video, + bandwidth: 4096000, + codecs: "avc1.640033".to_string(), + group_id: "e32eb6be-fd84-4bbe-8cd3-67f25be03845".to_string(), + default: true, + forced: false, + frame_rate: Some(60.0), + name: "e32eb6be-fd84-4bbe-8cd3-67f25be03845".to_string(), + resolution: Some((1280, 720)), + uri: "e32eb6be-fd84-4bbe-8cd3-67f25be03845/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.groups.get("68b9c364-e04e-4fd7-b467-b751d74ef082"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Video, + bandwidth: 2048000, + codecs: "avc1.640033".to_string(), + group_id: "68b9c364-e04e-4fd7-b467-b751d74ef082".to_string(), + default: true, + forced: false, + frame_rate: Some(30.0), + name: "68b9c364-e04e-4fd7-b467-b751d74ef082".to_string(), + resolution: Some((853, 480)), + uri: "68b9c364-e04e-4fd7-b467-b751d74ef082/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.groups.get("9c24b89d-9fa8-4b69-9c26-261393055869"), + Some(&vec![Media { + autoselect: true, + media_type: MediaType::Video, + bandwidth: 1024000, + codecs: "avc1.640033".to_string(), + group_id: "9c24b89d-9fa8-4b69-9c26-261393055869".to_string(), + default: true, + forced: false, + frame_rate: Some(30.0), + name: "9c24b89d-9fa8-4b69-9c26-261393055869".to_string(), + resolution: Some((640, 360)), + uri: "9c24b89d-9fa8-4b69-9c26-261393055869/index.m3u8".to_string(), + }]) + ); + + assert_eq!( + master.streams[0], + Stream { + audio: Some("043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string()), + video: None, + bandwidth: 98304, + codecs: "opus".to_string(), + group: "opus".to_string(), + name: "audio-only".to_string(), + uri: "043897a5-cda1-458b-84d6-ce7a879a6a1e/index.m3u8".to_string(), + frame_rate: None, + resolution: None, + } + ); + + assert_eq!( + master.streams[1], + Stream { + audio: Some("19c0a428-0925-40f7-ac28-979905df98a1".to_string()), + video: None, + bandwidth: 131072, + codecs: "mp4a.40.2".to_string(), + group: "aac".to_string(), + name: "audio-only".to_string(), + uri: "19c0a428-0925-40f7-ac28-979905df98a1/index.m3u8".to_string(), + frame_rate: None, + resolution: None, + } + ); + + assert_eq!( + master.streams[2], + Stream { + audio: Some("043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string()), + video: Some("541cf75a-ba88-4c91-b4a5-9023d7213f5d".to_string()), + bandwidth: 8192000 + 98304, + codecs: "av01.0.13M.08.0.110.01.01.01.0,opus".to_string(), + group: "opus".to_string(), + name: "source".to_string(), + uri: "541cf75a-ba88-4c91-b4a5-9023d7213f5d/index.m3u8".to_string(), + frame_rate: Some(60.0), + resolution: Some((3840, 2160)), + } + ); + + assert_eq!( + master.streams[3], + Stream { + audio: Some("19c0a428-0925-40f7-ac28-979905df98a1".to_string()), + video: Some("541cf75a-ba88-4c91-b4a5-9023d7213f5d".to_string()), + bandwidth: 8192000 + 131072, + codecs: "av01.0.13M.08.0.110.01.01.01.0,mp4a.40.2".to_string(), + group: "aac".to_string(), + name: "source".to_string(), + uri: "541cf75a-ba88-4c91-b4a5-9023d7213f5d/index.m3u8".to_string(), + frame_rate: Some(60.0), + resolution: Some((3840, 2160)), + } + ); + + assert_eq!( + master.streams[4], + Stream { + audio: Some("043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string()), + video: Some("e32eb6be-fd84-4bbe-8cd3-67f25be03845".to_string()), + bandwidth: 4096000 + 98304, + codecs: "avc1.640033,opus".to_string(), + group: "opus".to_string(), + name: "720p".to_string(), + uri: "e32eb6be-fd84-4bbe-8cd3-67f25be03845/index.m3u8".to_string(), + frame_rate: Some(60.0), + resolution: Some((1280, 720)), + } + ); + + assert_eq!( + master.streams[5], + Stream { + audio: Some("19c0a428-0925-40f7-ac28-979905df98a1".to_string()), + video: Some("e32eb6be-fd84-4bbe-8cd3-67f25be03845".to_string()), + bandwidth: 4096000 + 131072, + codecs: "avc1.640033,mp4a.40.2".to_string(), + group: "aac".to_string(), + name: "720p".to_string(), + uri: "e32eb6be-fd84-4bbe-8cd3-67f25be03845/index.m3u8".to_string(), + frame_rate: Some(60.0), + resolution: Some((1280, 720)), + } + ); + + assert_eq!( + master.streams[6], + Stream { + audio: Some("043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string()), + video: Some("68b9c364-e04e-4fd7-b467-b751d74ef082".to_string()), + bandwidth: 2048000 + 98304, + codecs: "avc1.640033,opus".to_string(), + group: "opus".to_string(), + name: "480p".to_string(), + uri: "68b9c364-e04e-4fd7-b467-b751d74ef082/index.m3u8".to_string(), + frame_rate: Some(30.0), + resolution: Some((853, 480)), + } + ); + + assert_eq!( + master.streams[7], + Stream { + audio: Some("19c0a428-0925-40f7-ac28-979905df98a1".to_string()), + video: Some("68b9c364-e04e-4fd7-b467-b751d74ef082".to_string()), + bandwidth: 2048000 + 131072, + codecs: "avc1.640033,mp4a.40.2".to_string(), + group: "aac".to_string(), + name: "480p".to_string(), + uri: "68b9c364-e04e-4fd7-b467-b751d74ef082/index.m3u8".to_string(), + frame_rate: Some(30.0), + resolution: Some((853, 480)), + } + ); + + assert_eq!( + master.streams[8], + Stream { + audio: Some("043897a5-cda1-458b-84d6-ce7a879a6a1e".to_string()), + video: Some("9c24b89d-9fa8-4b69-9c26-261393055869".to_string()), + bandwidth: 1024000 + 98304, + codecs: "avc1.640033,opus".to_string(), + group: "opus".to_string(), + name: "360p".to_string(), + uri: "9c24b89d-9fa8-4b69-9c26-261393055869/index.m3u8".to_string(), + frame_rate: Some(30.0), + resolution: Some((640, 360)), + } + ); + + assert_eq!( + master.streams[9], + Stream { + audio: Some("19c0a428-0925-40f7-ac28-979905df98a1".to_string()), + video: Some("9c24b89d-9fa8-4b69-9c26-261393055869".to_string()), + bandwidth: 1024000 + 131072, + codecs: "avc1.640033,mp4a.40.2".to_string(), + group: "aac".to_string(), + name: "360p".to_string(), + uri: "9c24b89d-9fa8-4b69-9c26-261393055869/index.m3u8".to_string(), + frame_rate: Some(30.0), + resolution: Some((640, 360)), + } + ); +} diff --git a/video/player/src/tests/mod.rs b/video/player/src/tests/mod.rs index 08125fb8..38611b4b 100644 --- a/video/player/src/tests/mod.rs +++ b/video/player/src/tests/mod.rs @@ -1 +1 @@ -mod web; +mod manifest; diff --git a/video/player/vite.config.ts b/video/player/vite.config.ts index 2f21d9f5..ed62cb86 100644 --- a/video/player/vite.config.ts +++ b/video/player/vite.config.ts @@ -1,11 +1,30 @@ import { defineConfig } from "vite"; +import path from "path"; +import dts from "vite-plugin-dts"; export default defineConfig({ - plugins: [], + plugins: [ + dts({ + outDir: ["dist"], + insertTypesEntry: true, + }), + ], + optimizeDeps: { + exclude: ["player-wasm"], + }, build: { minify: false, target: "esnext", outDir: "dist", + lib: { + entry: path.resolve(__dirname, "js/main.ts"), + formats: ["es"], + name: "Player", + fileName: "player", + }, assetsInlineLimit: 0, + rollupOptions: { + external: [/pkg/], + }, }, }); diff --git a/video/player/vite.demo.config.ts b/video/player/vite.demo.config.ts new file mode 100644 index 00000000..2f21d9f5 --- /dev/null +++ b/video/player/vite.demo.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [], + build: { + minify: false, + target: "esnext", + outDir: "dist", + assetsInlineLimit: 0, + }, +}); diff --git a/video/transcoder/Cargo.toml b/video/transcoder/Cargo.toml index 003b0783..e9cdbf85 100644 --- a/video/transcoder/Cargo.toml +++ b/video/transcoder/Cargo.toml @@ -1,49 +1,51 @@ [package] -name = "transcoder" -version = "0.1.0" +name = "video-transcoder" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1" -tracing = "0" -native-tls = "0" -tokio-native-tls = "0" -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -hyper = { version = "0", features = ["full"] } -tonic = { version = "0", features = ["tls"] } -chrono = { version = "0", default-features = false, features = ["clock"] } -prost = "0" -async-stream = "0" -futures = "0" -futures-util = "0" -bytes = "1" -async-trait = "0" -fred = { version = "6", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } -url-parse = "1" -nix = "0" -sha2 = "0" -tokio-util = "0" -tokio-stream = "0" -lapin = { version = "2", features = ["native-tls"] } -uuid = { version = "1", features = ["v4"] } +anyhow = "1.0.72" +tracing = "0.1.37" +native-tls = "0.2.11" +tokio-native-tls = "0.3.1" +tokio = { version = "1.29.1", features = ["full"] } +serde = { version = "1.0.183", features = ["derive"] } +hyper = { version = "0.14.27", features = ["full"] } +tonic = { version = "0.9.2", features = ["tls"] } +chrono = { version = "0.4.26", default-features = false, features = ["clock"] } +prost = "0.11.9" +async-stream = "0.3.5" +futures = "0.3.28" +futures-util = "0.3.28" +bytes = "1.4.0" +async-trait = "0.1.72" +url-parse = "1.0.7" +nix = "0.26.2" +sha2 = "0.10.7" +tokio-util = "0.7.8" +tokio-stream = "0.1.14" +ulid = { version = "1.0.0", features = ["uuid"] } +uuid = { version = "1.4.1", features = ["serde", "v4"] } +async-nats = "0.31.0" +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +sqlx-postgres = "0.7.1" +thiserror = "1.0.47" -aac = { path = "../codec/aac" } -mp4 = { path = "../container/mp4" } -common = { path = "../../common" } -bytesio = { path = "../bytesio" } -config = { path = "../../config/config" } - -[build-dependencies] -tonic-build = "0" -prost-build = "0" +aac = { workspace = true } +mp4 = { workspace = true } +common = { workspace = true, features = ["default"] } +bytesio = { workspace = true, features = ["default"] } +config = { workspace = true } +pb = { workspace = true } +video-database = { workspace = true } [dev-dependencies] -dotenvy = "0" -portpicker = "0" -serial_test = "2" -tempfile = "3" -transmuxer = { path = "../transmuxer" } -flv = { path = "../container/flv" } +dotenvy = "0.15.7" +portpicker = "0.1.1" +serial_test = "2.0.0" +tempfile = "3.7.1" +serde_json = "1.0.105" +transmuxer = { workspace = true } +flv = { workspace = true } diff --git a/video/transcoder/build.rs b/video/transcoder/build.rs deleted file mode 100644 index 3c3ceedf..00000000 --- a/video/transcoder/build.rs +++ /dev/null @@ -1,23 +0,0 @@ -const PROTO_DIR: &str = "../../proto"; - -fn main() { - let mut config = prost_build::Config::new(); - - config.protoc_arg("--experimental_allow_proto3_optional"); - config.bytes(["."]); - - tonic_build::configure() - .compile_with_config( - config, - &[ - format!("{}/scuffle/events/ingest.proto", PROTO_DIR), - format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), - format!("{}/scuffle/backend/api.proto", PROTO_DIR), - format!("{}/scuffle/video/ingest.proto", PROTO_DIR), - format!("{}/scuffle/video/transcoder.proto", PROTO_DIR), - format!("{}/scuffle/utils/health.proto", PROTO_DIR), - ], - &[PROTO_DIR], - ) - .unwrap(); -} diff --git a/video/transcoder/src/config.rs b/video/transcoder/src/config.rs index a5b72a1f..4121e712 100644 --- a/video/transcoder/src/config.rs +++ b/video/transcoder/src/config.rs @@ -1,7 +1,7 @@ -use std::net::SocketAddr; +use std::{net::SocketAddr, time::Duration}; use anyhow::Result; -use common::config::{LoggingConfig, RedisConfig, RmqConfig, TlsConfig}; +use common::config::{LoggingConfig, NatsConfig, TlsConfig}; #[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] #[serde(default)] @@ -35,23 +35,70 @@ pub struct TranscoderConfig { /// The direcory to create unix sockets in pub socket_dir: String, - /// The name of the RMQ queue to use - pub rmq_queue: String, + /// The name of the transcoder requests queue to use + pub transcoder_request_subject: String, + + /// The name of the events queue to use + pub events_subject: String, /// The uid to use for the unix socket and ffmpeg process - pub uid: u32, + pub ffmpeg_uid: u32, /// The gid to use for the unix socket and ffmpeg process - pub gid: u32, + pub ffmpeg_gid: u32, + + /// The NATS KV bucket to use for metadata + pub metadata_kv_store: String, + + /// The NATS ObjectStore bucket to use for media + pub media_ob_store: String, + + /// The target segment length + pub min_segment_duration: Duration, + + /// The target part length + pub target_part_duration: Duration, + + /// The maximum part length + pub max_part_duration: Duration, + + /// The TLS config to use when connecting to ingest + pub ingest_tls: Option, + + /// The number of segments to keep in the playlist + pub playlist_segments: usize, } impl Default for TranscoderConfig { fn default() -> Self { Self { - rmq_queue: "transcoder".to_string(), + events_subject: "events".to_string(), + transcoder_request_subject: "transcoder-request".to_string(), socket_dir: format!("/tmp/{}", std::process::id()), - uid: 1000, - gid: 1000, + ffmpeg_uid: 1000, + ffmpeg_gid: 1000, + metadata_kv_store: "transcoder-metadata".to_string(), + media_ob_store: "transcoder-media".to_string(), + min_segment_duration: Duration::from_secs(2), + target_part_duration: Duration::from_millis(250), + max_part_duration: Duration::from_millis(500), + ingest_tls: None, + playlist_segments: 5, + } + } +} + +#[derive(Debug, Clone, PartialEq, config::Config, serde::Deserialize)] +#[serde(default)] +pub struct DatabaseConfig { + /// The database URL to use + pub uri: String, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + uri: "postgres://root@localhost:5432/scuffle_video".to_string(), } } } @@ -71,11 +118,11 @@ pub struct AppConfig { /// gRPC server configuration pub grpc: GrpcConfig, - /// RMQ configuration - pub rmq: RmqConfig, + /// NATS configuration + pub nats: NatsConfig, - /// Redis configuration - pub redis: RedisConfig, + /// Database configuration + pub database: DatabaseConfig, /// Transcoder configuration pub transcoder: TranscoderConfig, @@ -88,8 +135,8 @@ impl Default for AppConfig { config_file: Some("config".to_string()), grpc: GrpcConfig::default(), logging: LoggingConfig::default(), - rmq: RmqConfig::default(), - redis: RedisConfig::default(), + nats: NatsConfig::default(), + database: DatabaseConfig::default(), transcoder: TranscoderConfig::default(), } } diff --git a/video/transcoder/src/global.rs b/video/transcoder/src/global.rs index bd99cdb1..012a815c 100644 --- a/video/transcoder/src/global.rs +++ b/video/transcoder/src/global.rs @@ -1,132 +1,52 @@ use std::sync::Arc; -use common::context::Context; -use fred::{ - pool::RedisPool, - types::{PerformanceConfig, ReconnectPolicy, RedisConfig, ServerConfig}, -}; -use lapin::{ - options::QueueDeclareOptions, - types::{AMQPValue, FieldTable}, -}; +use common::{context::Context, grpc::TlsSettings}; use crate::config::AppConfig; pub struct GlobalState { pub config: AppConfig, pub ctx: Context, - pub rmq: common::rmq::ConnectionPool, - pub redis: RedisPool, + pub nats: async_nats::Client, + pub jetstream: async_nats::jetstream::Context, + pub media_store: async_nats::jetstream::object_store::ObjectStore, + pub metadata_store: async_nats::jetstream::kv::Store, + pub db: Arc, + ingest_tls: Option, } impl GlobalState { pub fn new( config: AppConfig, ctx: Context, - rmq: common::rmq::ConnectionPool, - redis: RedisPool, + nats: async_nats::Client, + db: Arc, + metadata_store: async_nats::jetstream::kv::Store, + media_store: async_nats::jetstream::object_store::ObjectStore, ) -> Self { Self { + ingest_tls: config.transcoder.ingest_tls.as_ref().map(|tls| { + let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); + let key = std::fs::read(&tls.key).expect("failed to read redis key"); + let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); + + TlsSettings { + domain: tls.domain.clone(), + ca_cert: tonic::transport::Certificate::from_pem(ca_cert), + identity: tonic::transport::Identity::from_pem(cert, key), + } + }), config, ctx, - rmq, - redis, + jetstream: async_nats::jetstream::new(nats.clone()), + nats, + db, + metadata_store, + media_store, } } -} - -pub async fn init_rmq(global: &Arc, durable: bool) { - let channel = global.rmq.aquire().await.expect("failed to create channel"); - - let mut options = FieldTable::default(); - - options.insert("x-message-ttl".into(), AMQPValue::LongUInt(60 * 1000)); - - channel - .queue_declare( - &global.config.transcoder.rmq_queue, - QueueDeclareOptions { - durable, - ..Default::default() - }, - options, - ) - .await - .expect("failed to declare queue"); -} - -pub fn setup_redis(config: &AppConfig) -> RedisPool { - let mut redis_config = RedisConfig::default(); - let performance = PerformanceConfig::default(); - let policy = ReconnectPolicy::default(); - - redis_config.database = Some(config.redis.database); - redis_config.username = config.redis.username.clone(); - redis_config.password = config.redis.password.clone(); - - redis_config.server = if let Some(sentinel) = &config.redis.sentinel { - let addresses = config - .redis - .addresses - .iter() - .map(|a| { - let mut parts = a.split(':'); - let host = parts.next().expect("no redis host"); - let port = parts - .next() - .expect("no redis port") - .parse() - .expect("failed to parse redis port"); - (host, port) - }) - .collect::>(); - - ServerConfig::new_sentinel(addresses, sentinel.service_name.clone()) - } else { - let server = config.redis.addresses.first().expect("no redis addresses"); - if config.redis.addresses.len() > 1 { - tracing::warn!("multiple redis addresses, only using first: {}", server); - } - - let mut parts = server.split(':'); - let host = parts.next().expect("no redis host"); - let port = parts - .next() - .expect("no redis port") - .parse() - .expect("failed to parse redis port"); - - ServerConfig::new_centralized(host, port) - }; - - redis_config.tls = if let Some(tls) = &config.redis.tls { - let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); - let key = std::fs::read(&tls.key).expect("failed to read redis key"); - let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); - - Some( - fred::native_tls::TlsConnector::builder() - .identity( - native_tls::Identity::from_pkcs8(&cert, &key) - .expect("failed to parse redis cert/key"), - ) - .add_root_certificate( - native_tls::Certificate::from_pem(&ca_cert).expect("failed to parse redis ca"), - ) - .build() - .expect("failed to build redis tls") - .into(), - ) - } else { - None - }; - - RedisPool::new( - redis_config, - Some(performance), - Some(policy), - config.redis.pool_size, - ) - .expect("failed to create redis pool") + pub fn ingest_tls(&self) -> Option { + self.ingest_tls.clone() + } } diff --git a/video/transcoder/src/grpc/health.rs b/video/transcoder/src/grpc/health.rs index ebab92a9..8e5682fb 100644 --- a/video/transcoder/src/grpc/health.rs +++ b/video/transcoder/src/grpc/health.rs @@ -8,7 +8,7 @@ use async_stream::try_stream; use futures_util::Stream; use tonic::{async_trait, Request, Response, Status}; -use crate::pb::health::{ +use pb::grpc::health::v1::{ health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, }; diff --git a/video/transcoder/src/grpc/mod.rs b/video/transcoder/src/grpc/mod.rs index 9c066843..a5e05bbb 100644 --- a/video/transcoder/src/grpc/mod.rs +++ b/video/transcoder/src/grpc/mod.rs @@ -1,14 +1,11 @@ -use crate::{ - global::GlobalState, - pb::{health::health_server, scuffle::video::transcoder_server}, -}; +use crate::global::GlobalState; use anyhow::Result; +use pb::grpc::health::v1::health_server; use std::sync::Arc; use tokio::select; use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; pub mod health; -pub mod transcoder; pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); @@ -27,9 +24,6 @@ pub async fn run(global: Arc) -> Result<()> { tracing::info!("gRPC TLS disabled"); Server::builder() } - .add_service(transcoder_server::TranscoderServer::new( - transcoder::TranscoderServer::new(&global), - )) .add_service(health_server::HealthServer::new(health::HealthServer::new( &global, ))) diff --git a/video/transcoder/src/grpc/transcoder.rs b/video/transcoder/src/grpc/transcoder.rs deleted file mode 100644 index c627fb62..00000000 --- a/video/transcoder/src/grpc/transcoder.rs +++ /dev/null @@ -1,24 +0,0 @@ -#![allow(dead_code)] -// TODO: Remove this once we have a real implementation - -use crate::{global::GlobalState, pb::scuffle::video::transcoder_server}; -use std::sync::{Arc, Weak}; - -use tonic::{async_trait, Status}; - -pub struct TranscoderServer { - global: Weak, -} - -impl TranscoderServer { - pub fn new(global: &Arc) -> Self { - Self { - global: Arc::downgrade(global), - } - } -} - -type Result = std::result::Result; - -#[async_trait] -impl transcoder_server::Transcoder for TranscoderServer {} diff --git a/video/transcoder/src/main.rs b/video/transcoder/src/main.rs index 0f71374c..69740dd0 100644 --- a/video/transcoder/src/main.rs +++ b/video/transcoder/src/main.rs @@ -1,13 +1,15 @@ -use std::{sync::Arc, time::Duration}; +use std::{str::FromStr, sync::Arc, time::Duration}; -use anyhow::{Context as _, Result}; -use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use anyhow::Result; +use async_nats::ServerAddr; +use common::{context::Context, logging, signal}; +use sqlx::ConnectOptions; +use sqlx_postgres::PgConnectOptions; use tokio::{select, signal::unix::SignalKind, time}; mod config; mod global; mod grpc; -mod pb; mod transcoder; #[tokio::main] @@ -22,32 +24,66 @@ async fn main() -> Result<()> { let (ctx, handler) = Context::new(); - let rmq = common::rmq::ConnectionPool::connect( - config.rmq.uri.clone(), - lapin::ConnectionProperties::default(), - Duration::from_secs(30), - 1, - ) - .timeout(Duration::from_secs(5)) - .await - .context("failed to connect to rabbitmq, timedout")? - .context("failed to connect to rabbitmq")?; - - let redis = global::setup_redis(&config); - redis.connect(); - - redis - .wait_for_connect() - .timeout(Duration::from_secs(2)) - .await - .expect("failed to connect to redis") - .expect("failed to connect to redis"); - tracing::info!("connected to redis"); - - let global = Arc::new(global::GlobalState::new(config, ctx, rmq, redis)); - - global::init_rmq(&global, true).await; - tracing::info!("initialized rmq"); + let nats = { + let mut options = async_nats::ConnectOptions::new() + .connection_timeout(Duration::from_secs(5)) + .name(&config.name) + .retry_on_initial_connect(); + + if let Some(user) = &config.nats.username { + options = options.user_and_password( + user.clone(), + config.nats.password.clone().unwrap_or_default(), + ) + } else if let Some(token) = &config.nats.token { + options = options.token(token.clone()) + } + + if let Some(tls) = &config.nats.tls { + options = options + .require_tls(true) + .add_root_certificates((&tls.ca_cert).into()) + .add_client_certificate((&tls.cert).into(), (&tls.key).into()); + } + + options + .connect( + config + .nats + .servers + .iter() + .map(|s| s.parse::()) + .collect::, _>>()?, + ) + .await? + }; + + let db = Arc::new( + sqlx::PgPool::connect_with( + PgConnectOptions::from_str(&config.database.uri)? + .disable_statement_logging() + .to_owned(), + ) + .await?, + ); + + let jetstream = async_nats::jetstream::new(nats.clone()); + + let metadata_store = jetstream + .get_key_value(config.transcoder.metadata_kv_store.clone()) + .await?; + let media_store = jetstream + .get_object_store(config.transcoder.media_ob_store.clone()) + .await?; + + let global = Arc::new(global::GlobalState::new( + config, + ctx, + nats, + db, + metadata_store, + media_store, + )); let transcoder_future = tokio::spawn(transcoder::run(global.clone())); let grpc_future = tokio::spawn(grpc::run(global.clone())); @@ -60,7 +96,6 @@ async fn main() -> Result<()> { select! { r = transcoder_future => tracing::error!("transcoder stopped unexpectedly: {:?}", r), r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), - r = global.rmq.handle_reconnects() => tracing::error!("rabbitmq stopped unexpectedly: {:?}", r), _ = signal_handler.recv() => tracing::info!("shutting down"), } diff --git a/video/transcoder/src/pb.rs b/video/transcoder/src/pb.rs deleted file mode 100644 index 4f4bd310..00000000 --- a/video/transcoder/src/pb.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(clippy::match_single_binding)] - -pub mod scuffle { - pub mod backend { - tonic::include_proto!("scuffle.backend"); - } - - pub mod types { - tonic::include_proto!("scuffle.types"); - } - - pub mod video { - tonic::include_proto!("scuffle.video"); - } - - pub mod events { - tonic::include_proto!("scuffle.events"); - } -} - -pub mod health { - tonic::include_proto!("grpc.health.v1"); -} diff --git a/video/transcoder/src/tests/global.rs b/video/transcoder/src/tests/global.rs index 11e1b6c9..d8aef3b3 100644 --- a/video/transcoder/src/tests/global.rs +++ b/video/transcoder/src/tests/global.rs @@ -5,8 +5,6 @@ use common::{ logging, prelude::FutureTimeout, }; -use fred::pool::RedisPool; -use tokio::select; use crate::{config::AppConfig, global::GlobalState}; @@ -18,45 +16,19 @@ pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) logging::init(&config.logging.level, config.logging.mode) .expect("failed to initialize logging"); - let rmq = common::rmq::ConnectionPool::connect( - std::env::var("RMQ_URL").expect("RMQ_URL not set"), - lapin::ConnectionProperties::default(), - Duration::from_secs(30), - 1, - ) - .timeout(Duration::from_secs(5)) - .await - .expect("failed to connect to rabbitmq") - .expect("failed to connect to rabbitmq"); - - let redis = RedisPool::new( - fred::types::RedisConfig::from_url( - std::env::var("REDIS_URL") - .expect("REDIS_URL not set") - .as_str(), - ) - .expect("failed to parse redis url"), - Some(Default::default()), - Some(Default::default()), - 2, - ) - .expect("failed to create redis pool"); - - redis.connect(); - redis - .wait_for_connect() - .await - .expect("failed to connect to redis"); + let db = Arc::new( + sqlx::PgPool::connect(&std::env::var("DATABASE_URL").expect("DATABASE_URL not set")) + .await + .expect("failed to connect to database"), + ); - let global = Arc::new(GlobalState::new(config, ctx, rmq, redis)); + let nats = async_nats::connect(std::env::var("NATS_URL").expect("NATS_URL not set")) + .timeout(Duration::from_secs(5)) + .await + .expect("failed to connect to nats") + .expect("failed to connect to nats"); - let global2 = global.clone(); - tokio::spawn(async move { - select! { - _ = global2.rmq.handle_reconnects() => {}, - _ = global2.ctx.done() => {}, - } - }); + let global = Arc::new(GlobalState::new(config, ctx, nats, db)); (global, handler) } diff --git a/video/transcoder/src/tests/grpc/health.rs b/video/transcoder/src/tests/grpc/health.rs index a174980e..5b0544ce 100644 --- a/video/transcoder/src/tests/grpc/health.rs +++ b/video/transcoder/src/tests/grpc/health.rs @@ -28,14 +28,14 @@ async fn test_grpc_health_check() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -71,10 +71,10 @@ async fn test_grpc_health_watch() { ) .unwrap(); - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .watch(crate::pb::health::HealthCheckRequest::default()) + .watch(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); @@ -82,7 +82,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); let cancel = handler.cancel(); @@ -90,7 +90,7 @@ async fn test_grpc_health_watch() { let resp = stream.message().await.unwrap().unwrap(); assert_eq!( resp.status, - crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::NotServing as i32 ); cancel diff --git a/video/transcoder/src/tests/grpc/tls.rs b/video/transcoder/src/tests/grpc/tls.rs index b7eb9f83..e6ce6d8f 100644 --- a/video/transcoder/src/tests/grpc/tls.rs +++ b/video/transcoder/src/tests/grpc/tls.rs @@ -38,7 +38,7 @@ async fn test_grpc_tls_rsa() { vec![format!("https://localhost:{}", port)], Duration::from_secs(0), Some(TlsSettings { - domain: "localhost".to_string(), + domain: Some("localhost".to_string()), ca_cert: ca_content, identity: client_identity, }), @@ -56,15 +56,15 @@ async fn test_grpc_tls_rsa() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() @@ -108,7 +108,7 @@ async fn test_grpc_tls_ec() { vec![format!("https://localhost:{}", port)], Duration::from_secs(0), Some(TlsSettings { - domain: "localhost".to_string(), + domain: Some("localhost".to_string()), ca_cert: ca_content, identity: client_identity, }), @@ -126,15 +126,15 @@ async fn test_grpc_tls_ec() { tokio::time::sleep(Duration::from_millis(500)).await; - let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let mut client = pb::grpc::health::v1::health_client::HealthClient::new(channel); let resp = client - .check(crate::pb::health::HealthCheckRequest::default()) + .check(pb::grpc::health::v1::HealthCheckRequest::default()) .await .unwrap(); assert_eq!( resp.into_inner().status, - crate::pb::health::health_check_response::ServingStatus::Serving as i32 + pb::grpc::health::v1::health_check_response::ServingStatus::Serving as i32 ); handler .cancel() diff --git a/video/transcoder/src/tests/transcoder/mod.rs b/video/transcoder/src/tests/transcoder/mod.rs index 8a5726cd..045ee6d9 100644 --- a/video/transcoder/src/tests/transcoder/mod.rs +++ b/video/transcoder/src/tests/transcoder/mod.rs @@ -1,114 +1,63 @@ -// TODO: This is the test stub for the transcoder service. It is not yet implemented. -#![allow(unused_imports)] -#![allow(dead_code)] - -use core::panic; use std::{ - collections::HashMap, io::Cursor, net::SocketAddr, path::PathBuf, pin::Pin, sync::Arc, + io::{Cursor, Write}, + net::SocketAddr, + path::PathBuf, + pin::Pin, + process::Stdio, + sync::Arc, time::Duration, }; use async_trait::async_trait; -use bytes::{Buf, Bytes}; -use chrono::Utc; +use bytes::Bytes; use common::{config::LoggingConfig, logging, prelude::FutureTimeout}; -use fred::prelude::{HashesInterface, KeysInterface}; use futures_util::Stream; -use lapin::BasicProperties; -use mp4::DynBox; +use pb::scuffle::video::{ + internal::{ + events::{organization_event, OrganizationEvent, TranscoderRequest}, + ingest_server::{Ingest, IngestServer}, + ingest_watch_request, ingest_watch_response, IngestWatchRequest, IngestWatchResponse, + LiveRenditionManifest, + }, + v1::types::{AudioConfig, RenditionAudio, RenditionVideo, VideoConfig}, +}; use prost::Message; -use tokio::sync::{mpsc, oneshot}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{Request, Response}; -use transmuxer::{MediaType, TransmuxResult, Transmuxer}; +use tokio::{process::Command, sync::mpsc}; +use tokio_stream::{wrappers::ReceiverStream, StreamExt}; +use tonic::Response; +use transmuxer::{TransmuxResult, Transmuxer}; use uuid::Uuid; +use video_database::{adapter::TraitAdapterVec, room::Room, room_status::RoomStatus}; use crate::{ config::{AppConfig, TranscoderConfig}, - global::{self, GlobalState}, - pb::scuffle::{ - events::{self, transcoder_message}, - types::{stream_state, StreamState}, - video::{ - ingest_server::{Ingest, IngestServer}, - transcoder_event_request, watch_stream_response, ShutdownStreamRequest, - ShutdownStreamResponse, TranscoderEventRequest, TranscoderEventResponse, - WatchStreamRequest, WatchStreamResponse, - }, - }, - transcoder::{ - self, - job::variant::state::{PlaylistState, SegmentState}, - }, + global::GlobalState, + transcoder, }; +type IngestRequest = ( + mpsc::Sender>, + tonic::Streaming, +); + struct ImplIngestServer { tx: mpsc::Sender, } -#[derive(Debug)] -enum IngestRequest { - WatchStream { - request: WatchStreamRequest, - tx: mpsc::Sender>, - }, - TranscoderEvent { - request: TranscoderEventRequest, - tx: oneshot::Sender, - }, - Shutdown { - request: ShutdownStreamRequest, - tx: oneshot::Sender, - }, -} - type Result = std::result::Result; #[async_trait] impl Ingest for ImplIngestServer { - type WatchStreamStream = - Pin> + 'static + Send>>; + type WatchStream = Pin> + 'static + Send>>; - async fn watch_stream( + async fn watch( &self, - request: tonic::Request, - ) -> Result> { - let (tx, rx) = mpsc::channel(256); - let request = IngestRequest::WatchStream { - request: request.into_inner(), - tx, - }; - self.tx.send(request).await.unwrap(); + request: tonic::Request>, + ) -> Result> { + let (tx, rx) = mpsc::channel(16); + self.tx.send((tx, request.into_inner())).await.unwrap(); Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) } - - async fn transcoder_event( - &self, - request: Request, - ) -> Result> { - let (tx, rx) = oneshot::channel(); - let request = IngestRequest::TranscoderEvent { - request: request.into_inner(), - tx, - }; - - self.tx.send(request).await.unwrap(); - Ok(Response::new(rx.await.unwrap())) - } - - async fn shutdown_stream( - &self, - request: Request, - ) -> Result> { - let (tx, rx) = oneshot::channel(); - let request = IngestRequest::Shutdown { - request: request.into_inner(), - tx, - }; - - self.tx.send(request).await.unwrap(); - Ok(Response::new(rx.await.unwrap())) - } } fn setup_ingest_server( @@ -138,7 +87,9 @@ async fn test_transcode() { let (global, handler) = crate::tests::global::mock_global_state(AppConfig { transcoder: TranscoderConfig { - rmq_queue: Uuid::new_v4().to_string(), + events_subject: Uuid::new_v4().to_string(), + transcoder_request_subject: Uuid::new_v4().to_string(), + kv_bucket: Uuid::new_v4().to_string(), ..Default::default() }, logging: LoggingConfig { @@ -149,7 +100,30 @@ async fn test_transcode() { }) .await; - global::init_rmq(&global, true).await; + global + .jetstream + .create_stream(async_nats::jetstream::stream::Config { + name: global.config.transcoder.transcoder_request_subject.clone(), + ..Default::default() + }) + .await + .unwrap(); + + let kv = global + .jetstream + .create_key_value(async_nats::jetstream::kv::Config { + bucket: global.config.transcoder.kv_bucket.clone(), + max_age: Duration::from_secs(60), + ..Default::default() + }) + .await + .unwrap(); + + let mut event_stream = global + .nats + .subscribe(global.config.transcoder.events_subject.clone()) + .await + .unwrap(); let addr = SocketAddr::from(([127, 0, 0, 1], port)); @@ -157,169 +131,105 @@ async fn test_transcode() { let transcoder_run_handle = tokio::spawn(transcoder::run(global.clone())); - let channel = global.rmq.aquire().await.unwrap(); - let req_id = Uuid::new_v4(); - let source_video_id = Uuid::new_v4(); - let aac_audio_id = Uuid::new_v4(); - let opus_audio_id = Uuid::new_v4(); - let video_id_360p = Uuid::new_v4(); - - channel - .basic_publish( - "", - &global.config.transcoder.rmq_queue, - lapin::options::BasicPublishOptions::default(), - events::TranscoderMessage { - id: req_id.to_string(), - timestamp: Utc::now().timestamp() as u64, - data: Some(transcoder_message::Data::NewStream( - events::TranscoderMessageNewStream { - request_id: req_id.to_string(), - stream_id: req_id.to_string(), - ingest_address: addr.to_string(), - state: Some(StreamState { - transcodes: vec![ - stream_state::Transcode { - bitrate: 1000, - codec: "avc1.64002a".to_string(), - id: source_video_id.to_string(), - copy: true, - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 30, - height: 1080, - width: 1920, - }, - )), - }, - stream_state::Transcode { - bitrate: 1024 * 1024, - codec: "avc1.64002a".to_string(), - id: video_id_360p.to_string(), - copy: false, - settings: Some(stream_state::transcode::Settings::Video( - stream_state::transcode::VideoSettings { - framerate: 30, - height: 360, - width: 640, - }, - )), - }, - stream_state::Transcode { - bitrate: 96 * 1024, - codec: "opus".to_string(), - id: opus_audio_id.to_string(), - copy: false, - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - }, - stream_state::Transcode { - bitrate: 96 * 1024, - codec: "mp4a.40.2".to_string(), - id: aac_audio_id.to_string(), - copy: false, - settings: Some(stream_state::transcode::Settings::Audio( - stream_state::transcode::AudioSettings { - channels: 2, - sample_rate: 48000, - }, - )), - }, - ], - variants: vec![ - stream_state::Variant { - name: "source".to_string(), - group: "aac".to_string(), - transcode_ids: vec![ - source_video_id.to_string(), - aac_audio_id.to_string(), - ], - }, - stream_state::Variant { - name: "source".to_string(), - group: "opus".to_string(), - transcode_ids: vec![ - source_video_id.to_string(), - opus_audio_id.to_string(), - ], - }, - stream_state::Variant { - name: "360p".to_string(), - group: "aac".to_string(), - transcode_ids: vec![ - video_id_360p.to_string(), - aac_audio_id.to_string(), - ], - }, - stream_state::Variant { - name: "360p".to_string(), - group: "opus".to_string(), - transcode_ids: vec![ - video_id_360p.to_string(), - opus_audio_id.to_string(), - ], - }, - stream_state::Variant { - name: "audio-only".to_string(), - group: "aac".to_string(), - transcode_ids: vec![aac_audio_id.to_string()], - }, - stream_state::Variant { - name: "audio-only".to_string(), - group: "opus".to_string(), - transcode_ids: vec![opus_audio_id.to_string()], - }, - ], - groups: vec![ - stream_state::Group { - name: "opus".to_string(), - priority: 1, - }, - stream_state::Group { - name: "aac".to_string(), - priority: 2, - }, - ], - }), - }, - )), + let room_name = Uuid::new_v4().simple().to_string(); + let org_id = Uuid::new_v4(); + let connection_id = Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO organization ( + id, + name + ) VALUES ( + $1, + $2 + )"#, + ) + .bind(org_id) + .bind(&room_name) + .execute(global.db.as_ref()) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO room ( + organization_id, + name, + active_ingest_connection_id, + stream_key, + video_input, + audio_input + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 + )"#, + ) + .bind(org_id) + .bind(&room_name) + .bind(connection_id) + .bind(&room_name) + .bind( + VideoConfig { + bitrate: 7358 * 1024, + codec: "avc1.64002a".to_string(), + fps: 60, + height: 2160, + width: 3840, + rendition: RenditionVideo::SourceVideo.into(), + } + .encode_to_vec(), + ) + .bind( + AudioConfig { + bitrate: 130 * 1024, + codec: "mp4a.40.2".to_string(), + channels: 2, + sample_rate: 48000, + rendition: RenditionAudio::SourceAudio.into(), + } + .encode_to_vec(), + ) + .execute(global.db.as_ref()) + .await + .unwrap(); + + global + .nats + .publish( + global.config.transcoder.transcoder_request_subject.clone(), + TranscoderRequest { + room_name: room_name.clone(), + organization_id: org_id.to_string(), + request_id: req_id.to_string(), + connection_id: connection_id.to_string(), + grpc_endpoint: format!("localhost:{}", port), } .encode_to_vec() - .as_slice(), - BasicProperties::default() - .with_message_id(req_id.to_string().into()) - .with_content_type("application/octet-stream".into()) - .with_expiration("60000".into()), + .into(), ) .await .unwrap(); - let watch_stream_req = match rx + let (sender, receiver) = rx .recv() .timeout(Duration::from_secs(2)) .await .unwrap() - .unwrap() - { - IngestRequest::WatchStream { request, tx } => { - assert_eq!(request.stream_id, req_id.to_string()); - assert_eq!(request.request_id, req_id.to_string()); - - tx - } - _ => panic!("unexpected request"), - }; + .unwrap(); // This is now a stream we can write frames to. // We now need to demux the video into fragmnts to send to the transcoder. - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); - let data = std::fs::read(dir.join("avc_aac.flv").to_str().unwrap()).unwrap(); + let flv_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../assets") + .join("avc_aac.flv"); + let data = std::fs::read(&flv_path).unwrap(); let mut cursor = Cursor::new(Bytes::from(data)); let mut transmuxer = Transmuxer::new(); @@ -334,29 +244,40 @@ async fn test_transcode() { if let Some(data) = transmuxer.mux().unwrap() { match data { TransmuxResult::InitSegment { data, .. } => { - watch_stream_req - .send(Ok(WatchStreamResponse { - data: Some(watch_stream_response::Data::InitSegment(data)), + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data, + keyframe: false, + r#type: ingest_watch_response::media::Type::Init.into(), + }, + )), + })) + .await + .unwrap(); + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), })) .await .unwrap(); } TransmuxResult::MediaSegment(ms) => { - watch_stream_req - .send(Ok(WatchStreamResponse { - data: Some(watch_stream_response::Data::MediaSegment( - watch_stream_response::MediaSegment { - timestamp: ms.timestamp, + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { data: ms.data, keyframe: ms.keyframe, - data_type: match ms.ty { - MediaType::Audio => { - watch_stream_response::media_segment::DataType::Audio - as i32 + r#type: match ms.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio.into() } - MediaType::Video => { - watch_stream_response::media_segment::DataType::Video - as i32 + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video.into() } }, }, @@ -369,41 +290,292 @@ async fn test_transcode() { } } - match rx - .recv() - .timeout(Duration::from_secs(2)) + { + let event = OrganizationEvent::decode(event_stream.next().await.unwrap().payload).unwrap(); + assert_eq!(event.id, org_id.to_string()); + assert!(event.timestamp > 0); + match event.event { + Some(organization_event::Event::RoomReady(r)) => { + assert_eq!(r.room_name, room_name); + assert_eq!(r.connection_id, connection_id.to_string()); + } + _ => panic!("unexpected event"), + }; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) .await .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await .unwrap() - { - IngestRequest::TranscoderEvent { request, tx } => { - assert_eq!(request.stream_id, req_id.to_string()); - assert_eq!(request.request_id, req_id.to_string()); + .unwrap(), + ) + .unwrap(); - assert_eq!( - request.event, - Some(transcoder_event_request::Event::Started(true)) - ); + assert_eq!(video_manifest.parts.len(), 3); + assert!(video_manifest.parts.iter().skip(1).all(|p| !p.independent)); + assert!(video_manifest.parts[0].independent); + assert!(!video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 3); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 1 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 3); - tx.send(TranscoderEventResponse {}).unwrap(); - } - _ => panic!("unexpected request"), - }; + assert_eq!(audio_manifest.parts.len(), 3); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(!audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 3); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 1 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 3); tracing::debug!("finished sending frames"); - watch_stream_req - .send(Ok(WatchStreamResponse { - data: Some(watch_stream_response::Data::ShuttingDown(true)), + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Stream.into(), + )), })) .await .unwrap(); - drop(watch_stream_req); + drop(sender); + drop(receiver); tokio::time::sleep(Duration::from_millis(250)).await; - let redis = global.redis.clone(); + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + + assert_eq!(video_manifest.parts.len(), 4); + assert!(video_manifest.parts.iter().skip(1).all(|p| !p.independent)); + assert!(video_manifest.parts[0].independent); + assert!(video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 4); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 1 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 4); + assert_eq!(video_manifest.total_duration, 59000); // verified with ffprobe + + assert_eq!(audio_manifest.parts.len(), 4); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 4); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 1 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 4); + assert_eq!(audio_manifest.total_duration, 48128); // verified with ffprobe + + let mut video_parts = vec![kv + .get(format!( + "{}.{}.{}.video_source.init", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap()]; + let mut audio_parts = vec![kv + .get(format!( + "{}.{}.{}.audio_source.init", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap()]; + + for i in 1..=3 { + video_parts.push( + kv.get(format!( + "{}.{}.{}.video_source.{}", + org_id, &room_name, connection_id, i + )) + .await + .unwrap() + .unwrap(), + ); + audio_parts.push( + kv.get(format!( + "{}.{}.{}.audio_source.{}", + org_id, &room_name, connection_id, i + )) + .await + .unwrap() + .unwrap(), + ); + } + + let mut tmp_file = tempfile::NamedTempFile::new().unwrap(); + tmp_file.write_all(&video_parts.concat()).unwrap(); + + let command = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg(tmp_file.path().to_str().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + let output = command.wait_with_output().await.unwrap(); + let json = serde_json::from_slice::(&output.stdout).unwrap(); + + println!("{:#?}", json); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["tags"]["major_brand"], "iso5"); + assert_eq!(json["format"]["tags"]["minor_version"], "512"); + assert_eq!(json["format"]["tags"]["compatible_brands"], "iso5iso6mp41"); + + assert_eq!(json["streams"][0]["codec_name"], "h264"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["width"], 3840); + assert_eq!(json["streams"][0]["height"], 2160); + assert_eq!(json["streams"][0]["r_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["avg_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["duration_ts"], 59000); + assert_eq!(json["streams"][0]["time_base"], "1/60000"); + assert_eq!(json["streams"][0]["duration"], "0.983333"); + + let mut tmp_file = tempfile::NamedTempFile::new().unwrap(); + tmp_file.write_all(&audio_parts.concat()).unwrap(); + + let command = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000000") + .arg("-probesize") + .arg("20000000") + .arg("-analyzeduration") + .arg("20000000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg(tmp_file.path().to_str().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + let output = command.wait_with_output().await.unwrap(); + let json = serde_json::from_slice::(&output.stdout).unwrap(); + + println!("{:#?}", json); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["tags"]["major_brand"], "iso5"); + assert_eq!(json["format"]["tags"]["minor_version"], "512"); + assert_eq!(json["format"]["tags"]["compatible_brands"], "iso5iso6mp41"); + + assert_eq!(json["streams"][0]["codec_name"], "aac"); + assert_eq!(json["streams"][0]["codec_type"], "audio"); + assert_eq!(json["streams"][0]["sample_rate"], "48000"); + assert_eq!(json["streams"][0]["channels"], 2); + assert_eq!(json["streams"][0]["duration_ts"], 48128); + assert_eq!(json["streams"][0]["time_base"], "1/48000"); + + let room: Room = sqlx::query_as("SELECT * FROM room WHERE organization_id = $1 AND name = $2 AND active_ingest_connection_id = $3") + .bind(org_id) + .bind(&room_name) + .bind(connection_id) + .fetch_one(global.db.as_ref()) + .await + .unwrap(); + + let active_transcoding_config = room.active_transcoding_config.unwrap().0; + assert!(room.active_recording_config.is_none()); + let video_output = room.video_output.unwrap().into_vec(); + let audio_output = room.audio_output.unwrap().into_vec(); + + assert_eq!( + active_transcoding_config.audio_renditions, + vec![RenditionAudio::SourceAudio as i32] + ); + assert_eq!( + active_transcoding_config.video_renditions, + vec![RenditionVideo::SourceVideo as i32] + ); + assert_eq!(active_transcoding_config.name, ""); + assert_eq!(active_transcoding_config.created_at, 0); + + assert_eq!(video_output.len(), 1); + assert_eq!(audio_output.len(), 1); + + assert_eq!( + video_output[0].rendition, + RenditionVideo::SourceVideo as i32 + ); + assert_eq!(video_output[0].codec, "avc1.64002a"); + assert_eq!(video_output[0].bitrate, 7358 * 1024); + assert_eq!(video_output[0].fps, 60); + assert_eq!(video_output[0].height, 2160); + assert_eq!(video_output[0].width, 3840); + + assert_eq!( + audio_output[0].rendition, + RenditionAudio::SourceAudio as i32 + ); + assert_eq!(audio_output[0].codec, "mp4a.40.2"); + assert_eq!(audio_output[0].bitrate, 130 * 1024); + assert_eq!(audio_output[0].channels, 2); + assert_eq!(audio_output[0].sample_rate, 48000); + + assert_eq!(room.status, RoomStatus::Ready); + drop(global); handler .cancel() @@ -417,138 +589,883 @@ async fn test_transcode() { .unwrap() .unwrap(); - // Validate data - let resp: String = redis - .get(format!("transcoder:{}:playlist", req_id)) - .await - .unwrap(); + tracing::info!("done"); +} - // Assert that the master playlist is correct. - assert_eq!( - resp, - format!( - r#"#EXTM3U -#EXT-X-MEDIA:TYPE=VIDEO,AUTOSELECT=YES,DEFAULT=YES,GROUP-ID="{source_video_id}",NAME="{source_video_id}",BANDWIDTH=1000,CODECS="avc1.64002a",URI="{source_video_id}/index.m3u8",FRAME-RATE=30,RESOLUTION=1920x1080 -#EXT-X-MEDIA:TYPE=VIDEO,AUTOSELECT=YES,DEFAULT=YES,GROUP-ID="{video_id_360p}",NAME="{video_id_360p}",BANDWIDTH=1048576,CODECS="avc1.64002a",URI="{video_id_360p}/index.m3u8",FRAME-RATE=30,RESOLUTION=640x360 -#EXT-X-MEDIA:TYPE=AUDIO,AUTOSELECT=YES,DEFAULT=YES,GROUP-ID="{opus_audio_id}",NAME="{opus_audio_id}",BANDWIDTH=98304,CODECS="opus",URI="{opus_audio_id}/index.m3u8" -#EXT-X-MEDIA:TYPE=AUDIO,AUTOSELECT=YES,DEFAULT=YES,GROUP-ID="{aac_audio_id}",NAME="{aac_audio_id}",BANDWIDTH=98304,CODECS="mp4a.40.2",URI="{aac_audio_id}/index.m3u8" -#EXT-X-STREAM-INF:GROUP="aac",NAME="source",BANDWIDTH=99304,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=30,VIDEO="{source_video_id}",AUDIO="{aac_audio_id}" -{source_video_id}/index.m3u8 -#EXT-X-STREAM-INF:GROUP="opus",NAME="source",BANDWIDTH=99304,CODECS="avc1.64002a,opus",RESOLUTION=1920x1080,FRAME-RATE=30,VIDEO="{source_video_id}",AUDIO="{opus_audio_id}" -{source_video_id}/index.m3u8 -#EXT-X-STREAM-INF:GROUP="aac",NAME="360p",BANDWIDTH=1146880,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30,VIDEO="{video_id_360p}",AUDIO="{aac_audio_id}" -{video_id_360p}/index.m3u8 -#EXT-X-STREAM-INF:GROUP="opus",NAME="360p",BANDWIDTH=1146880,CODECS="avc1.64002a,opus",RESOLUTION=640x360,FRAME-RATE=30,VIDEO="{video_id_360p}",AUDIO="{opus_audio_id}" -{video_id_360p}/index.m3u8 -#EXT-X-STREAM-INF:GROUP="aac",NAME="audio-only",BANDWIDTH=98304,CODECS="mp4a.40.2",AUDIO="{aac_audio_id}" -{aac_audio_id}/index.m3u8 -#EXT-X-STREAM-INF:GROUP="opus",NAME="audio-only",BANDWIDTH=98304,CODECS="opus",AUDIO="{opus_audio_id}" -{opus_audio_id}/index.m3u8 -#EXT-X-SCUF-GROUP:GROUP="opus",PRIORITY=1 -#EXT-X-SCUF-GROUP:GROUP="aac",PRIORITY=2 -"# - ) - ); +#[tokio::test] +async fn test_transcode_reconnect() { + let port = portpicker::pick_unused_port().unwrap(); - let source_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:state", req_id, source_video_id)) - .await - .unwrap(); - let source_state = PlaylistState::from(source_state); - - assert_eq!(source_state.current_fragment_idx(), 0); - assert_eq!(source_state.current_segment_idx(), 1); - assert_eq!(source_state.discontinuity_sequence(), 0); - assert_eq!(source_state.track_count(), 1); - assert_eq!(source_state.track_duration(0), Some(59000)); - assert_eq!(source_state.track_timescale(0), Some(60000)); - assert_eq!(source_state.longest_segment(), 59000.0 / 60000.0); - - let video_360p_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:state", req_id, video_id_360p)) + let (global, handler) = crate::tests::global::mock_global_state(AppConfig { + transcoder: TranscoderConfig { + events_subject: Uuid::new_v4().to_string(), + transcoder_request_subject: Uuid::new_v4().to_string(), + kv_bucket: Uuid::new_v4().to_string(), + ..Default::default() + }, + logging: LoggingConfig { + level: "info,transcoder=debug".to_string(), + mode: logging::Mode::Default, + }, + ..Default::default() + }) + .await; + + global + .jetstream + .create_stream(async_nats::jetstream::stream::Config { + name: global.config.transcoder.transcoder_request_subject.clone(), + ..Default::default() + }) .await .unwrap(); - let video_360p_state = PlaylistState::from(video_360p_state); - - assert_eq!(video_360p_state.current_fragment_idx(), 0); - assert_eq!(video_360p_state.current_segment_idx(), 1); - assert_eq!(video_360p_state.discontinuity_sequence(), 0); - assert_eq!(video_360p_state.track_count(), 1); - assert_eq!(video_360p_state.track_duration(0), Some(15872)); - assert_eq!(video_360p_state.track_timescale(0), Some(15360)); - assert_eq!(video_360p_state.longest_segment(), 15872.0 / 15360.0); - - let opus_audio_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:state", req_id, opus_audio_id)) + + let kv = global + .jetstream + .create_key_value(async_nats::jetstream::kv::Config { + bucket: global.config.transcoder.kv_bucket.clone(), + max_age: Duration::from_secs(60), + ..Default::default() + }) .await .unwrap(); - let opus_audio_state = PlaylistState::from(opus_audio_state); - - assert_eq!(opus_audio_state.current_fragment_idx(), 0); - assert_eq!(opus_audio_state.current_segment_idx(), 1); - assert_eq!(opus_audio_state.discontinuity_sequence(), 0); - assert_eq!(opus_audio_state.track_count(), 1); - assert_eq!(opus_audio_state.track_duration(0), Some(48440)); - assert_eq!(opus_audio_state.track_timescale(0), Some(48000)); - assert_eq!(opus_audio_state.longest_segment(), 48440.0 / 48000.0); - - let aac_audio_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:state", req_id, aac_audio_id)) + + let mut event_stream = global + .nats + .subscribe(global.config.transcoder.events_subject.clone()) .await .unwrap(); - let aac_audio_state = PlaylistState::from(aac_audio_state); - assert_eq!(aac_audio_state.current_fragment_idx(), 0); - assert_eq!(aac_audio_state.current_segment_idx(), 1); - assert_eq!(aac_audio_state.discontinuity_sequence(), 0); - assert_eq!(aac_audio_state.track_count(), 1); - assert_eq!(aac_audio_state.track_duration(0), Some(49152)); - assert_eq!(aac_audio_state.track_timescale(0), Some(48000)); - assert_eq!(aac_audio_state.longest_segment(), 49152.0 / 48000.0); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + let mut rx = setup_ingest_server(global.clone(), addr); + + let transcoder_run_handle = tokio::spawn(transcoder::run(global.clone())); + + let req_id = Uuid::new_v4(); + + let room_name = Uuid::new_v4().simple().to_string(); + let org_id = Uuid::new_v4(); + let connection_id = Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO organization ( + id, + name + ) VALUES ( + $1, + $2 + )"#, + ) + .bind(org_id) + .bind(&room_name) + .execute(global.db.as_ref()) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO room ( + organization_id, + name, + active_ingest_connection_id, + stream_key, + video_input, + audio_input + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 + )"#, + ) + .bind(org_id) + .bind(&room_name) + .bind(connection_id) + .bind(&room_name) + .bind( + VideoConfig { + bitrate: 7358 * 1024, + codec: "avc1.64002a".to_string(), + fps: 60, + height: 3840, + width: 2160, + rendition: RenditionVideo::SourceVideo.into(), + } + .encode_to_vec(), + ) + .bind( + AudioConfig { + bitrate: 130 * 1024, + codec: "mp4a.40.2".to_string(), + channels: 2, + sample_rate: 48000, + rendition: RenditionAudio::SourceAudio.into(), + } + .encode_to_vec(), + ) + .execute(global.db.as_ref()) + .await + .unwrap(); + + // This is now a stream we can write frames to. + // We now need to demux the video into fragmnts to send to the transcoder. + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let data = std::fs::read(dir.join("avc_aac.flv").to_str().unwrap()).unwrap(); + + let mut cursor = Cursor::new(Bytes::from(data)); + let mut transmuxer = Transmuxer::new(); + + let flv = flv::Flv::demux(&mut cursor).unwrap(); + + flv.tags.into_iter().for_each(|t| { + transmuxer.add_tag(t); + }); + + let mut packets = vec![]; + while let Some(packet) = transmuxer.mux().unwrap() { + packets.push(packet); + } { - let segment_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:0:state", req_id, source_video_id)) + global + .nats + .publish( + global.config.transcoder.transcoder_request_subject.clone(), + TranscoderRequest { + room_name: room_name.clone(), + organization_id: org_id.to_string(), + request_id: req_id.to_string(), + connection_id: connection_id.to_string(), + grpc_endpoint: format!("localhost:{}", port), + } + .encode_to_vec() + .into(), + ) + .await + .unwrap(); + + let (sender, mut receiver) = rx + .recv() + .timeout(Duration::from_secs(2)) + .await + .unwrap() + .unwrap(); + + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Open(ingest_watch_request::Open { + request_id: req_id.to_string(), + }) + ); + + for packet in &packets { + match packet { + TransmuxResult::InitSegment { data, .. } => { + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: data.clone(), + keyframe: false, + r#type: ingest_watch_response::media::Type::Init.into(), + }, + )), + })) + .await + .unwrap(); + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), + })) + .await + .unwrap(); + } + TransmuxResult::MediaSegment(ms) => { + tokio::time::sleep(Duration::from_millis(10)).await; + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: ms.data.clone(), + keyframe: ms.keyframe, + r#type: match ms.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio.into() + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video.into() + } + }, + }, + )), + })) + .await + .unwrap(); + } + } + } + + { + let event = + OrganizationEvent::decode(event_stream.next().await.unwrap().payload).unwrap(); + assert_eq!(event.id, org_id.to_string()); + assert!(event.timestamp > 0); + match event.event { + Some(organization_event::Event::RoomReady(r)) => { + assert_eq!(r.room_name, room_name); + assert_eq!(r.connection_id, connection_id.to_string()); + } + _ => panic!("unexpected event"), + }; + } + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Transcoder.into(), + )), + })) .await .unwrap(); - let segment_state = SegmentState::from(segment_state); + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete.into() + ) + ); + + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + + assert_eq!(video_manifest.parts.len(), 4); + assert!(video_manifest.parts.iter().skip(1).all(|p| !p.independent)); + assert!(video_manifest.parts[0].independent); + assert!(!video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 4); + assert_eq!( + video_manifest.info.as_ref().unwrap().next_segment_part_idx, + 4 + ); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 1 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 4); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_part_idx, + 4 + ); + assert_eq!(video_manifest.total_duration, 59000); // verified with ffprobe - assert_eq!(segment_state.fragments().len(), 4); - assert!(segment_state.fragments()[0].keyframe); + assert_eq!(audio_manifest.parts.len(), 4); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(!audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 4); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 1 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 4); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_part_idx, + 4 + ); + assert_eq!(audio_manifest.total_duration, 48128); // verified with ffprobe } { - let segment_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:0:state", req_id, aac_audio_id)) + let new_req_id = Uuid::new_v4(); + + global + .nats + .publish( + global.config.transcoder.transcoder_request_subject.clone(), + TranscoderRequest { + room_name: room_name.clone(), + organization_id: org_id.to_string(), + request_id: new_req_id.to_string(), + connection_id: connection_id.to_string(), + grpc_endpoint: format!("localhost:{}", port), + } + .encode_to_vec() + .into(), + ) + .await + .unwrap(); + + let (sender, mut receiver) = rx + .recv() + .timeout(Duration::from_secs(2)) + .await + .unwrap() + .unwrap(); + + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Open(ingest_watch_request::Open { + request_id: new_req_id.to_string(), + }) + ); + + for packet in &packets { + match packet { + TransmuxResult::InitSegment { data, .. } => { + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: data.clone(), + keyframe: false, + r#type: ingest_watch_response::media::Type::Init.into(), + }, + )), + })) + .await + .unwrap(); + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), + })) + .await + .unwrap(); + } + TransmuxResult::MediaSegment(ms) => { + tokio::time::sleep(Duration::from_millis(10)).await; + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: ms.data.clone(), + keyframe: ms.keyframe, + r#type: match ms.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio.into() + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video.into() + } + }, + }, + )), + })) + .await + .unwrap(); + } + } + } + + { + let event = + OrganizationEvent::decode(event_stream.next().await.unwrap().payload).unwrap(); + assert_eq!(event.id, org_id.to_string()); + assert!(event.timestamp > 0); + match event.event { + Some(organization_event::Event::RoomReady(r)) => { + assert_eq!(r.room_name, room_name); + assert_eq!(r.connection_id, connection_id.to_string()); + } + _ => panic!("unexpected event"), + }; + } + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Transcoder.into(), + )), + })) .await .unwrap(); - let segment_state = SegmentState::from(segment_state); + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete.into() + ) + ); + + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + + assert_eq!(video_manifest.parts.len(), 8); + assert_eq!( + video_manifest + .parts + .iter() + .filter(|p| p.independent) + .count(), + 2 + ); + assert!(video_manifest.parts[0].independent); + assert!(video_manifest.parts[4].independent); + assert!(!video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 8); + assert_eq!( + video_manifest.info.as_ref().unwrap().next_segment_part_idx, + 8 + ); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 1 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 8); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_part_idx, + 8 + ); + assert_eq!(video_manifest.total_duration, 59000 * 2); // verified with ffprobe - assert_eq!(segment_state.fragments().len(), 4); - assert!(segment_state.fragments().iter().all(|f| f.keyframe)); + assert_eq!(audio_manifest.parts.len(), 8); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(!audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 8); + assert_eq!( + audio_manifest.info.as_ref().unwrap().next_segment_part_idx, + 8 + ); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 1 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 8); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_part_idx, + 8 + ); + assert_eq!(audio_manifest.total_duration, 48128 * 2); // verified with ffprobe } { - let segment_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:0:state", req_id, opus_audio_id)) + let new_req_id = Uuid::new_v4(); + + global + .nats + .publish( + global.config.transcoder.transcoder_request_subject.clone(), + TranscoderRequest { + room_name: room_name.clone(), + organization_id: org_id.to_string(), + request_id: new_req_id.to_string(), + connection_id: connection_id.to_string(), + grpc_endpoint: format!("localhost:{}", port), + } + .encode_to_vec() + .into(), + ) + .await + .unwrap(); + + let (sender, mut receiver) = rx + .recv() + .timeout(Duration::from_secs(2)) + .await + .unwrap() + .unwrap(); + + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Open(ingest_watch_request::Open { + request_id: new_req_id.to_string(), + }) + ); + + for packet in &packets { + match packet { + TransmuxResult::InitSegment { data, .. } => { + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: data.clone(), + keyframe: false, + r#type: ingest_watch_response::media::Type::Init.into(), + }, + )), + })) + .await + .unwrap(); + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), + })) + .await + .unwrap(); + } + TransmuxResult::MediaSegment(ms) => { + tokio::time::sleep(Duration::from_millis(10)).await; + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: ms.data.clone(), + keyframe: ms.keyframe, + r#type: match ms.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio.into() + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video.into() + } + }, + }, + )), + })) + .await + .unwrap(); + } + } + } + + { + let event = + OrganizationEvent::decode(event_stream.next().await.unwrap().payload).unwrap(); + assert_eq!(event.id, org_id.to_string()); + assert!(event.timestamp > 0); + match event.event { + Some(organization_event::Event::RoomReady(r)) => { + assert_eq!(r.room_name, room_name); + assert_eq!(r.connection_id, connection_id.to_string()); + } + _ => panic!("unexpected event"), + }; + } + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Transcoder.into(), + )), + })) .await .unwrap(); - let segment_state = SegmentState::from(segment_state); + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete.into() + ) + ); + + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + + assert_eq!(video_manifest.parts.len(), 12); + assert_eq!( + video_manifest + .parts + .iter() + .filter(|p| p.independent) + .count(), + 3 + ); + assert!(video_manifest.parts[0].independent); + assert!(video_manifest.parts[4].independent); + assert!(video_manifest.parts[8].independent); + assert!(!video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 1); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 12); + assert_eq!( + video_manifest.info.as_ref().unwrap().next_segment_part_idx, + 12 + ); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 2 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 13); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_part_idx, + 4 + ); + assert_eq!(video_manifest.total_duration, 59000 * 3); // verified with ffprobe - assert_eq!(segment_state.fragments().len(), 4); - assert!(segment_state.fragments().iter().all(|f| f.keyframe)); + assert_eq!(audio_manifest.parts.len(), 13); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(!audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 2); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 13); + assert_eq!( + audio_manifest.info.as_ref().unwrap().next_segment_part_idx, + 4 + ); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 1 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 12); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_part_idx, + 12 + ); + assert_eq!(audio_manifest.total_duration, 48128 * 3); // verified with ffprobe } { - let segment_state: HashMap = redis - .hgetall(format!("transcoder:{}:{}:0:state", req_id, video_id_360p)) + let new_req_id = Uuid::new_v4(); + + global + .nats + .publish( + global.config.transcoder.transcoder_request_subject.clone(), + TranscoderRequest { + room_name: room_name.clone(), + organization_id: org_id.to_string(), + request_id: new_req_id.to_string(), + connection_id: connection_id.to_string(), + grpc_endpoint: format!("localhost:{}", port), + } + .encode_to_vec() + .into(), + ) + .await + .unwrap(); + + let (sender, mut receiver) = rx + .recv() + .timeout(Duration::from_secs(2)) + .await + .unwrap() + .unwrap(); + + assert_eq!( + receiver.message().await.unwrap().unwrap().message.unwrap(), + ingest_watch_request::Message::Open(ingest_watch_request::Open { + request_id: new_req_id.to_string(), + }) + ); + + for packet in &packets { + match packet { + TransmuxResult::InitSegment { data, .. } => { + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: data.clone(), + keyframe: false, + r#type: ingest_watch_response::media::Type::Init.into(), + }, + )), + })) + .await + .unwrap(); + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Ready( + ingest_watch_response::Ready::Ready.into(), + )), + })) + .await + .unwrap(); + } + TransmuxResult::MediaSegment(ms) => { + tokio::time::sleep(Duration::from_millis(10)).await; + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Media( + ingest_watch_response::Media { + data: ms.data.clone(), + keyframe: ms.keyframe, + r#type: match ms.ty { + transmuxer::MediaType::Audio => { + ingest_watch_response::media::Type::Audio.into() + } + transmuxer::MediaType::Video => { + ingest_watch_response::media::Type::Video.into() + } + }, + }, + )), + })) + .await + .unwrap(); + } + } + } + + { + let event = + OrganizationEvent::decode(event_stream.next().await.unwrap().payload).unwrap(); + assert_eq!(event.id, org_id.to_string()); + assert!(event.timestamp > 0); + match event.event { + Some(organization_event::Event::RoomReady(r)) => { + assert_eq!(r.room_name, room_name); + assert_eq!(r.connection_id, connection_id.to_string()); + } + _ => panic!("unexpected event"), + }; + } + + sender + .send(Ok(IngestWatchResponse { + message: Some(ingest_watch_response::Message::Shutdown( + ingest_watch_response::Shutdown::Stream.into(), + )), + })) .await .unwrap(); - let segment_state = SegmentState::from(segment_state); + assert!(receiver.message().await.unwrap().is_none()); + + let video_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.video_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + let audio_manifest = LiveRenditionManifest::decode( + kv.get(format!( + "{}.{}.{}.audio_source.manifest", + org_id, &room_name, connection_id + )) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + + assert_eq!(video_manifest.parts.len(), 16); + assert_eq!( + video_manifest + .parts + .iter() + .filter(|p| p.independent) + .count(), + 4 + ); + assert!(video_manifest.parts[0].independent); + assert!(video_manifest.parts[4].independent); + assert!(video_manifest.parts[8].independent); + assert!(video_manifest.parts[12].independent); + assert!(video_manifest.completed); + assert_eq!(video_manifest.info.as_ref().unwrap().next_segment_idx, 2); + assert_eq!(video_manifest.info.as_ref().unwrap().next_part_idx, 16); + assert_eq!( + video_manifest.info.as_ref().unwrap().next_segment_part_idx, + 4 + ); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_idx, + 2 + ); + assert_eq!(video_manifest.other_info["audio_source"].next_part_idx, 17); + assert_eq!( + video_manifest.other_info["audio_source"].next_segment_part_idx, + 8 + ); + assert_eq!(video_manifest.total_duration, 59000 * 4); // verified with ffprobe - assert_eq!(segment_state.fragments().len(), 4); - assert!(segment_state.fragments()[0].keyframe); + assert_eq!(audio_manifest.parts.len(), 17); + assert!(audio_manifest.parts.iter().all(|p| p.independent)); + assert!(audio_manifest.completed); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_segment_idx, 2); + assert_eq!(audio_manifest.info.as_ref().unwrap().next_part_idx, 17); + assert_eq!( + audio_manifest.info.as_ref().unwrap().next_segment_part_idx, + 8 + ); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_idx, + 2 + ); + assert_eq!(audio_manifest.other_info["video_source"].next_part_idx, 16); + assert_eq!( + audio_manifest.other_info["video_source"].next_segment_part_idx, + 4 + ); + assert_eq!(audio_manifest.total_duration, 48128 * 4); // verified with ffprobe } + drop(global); + handler + .cancel() + .timeout(Duration::from_secs(2)) + .await + .unwrap(); + transcoder_run_handle + .timeout(Duration::from_secs(2)) + .await + .unwrap() + .unwrap() + .unwrap(); + tracing::info!("done"); } diff --git a/video/transcoder/src/transcoder/job/mod.rs b/video/transcoder/src/transcoder/job/mod.rs index 5955becd..d65513bf 100644 --- a/video/transcoder/src/transcoder/job/mod.rs +++ b/video/transcoder/src/transcoder/job/mod.rs @@ -1,881 +1,888 @@ use std::collections::HashMap; use std::io; -use std::process::Output; -use std::{ - os::unix::process::CommandExt, path::Path, pin::pin, process::Command as StdCommand, sync::Arc, - time::Duration, +use std::path::PathBuf; +use std::pin::Pin; +use std::{path::Path, pin::pin, sync::Arc, time::Duration}; + +use anyhow::{Context, Result}; +use async_nats::jetstream::Message; +use bytes::Bytes; +use common::prelude::FutureTimeout; +use futures::{FutureExt, StreamExt}; +use futures_util::{Future, Stream, TryFutureExt}; +use pb::ext::UlidExt; +use pb::scuffle::video::internal::events::{ + organization_event, OrganizationEvent, TranscoderRequest, }; - -use anyhow::{anyhow, Result}; -use async_stream::stream; -use common::prelude::*; -use common::vec_of_strings; -use fred::types::Expiration; -use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; -use futures_util::Stream; -use lapin::message::Delivery; -use lapin::options::BasicAckOptions; -use mp4::codec::{AudioCodec, VideoCodec}; -use nix::sys::signal; -use nix::unistd::Pid; +use pb::scuffle::video::internal::ingest_client::IngestClient; +use pb::scuffle::video::internal::{ + ingest_watch_request, ingest_watch_response, IngestWatchRequest, IngestWatchResponse, + LiveManifest, +}; +use pb::scuffle::video::internal::{live_rendition_manifest, LiveRenditionManifest}; +use pb::scuffle::video::v1::types::{RecordingConfig, VideoConfig}; use prost::Message as _; +use tokio::io::AsyncReadExt; +use tokio::process::Child; use tokio::sync::mpsc; -use tokio::{ - io::AsyncWriteExt, - net::UnixListener, - process::{ChildStdin, Command}, - select, -}; +use tokio::time::Instant; +use tokio::try_join; +use tokio::{io::AsyncWriteExt, net::UnixListener, process::ChildStdin, select}; use tokio_util::sync::CancellationToken; use tonic::{transport::Channel, Status}; - -use crate::pb::scuffle::types::{stream_state, StreamState}; -use crate::transcoder::job::utils::{release_lock, set_lock, SharedFuture}; -use crate::{ - global::GlobalState, - pb::scuffle::{ - events::{transcoder_message, TranscoderMessage, TranscoderMessageNewStream}, - video::{ - ingest_client::IngestClient, transcoder_event_request, watch_stream_response, - TranscoderEventRequest, WatchStreamRequest, WatchStreamResponse, - }, - }, +use ulid::Ulid; +use uuid::Uuid; +use video_database::rendition::Rendition; +use video_database::room_status::RoomStatus; + +use crate::global::GlobalState; +use crate::transcoder::job::track_parser::track_parser; +use crate::transcoder::job::utils::{ + bind_socket, perform_sql_operations, spawn_ffmpeg, spawn_ffmpeg_screenshot, unix_stream, }; -use fred::interfaces::KeysInterface; -use self::renditions::RenditionMap; +use self::renditions::screenshot_size; +use self::track_parser::TrackOut; +use self::utils::{TaskError, Tasker, TrackState}; mod renditions; mod track_parser; mod utils; -pub(crate) mod variant; pub async fn handle_message( global: Arc, - msg: Delivery, + msg: Message, shutdown_token: CancellationToken, ) { - let mut job = match handle_message_internal(&msg).await { + let mut job = match Job::new(&global, &msg).await { Ok(job) => job, Err(err) => { - tracing::error!("failed to handle message: {}", err); + msg.ack_with(async_nats::jetstream::AckKind::Nak(Some( + Duration::from_secs(15), + ))) + .await + .ok(); + tracing::error!(error = %err, "failed to handle message"); return; } }; - if let Err(err) = msg.ack(BasicAckOptions::default()).await { - tracing::error!("failed to ACK message: {}", err); + if let Err(err) = msg.double_ack().await { + tracing::error!(error = %err, "failed to ACK message"); return; }; - job.run(global, shutdown_token).await; -} - -async fn handle_message_internal(msg: &Delivery) -> Result { - let message = TranscoderMessage::decode(msg.data.as_slice())?; + let mut streams = futures::stream::select_all(job.tracks.drain(..).map(|(t, rendition)| { + Box::pin(track_parser(unix_stream(t, 256 * 1024))).map(move |r| (r, rendition)) + })); - let req = match message.data { - Some(transcoder_message::Data::NewStream(data)) => data, - None => return Err(anyhow!("message missing data")), - }; + if let Err(err) = job.run(&global, shutdown_token, &mut streams).await { + tracing::error!(error = %err, "failed to run transcoder"); + } - let channel = common::grpc::make_channel( - vec![req.ingest_address.clone()], - Duration::from_secs(30), - None, - )?; + if let Err(err) = job.handle_shutdown(&global, &mut streams).await { + tracing::error!(error = %err, "failed to shutdown transcoder"); + } - tracing::info!("got new stream request: {}", req.stream_id); + tracing::info!("stream finished"); +} - let mut client = IngestClient::new(channel); +type TaskFuture = Pin> + Send>>; - let stream = client - .watch_stream(WatchStreamRequest { - request_id: req.request_id.clone(), - stream_id: req.stream_id.clone(), - }) - .timeout(Duration::from_secs(2)) - .await?? - .into_inner(); - - Ok(Job { - req, - client, - stream, - lock_owner: CancellationToken::new(), - }) +struct Ffmpeg { + process: Child, + stdin: Option, } struct Job { - req: TranscoderMessageNewStream, - client: IngestClient, - stream: tonic::Streaming, - lock_owner: CancellationToken, -} + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + _socket_dir: CleanupPath, -#[inline(always)] -fn redis_mutex_key(stream_id: impl std::fmt::Display) -> String { - format!("transcoder:{}:mutex", stream_id) -} + video_input: VideoConfig, + recording_config: Option, -#[inline(always)] -fn redis_master_playlist_key(stream_id: impl std::fmt::Display) -> String { - format!("transcoder:{}:playlist", stream_id) -} + ready: bool, + init_segment: Option, -fn set_master_playlist( - global: Arc, - stream_id: impl std::fmt::Display, - state: &StreamState, - lock: CancellationToken, -) -> impl futures::Future> + Send + 'static { - let playlist_key = redis_master_playlist_key(stream_id); + track_state: HashMap, + manifests: HashMap, - let mut playlist = String::new(); + tasker: Tasker, + screenshot_task: Option>, - playlist.push_str("#EXTM3U\n"); + ffmpeg: Ffmpeg, - let mut state_map = HashMap::new(); + tracks: Vec<(UnixListener, Rendition)>, - for transcode_state in state.transcodes.iter() { - let mut tags = vec![ - format!( - "TYPE={}", - match transcode_state.settings.as_ref().unwrap() { - stream_state::transcode::Settings::Video(_) => { - "VIDEO" - } - stream_state::transcode::Settings::Audio(_) => { - "AUDIO" - } - } - ), - "AUTOSELECT=YES".to_string(), - "DEFAULT=YES".to_string(), - format!("GROUP-ID=\"{}\"", transcode_state.id), - format!("NAME=\"{}\"", transcode_state.id), - format!("BANDWIDTH={}", transcode_state.bitrate), - format!("CODECS=\"{}\"", transcode_state.codec), - format!("URI=\"{}/index.m3u8\"", transcode_state.id), - ]; - - if let Some(stream_state::transcode::Settings::Video(settings)) = - transcode_state.settings.as_ref() - { - tags.push(format!("FRAME-RATE={}", settings.framerate)); - tags.push(format!("RESOLUTION={}x{}", settings.width, settings.height)); - } + _client: IngestClient, - playlist.push_str(format!("#EXT-X-MEDIA:{}\n", tags.join(",")).as_str()); + shutdown: Option, - state_map.insert(transcode_state.id.as_str(), transcode_state); - } + first_init_put: bool, - for stream_variant in state.variants.iter() { - let video_transcode_state = stream_variant.transcode_ids.iter().find_map(|id| { - let t = state_map.get(id.as_str()).unwrap(); - if matches!( - t.settings, - Some(stream_state::transcode::Settings::Video(_)) - ) { - Some(t) - } else { - None - } - }); + screenshot_idx: u32, - let audio_transcode_state = stream_variant.transcode_ids.iter().find_map(|id| { - let t = state_map.get(id.as_str()).unwrap(); - if matches!( - t.settings, - Some(stream_state::transcode::Settings::Audio(_)) - ) { - Some(t) - } else { - None + last_screenshot: Instant, + + send: mpsc::Sender, + recv: tonic::Streaming, +} + +struct CleanupPath(PathBuf); + +impl Drop for CleanupPath { + fn drop(&mut self) { + let path = self.0.clone(); + tokio::spawn(async move { + if let Err(err) = tokio::fs::remove_dir_all(path).await { + tracing::error!(error = %err, "failed to cleanup socket dir"); } }); + } +} - let bandwidth = video_transcode_state.map(|t| t.bitrate).unwrap_or(0) - + audio_transcode_state.map(|t| t.bitrate).unwrap_or(0); - let codecs = video_transcode_state - .iter() - .chain(audio_transcode_state.iter()) - .map(|t| t.codec.as_str()) - .collect::>() - .join(","); - - let mut tags = vec![ - format!("GROUP=\"{}\"", stream_variant.group), - format!("NAME=\"{}\"", stream_variant.name), - format!("BANDWIDTH={}", bandwidth), - format!("CODECS=\"{}\"", codecs), - ]; - - if let Some(video) = video_transcode_state { - let settings = match video.settings.as_ref() { - Some(stream_state::transcode::Settings::Video(settings)) => settings, - _ => unreachable!(), - }; +impl Job { + async fn new(global: &Arc, msg: &Message) -> Result { + let message = TranscoderRequest::decode(msg.payload.clone())?; + + let organization_id = message.organization_id.to_ulid(); + let room_id = message.room_id.to_ulid(); + let connection_id = message.connection_id.to_ulid(); + + let result = + perform_sql_operations(global, organization_id, room_id, connection_id).await?; + + tracing::info!( + %organization_id, + %room_id, + %connection_id, + transcoding_config_id = %result.transcoding_config.id.to_ulid(), + recording_config_name = %result.recording_config.as_ref().map(|v| v.id.to_ulid().to_string()).unwrap_or_default(), + "got new stream request", + ); - tags.push(format!("RESOLUTION={}x{}", settings.width, settings.height)); - tags.push(format!("FRAME-RATE={}", settings.framerate)); - tags.push(format!("VIDEO=\"{}\"", video.id)); + // We need to create a unix socket for ffmpeg to connect to. + let socket_dir = CleanupPath( + Path::new(&global.config.transcoder.socket_dir) + .join(message.request_id.to_ulid().to_string()), + ); + if let Err(err) = tokio::fs::create_dir_all(&socket_dir.0).await { + anyhow::bail!("failed to create socket dir: {}", err) } - if let Some(audio) = audio_transcode_state { - tags.push(format!("AUDIO=\"{}\"", audio.id)); + if result.recording_config.is_some() { + todo!("implement recording"); } - playlist.push_str( - format!( - "#EXT-X-STREAM-INF:{}\n{}/index.m3u8\n", - tags.join(","), - video_transcode_state.or(audio_transcode_state).unwrap().id - ) - .as_str(), - ); - } + let tracks = result + .video_output + .iter() + .map(|output| output.rendition()) + .chain(result.audio_output.iter().map(|output| output.rendition())) + .map(Rendition::from) + .map(|rendition| { + let sock_path = socket_dir.0.join(format!("{rendition}.sock")); + let socket = bind_socket( + &sock_path, + global.config.transcoder.ffmpeg_uid, + global.config.transcoder.ffmpeg_gid, + )?; + + Ok((socket, rendition)) + }) + .collect::>>()?; - for group in state.groups.iter() { - playlist.push_str( - format!( - "#EXT-X-SCUF-GROUP:GROUP=\"{}\",PRIORITY={}\n", - group.name, group.priority - ) - .as_str(), - ); + let mut ffmpeg = spawn_ffmpeg( + global.config.transcoder.ffmpeg_gid, + global.config.transcoder.ffmpeg_uid, + &socket_dir.0, + &result.video_output, + &result.audio_output, + )?; + + tracing::debug!(endpoint = %message.grpc_endpoint, "trying to connect to ingest"); + + let tls = global.ingest_tls(); + + let channel = + common::grpc::make_channel(vec![message.grpc_endpoint], Duration::from_secs(30), tls)?; + + let mut client = IngestClient::new(channel); + + let (send, rx) = mpsc::channel(16); + + send.try_send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Open( + ingest_watch_request::Open { + request_id: message.request_id.clone(), + }, + )), + }) + .ok(); + + let recv = client + .watch(tokio_stream::wrappers::ReceiverStream::new(rx)) + .timeout(Duration::from_secs(2)) + .await + .context("failed to connect to ingest")?? + .into_inner(); + + Ok(Self { + organization_id, + room_id, + connection_id, + _socket_dir: socket_dir, + recording_config: result.recording_config, + _client: client, + ffmpeg: Ffmpeg { + stdin: ffmpeg.stdin.take(), + process: ffmpeg, + }, + init_segment: None, + shutdown: None, + tasker: Tasker::new(), + screenshot_task: None, + last_screenshot: Instant::now(), + screenshot_idx: 0, + video_input: result.video_input, + manifests: tracks + .iter() + .map(|(_, rendition)| (*rendition, LiveRenditionManifest::default())) + .collect(), + ready: false, + track_state: tracks + .iter() + .map(|(_, rendition)| (*rendition, TrackState::default())) + .collect(), + send, + recv, + tracks, + first_init_put: true, + }) } - async move { - lock.cancelled().await; - - global - .redis - .set( - &playlist_key, - playlist, - Some(Expiration::EX(450)), - None, - false, - ) - .await?; - - let mut ticker = tokio::time::interval(Duration::from_secs(60)); + async fn run( + &mut self, + global: &Arc, + shutdown_token: CancellationToken, + mut streams: impl Stream, Rendition)> + Unpin, + ) -> Result<()> { + tracing::info!("starting transcode job"); + + let mut shutdown_fuse = pin!(shutdown_token.cancelled().fuse()); + + let mut upload_init_timer = tokio::time::interval(Duration::from_secs(15)); + loop { - ticker.tick().await; - global.redis.expire(&playlist_key, 450).await?; - } - } -} + select! { + _ = &mut shutdown_fuse => { + self.send.try_send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Request as i32, + )) + })?; + }, + msg = self.recv.next() => self.handle_msg(global, msg).await?, + r = self.ffmpeg.process.wait() => { + r?; + break; + }, + Some(result) = self.tasker.next_task(global) => { + match result { + Err((task, err)) => { + tracing::error!(error = %err, retry = task.retry_count(), "failed to upload media"); + if task.retry_count() < 5 { + self.tasker.requeue(task); + } else { + anyhow::bail!("failed to upload media after 5 retries: {}", err); + } + } + Ok(task) => { + tracing::debug!(key = %task.key(), "completed task"); + } + } + }, + Some(screenshot) = async { + if let Some(task) = self.screenshot_task.as_mut() { + let r = task.await; + self.screenshot_task = None; + Some(r) + } else { + tracing::trace!("no screenshot to process"); + None + } + } => { + let screenshot = screenshot?; + self.screenshot_idx += 1; -fn report_to_ingest( - mut client: IngestClient, - mut channel: mpsc::Receiver, -) -> impl Stream> + Send + 'static { - stream!({ - while let Some(msg) = channel.recv().await { - tracing::debug!("sending message: {:?}", msg); - match client - .transcoder_event(msg) - .timeout(Duration::from_secs(5)) - .await - { - Ok(Ok(_)) => {} - Ok(Err(e)) => { - yield Err(e.into()); + let key = utils::keys::screenshot( + self.organization_id, + self.room_id, + self.connection_id, + self.screenshot_idx, + ); + + tracing::debug!(key = %key, "uploading screenshot"); + + self.tasker.upload_media(key, screenshot); + + self.update_manifest(); } - Err(e) => { - yield Err(e.into()); + r = streams.next() => { + let Some((result, rendition)) = r else { + break; + }; + + self.handle_track(global, rendition, result)?; + }, + _ = upload_init_timer.tick() => { + self.put_init_segments()?; + self.update_manifest(); } } } - }) -} -impl Job { - fn stream_state(&self) -> &StreamState { - self.req.state.as_ref().unwrap() + Ok(()) } - async fn run(&mut self, global: Arc, shutdown_token: CancellationToken) { - tracing::info!("starting transcode job"); - let mut set_lock_fut = pin!(set_lock( - global.clone(), - redis_mutex_key(&self.req.stream_id), - self.req.request_id.clone(), - self.lock_owner.clone(), - )); - - let mut update_playlist_fut = pin!(set_master_playlist( - global.clone(), - self.req.stream_id.clone(), - self.stream_state(), - self.lock_owner.child_token(), - )); + async fn handle_msg( + &mut self, + global: &Arc, + msg: Option>, + ) -> Result<()> { + tracing::trace!("recieved message"); + + let Some(Ok(msg)) = msg else { + if self.shutdown.is_none() { + anyhow::bail!("ingest stream closed") + } - // We need to create a unix socket for ffmpeg to connect to. - let socket_dir = Path::new(&global.config.transcoder.socket_dir).join(&self.req.request_id); - if let Err(err) = tokio::fs::create_dir_all(&socket_dir).await { - tracing::error!("failed to create socket dir: {}", err); - self.report_error("Failed to create socket dir", false) - .await; - return; - } + return Ok(()); + }; - let mut futures = FuturesUnordered::new(); + let msg = msg + .message + .ok_or_else(|| anyhow::anyhow!("ingest sent bad message"))?; - let stream_state = self.stream_state(); + match msg { + ingest_watch_response::Message::Media(media) => { + if let Some(stdin) = &mut self.ffmpeg.stdin { + stdin.write_all(&media.data).await?; + } else { + anyhow::bail!("ffmpeg stdin was not open"); + } - let (ready_tx, mut ready_recv) = mpsc::channel(16); - let mut rendition_map = RenditionMap::new(); - for transcode_state in stream_state.transcodes.iter() { - rendition_map.insert(transcode_state.id.clone()); + match media.r#type() { + ingest_watch_response::media::Type::Init => { + self.init_segment = Some(media.data.clone()); + } + ingest_watch_response::media::Type::Video => { + if media.keyframe + && self.last_screenshot.elapsed() > Duration::from_secs(5) + && self.screenshot_task.is_none() + { + self.take_screenshot(global, &media.data).await?; + } + } + ingest_watch_response::media::Type::Audio => {} + } + } + ingest_watch_response::Message::Shutdown(s) => { + self.shutdown = ingest_watch_response::Shutdown::from_i32(s); + self.ffmpeg.stdin.take(); + } + ingest_watch_response::Message::Ready(_) => { + self.ready = true; + self.fetch_manifests(global).await?; + self.put_init_segments()?; + for rendition in self.track_state.keys().cloned().collect::>() { + self.handle_sample(global, rendition)?; + } + tracing::info!("ingest reported ready"); + } } - let rendition_map = Arc::new(rendition_map); - - for transcode_state in stream_state.transcodes.iter() { - let sock_path = socket_dir.join(format!("{}.sock", transcode_state.id)); - let socket = match UnixListener::bind(&sock_path) { - Ok(s) => s, - Err(err) => { - tracing::error!("failed to bind socket: {}", err); - self.report_error("Failed to bind socket", false).await; - return; + + Ok(()) + } + + async fn take_screenshot(&mut self, global: &Arc, data: &Bytes) -> Result<()> { + if let Some(init_segment) = &self.init_segment { + let (width, height) = screenshot_size(&self.video_input); + + let mut child = spawn_ffmpeg_screenshot( + global.config.transcoder.ffmpeg_gid, + global.config.transcoder.ffmpeg_uid, + width, + height, + )?; + + let mut stdin = child.stdin.take(); + stdin.as_mut().unwrap().write_all(init_segment).await?; + stdin.as_mut().unwrap().write_all(data).await?; + + self.last_screenshot = Instant::now(); + + tracing::debug!("taking screenshot"); + + self.screenshot_task = Some(Box::pin(async move { + let start = Instant::now(); + let output = child.wait_with_output().await?; + if !output.status.success() { + tracing::error!( + "screenshot stdout: {}", + String::from_utf8_lossy(&output.stderr) + ); } - }; - // Change user and group of the socket. - if let Err(err) = nix::unistd::chown( - sock_path.as_os_str(), - Some(nix::unistd::Uid::from_raw(global.config.transcoder.uid)), - Some(nix::unistd::Gid::from_raw(global.config.transcoder.gid)), - ) { - tracing::error!("failed to chown socket: {}", err); - self.report_error("Failed to chown socket", false).await; - return; + let duration = format!("{:.5}ms", start.elapsed().as_secs_f64() * 1000.0); + + tracing::debug!(duration, "screenshot captured"); + + Ok(Bytes::from(output.stdout)) + })); + } + + Ok(()) + } + + fn handle_track( + &mut self, + global: &Arc, + rendition: Rendition, + result: io::Result, + ) -> Result<()> { + match result? { + TrackOut::Moov(moov) => { + self.track_state.get_mut(&rendition).unwrap().set_moov(moov); + self.put_init_segments()?; + } + TrackOut::Samples(samples) => { + self.track_state + .get_mut(&rendition) + .unwrap() + .append_samples(samples); + self.handle_sample(global, rendition)?; } + } - futures.push(variant::handle_variant( - global.clone(), - ready_tx.clone(), - self.req.stream_id.clone(), - transcode_state.id.clone(), - self.req.request_id.clone(), - socket, - rendition_map.clone(), - )); + Ok(()) + } + + fn put_init_segments(&mut self) -> Result<()> { + if !self.ready { + return Ok(()); } - let filter_graph_items = self - .stream_state() - .transcodes + if self + .track_state .iter() - .filter(|v| { - !v.copy - && matches!( - v.settings, - Some(stream_state::transcode::Settings::Video(_)) - ) - }) - .collect::>(); + .any(|(_, state)| state.init_segment().is_none()) + { + return Ok(()); + } - let filter_graph = filter_graph_items - .iter() - .enumerate() - .map(|(i, v)| { - let settings = match v.settings.as_ref().unwrap() { - stream_state::transcode::Settings::Video(v) => v, - _ => unreachable!(), - }; + self.track_state.iter().for_each(|(rendition, state)| { + let key = utils::keys::init( + self.organization_id, + self.room_id, + self.connection_id, + *rendition, + ); - let previous = if i == 0 { - "[0:v]".to_string() - } else { - format!("[{}_out]", i - 1) - }; + let data = state.init_segment().unwrap().clone(); + self.tasker.upload_media(key, data); + }); - format!( - "{}scale={}:{},pad=ceil(iw/2)*2:ceil(ih/2)*2{}", - previous, - settings.width, - settings.height, - if i == filter_graph_items.len() - 1 { - format!("[{}]", v.id) - } else { - format!(",split=2[{}][{}_out]", v.id, i) + if self.first_init_put { + self.first_init_put = false; + + let event = Bytes::from( + OrganizationEvent { + id: Some(self.organization_id.into()), + timestamp: chrono::Utc::now().timestamp_micros(), + event: Some(organization_event::Event::RoomReady( + organization_event::RoomReady { + room_id: Some(self.room_id.into()), + connection_id: Some(self.connection_id.into()), + }, + )), + } + .encode_to_vec(), + ); + + let organization_id = self.organization_id; + let connection_id = self.connection_id; + let room_id = self.room_id; + + self.tasker.custom("room_ready".into(), move |_, global| { + let global = global.clone(); + let event = event.clone(); + Box::pin(async move { + let resp = sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + status = $1 + WHERE + organization_id = $2 AND + id = $3 AND + active_ingest_connection_id = $4 + "#, + ) + .bind(RoomStatus::Ready) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(Uuid::from(connection_id)) + .execute(global.db.as_ref()) + .await + .map_err(|e| TaskError::Custom(e.into()))?; + + if resp.rows_affected() != 1 { + return Err(TaskError::Custom(anyhow::anyhow!( + "failed to update room status" + ))); } + + global + .nats + .publish(global.config.transcoder.events_subject.clone(), event) + .await + .map_err(|e| TaskError::Custom(e.into()))?; + + Ok(()) + }) + }); + } + + Ok(()) + } + + fn handle_sample(&mut self, global: &Arc, rendition: Rendition) -> Result<()> { + if !self.ready { + return Ok(()); + } + + let track_state = self.track_state.get_mut(&rendition).unwrap(); + + let additions = track_state.split_samples( + global.config.transcoder.target_part_duration.as_secs_f64(), + global.config.transcoder.max_part_duration.as_secs_f64(), + global.config.transcoder.min_segment_duration.as_secs_f64(), + ); + + for (segment_idx, parts) in additions { + for part_idx in parts { + let key = utils::keys::part( + self.organization_id, + self.room_id, + self.connection_id, + rendition, + part_idx, + ); + + let data = track_state + .part(segment_idx, part_idx) + .unwrap() + .data + .clone(); + + self.tasker.upload_media(key, data); + } + } + + let part_keys = track_state + .retain_segments(global.config.transcoder.playlist_segments) + .into_iter() + .flat_map(|s| s.parts.into_iter().map(|p| p.idx)) + .map(|idx| { + utils::keys::part( + self.organization_id, + self.room_id, + self.connection_id, + rendition, + idx, ) }) - .collect::>() - .join(";"); + .collect::>(); - const MP4_FLAGS: &str = "+frag_keyframe+empty_moov+default_base_moof"; + for key in part_keys { + self.tasker.delete_media(key); + } - #[rustfmt::skip] - let mut args = vec_of_strings![ - "-v", "error", - "-i", "-", - "-probesize", "250M", - "-analyzeduration", "250M", - ]; + self.update_rendition_manifest(rendition); - if !filter_graph.is_empty() { - args.extend(vec_of_strings!["-filter_complex", filter_graph]); - } + Ok(()) + } - for state in stream_state.transcodes.iter() { - match state.settings { - Some(stream_state::transcode::Settings::Video(ref video)) => { - if state.copy { - #[rustfmt::skip] - args.extend(vec_of_strings![ - "-map", "0:v", - "-c:v", "copy", - ]); - } else { - let codec: VideoCodec = match state.codec.parse() { - Ok(c) => c, - Err(err) => { - tracing::error!("invalid video codec: {}", err); - self.report_error("Invalid video codec", false).await; - return; - } - }; - - match codec { - VideoCodec::Avc { profile, level, .. } => { - #[rustfmt::skip] - args.extend(vec_of_strings![ - "-map", format!("[{}]", state.id), - "-c:v", "libx264", - "-preset", "medium", - "-b:v", format!("{}", state.bitrate), - "-maxrate", format!("{}", state.bitrate), - "-bufsize", format!("{}", state.bitrate * 2), - "-profile:v", match profile { - 66 => "baseline", - 77 => "main", - 100 => "high", - _ => { - tracing::error!("invalid avc profile: {}", profile); - self.report_error("Invalid avc profile", false).await; - return; - }, - }, - "-level:v", match level { - 30 => "3.0", - 31 => "3.1", - 32 => "3.2", - 40 => "4.0", - 41 => "4.1", - 42 => "4.2", - 50 => "5.0", - 51 => "5.1", - 52 => "5.2", - 60 => "6.0", - 61 => "6.1", - 62 => "6.2", - _ => { - tracing::error!("invalid avc level: {}", level); - self.report_error("Invalid avc level", false).await; - return; - }, - }, - "-pix_fmt", "yuv420p", - "-g", format!("{}", video.framerate * 2), - "-keyint_min", format!("{}", video.framerate * 2), - "-sc_threshold", "0", - "-r", format!("{}", video.framerate), - "-crf", "23", - "-tune", "zerolatency", - ]); - } - VideoCodec::Av1 { .. } => { - tracing::error!("av1 is not supported"); - self.report_error("AV1 is not supported", false).await; - return; - } - VideoCodec::Hevc { .. } => { - tracing::error!("hevc is not supported"); - self.report_error("HEVC is not supported", false).await; - return; - } + pub async fn handle_shutdown( + &mut self, + global: &Arc, + mut streams: impl Stream, Rendition)> + Unpin, + ) -> Result<()> { + tracing::info!("shutting down transcoder"); + + let mut ffmpeg_done = false; + + match async { + loop { + select! { + Some(r) = async { + if !ffmpeg_done { + Some(self.ffmpeg.process.wait().timeout(Duration::from_secs(2)).await) + } else { + None } - } - } - Some(stream_state::transcode::Settings::Audio(ref audio)) => { - if state.copy { - tracing::error!("audio copy is not supported"); - self.report_error("Audio copy is not supported", false) - .await; - return; - } else { - let codec: AudioCodec = match state.codec.parse() { - Ok(c) => c, - Err(err) => { - tracing::error!("invalid audio codec: {}", err); - self.report_error("Invalid audio codec", false).await; - return; + } => { + match r { + Ok(Ok(status)) => { + if !status.success() { + if let Some(mut stderr) = self.ffmpeg.process.stderr.take() { + let mut buf = Vec::new(); + let size = stderr.read_to_end(&mut buf).await.unwrap_or_default(); + tracing::error!("ffmpeg stdout: {}", String::from_utf8_lossy(&buf[..size])); + } + } + // ffmpeg exited gracefully } - }; - - match codec { - AudioCodec::Aac { object_type } => { - args.extend(vec_of_strings![ - "-map", - "0:a", - "-c:a", - "aac", - "-b:a", - format!("{}", state.bitrate), - "-ar", - format!("{}", audio.sample_rate), - "-ac", - format!("{}", audio.channels), - "-profile:a", - match object_type { - aac::AudioObjectType::AacLowComplexity => { - "aac_low" - } - aac::AudioObjectType::AacMain => { - "aac_main" - } - aac::AudioObjectType::Unknown(profile) => { - tracing::error!("invalid aac profile: {}", profile); - self.report_error("Invalid aac profile", false).await; - return; - } - }, - ]); + Ok(Err(e)) => { + tracing::error!(error = %e, "ffmpeg exited with error"); } - AudioCodec::Opus => { - args.extend(vec_of_strings![ - "-map", - "0:a", - "-c:a", - "libopus", - "-b:a", - format!("{}", state.bitrate), - "-ar", - format!("{}", audio.sample_rate), - "-ac", - format!("{}", audio.channels), - ]); + Err(_) => { + tracing::error!("ffmpeg timeout while exit"); + self.ffmpeg.process.kill().await.ok(); + + if let Some(mut stderr) = self.ffmpeg.process.stderr.take() { + let mut buf = Vec::new(); + let size = stderr.read_to_end(&mut buf).await.unwrap_or_default(); + tracing::error!("ffmpeg stdout: {}", String::from_utf8_lossy(&buf[..size])); + } } } + ffmpeg_done = true; + }, + Some(upload) = self.tasker.next_task(global) => { + if let Err((task, err)) = upload { + tracing::error!(error = %err, "failed to upload media"); + self.tasker.requeue(task); + } + }, + Some((result, rendition)) = streams.next() => { + self.handle_track(global, rendition, result)?; + }, + else => { + break; } } - None => { - tracing::error!("no settings for variant {}", state.id); - self.report_error("No settings for variant", true).await; - return; - } } - // Common args regardless of copy or transcode mode - #[rustfmt::skip] - args.extend(vec_of_strings![ - "-f", "mp4", - "-movflags", MP4_FLAGS, - "-frag_duration", "1", - format!( - "unix://{}", - socket_dir.join(format!("{}.sock", state.id)).display() - ), - ]); + Ok::<_, anyhow::Error>(()) } - - let mut child = StdCommand::new("ffmpeg"); - - child - .args(&args) - .stdin(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .process_group(0) - .uid(global.config.transcoder.uid) - .gid(global.config.transcoder.gid) - .env_clear() - .env("PATH", std::env::var("PATH").unwrap_or_default()); - - let mut child = match Command::from(child).spawn() { - Ok(c) => c, - Err(err) => { - tracing::error!("failed to spawn ffmpeg: {}", err); - self.report_error("failed to spawn ffmpeg", false).await; - return; + .timeout(Duration::from_secs(5)) + .await + { + Ok(Ok(_)) => {} + Ok(Err(e)) => { + tracing::error!(error = %e, "failed to shutdown transcoder"); } - }; + Err(_) => { + tracing::error!("timeout during shutdown"); + } + } - let mut stdin = child.stdin.take().expect("failed to get stdin"); + self.track_state + .iter_mut() + .map(|(rendition, state)| { + let Some((segment_idx, part_idx)) = state.finish() else { + return *rendition; + }; - let pid = match child.id() { - Some(pid) => Pid::from_raw(pid as i32), - None => { - tracing::error!("failed to get pid"); - self.report_error("failed to get pid", false).await; - return; - } - }; + let key = utils::keys::part( + self.organization_id, + self.room_id, + self.connection_id, + *rendition, + part_idx, + ); - let child = pin!(child.wait_with_output()); - let mut child = SharedFuture::new(child); + let data = state.part(segment_idx, part_idx).unwrap().data.clone(); - let mut shutdown_fuse = pin!(shutdown_token.cancelled().fuse()); + self.tasker.upload_media(key, data); - let mut ready_count = 0; + *rendition + }) + .collect::>() + .into_iter() + .for_each(|rendition| { + self.update_rendition_manifest(rendition); + }); - let (report, rx) = mpsc::channel(10); - let mut report_fut = pin!(report_to_ingest(self.client.clone(), rx)); + while let Some(result) = self.tasker.next_task(global).await { + if let Err((task, err)) = result { + tracing::error!(error = %err, "failed to upload media"); + self.tasker.requeue(task); + } + } - while select! { - r = report_fut.next() => { - tracing::info!("reporting to ingest failed: {:#?}", r); - false - }, - _ = &mut shutdown_fuse => { - report.try_send(TranscoderEventRequest { - request_id: self.req.request_id.clone(), - stream_id: self.req.stream_id.clone(), - event: Some(transcoder_event_request::Event::ShuttingDown(true)), - }).is_ok() - }, - msg = self.stream.next() => self.handle_msg(msg, &mut stdin).await, - // When FFmpeg exits, we need to exit as well. - // This is almost always because the stream was closed. - // So we don't need to report an error, however we check the exit code in the complete_loop function. - // If the exit code is not 0, we report an error. - r = &mut child => { - tracing::info!("ffmpeg exited: {:?}", r); - false - }, - // This shutting down usually implies that the stream was closed. - // So we don't need to report an error. - r = &mut set_lock_fut => { - if let Err(err) = r { - tracing::error!("set lock error: {:#}", err); - } else { - tracing::warn!("set lock done prematurely without error"); + if let Some(shutdown) = self.shutdown.take() { + match shutdown { + ingest_watch_response::Shutdown::Stream => { + // write the playlist states to shutdown } - false - }, - _ = &mut update_playlist_fut => { - tracing::info!("playlist update shutdown while running"); - false - }, - // This shutting down usually implies that the stream was closed. - // So we only report an error if the stream was not closed. - f = futures.next() => { - tracing::info!("variant stream shutdown while running"); - if f.unwrap().is_err() { - self.report_error("variant stream shutdown while running", true).await; - } - false - }, - _ = ready_recv.recv() => { - ready_count += 1; - if ready_count == self.stream_state().transcodes.len() { - tracing::info!("all variants ready"); - report.try_send(TranscoderEventRequest { - request_id: self.req.request_id.clone(), - stream_id: self.req.stream_id.clone(), - event: Some(transcoder_event_request::Event::Started(true)), - }).is_ok() - } else { - true + ingest_watch_response::Shutdown::Transcoder => { + self.send.try_send(IngestWatchRequest { + message: Some(ingest_watch_request::Message::Shutdown( + ingest_watch_request::Shutdown::Complete as i32, + )), + })?; } } - } {} + } - tracing::debug!("shutting down"); - drop(stdin); + Ok(()) + } - select! { - r = self.complete_loop(pid, child, futures.collect::>()).timeout(Duration::from_secs(5)) => { - if let Err(err) = r { - tracing::error!("failed to complete loop: {:#}", err); - self.report_error("failed to complete loop", false).await; - } - }, - r = set_lock_fut => { - if let Err(err) = r { - tracing::error!("set lock error: {:#}", err); - } else { - tracing::warn!("set lock done prematurely without error"); - } - }, + fn update_manifest(&mut self) { + if !self.ready { + return; } - drop(report); - - tracing::debug!("waiting for report to ingest to exit"); + let key = utils::keys::manifest(self.organization_id, self.room_id, self.connection_id); - // Finish all the report futures - while report_fut.next().await.is_some() { - tracing::debug!("report to ingest exited"); + let data: Bytes = LiveManifest { + screenshot_idx: self.screenshot_idx, } + .encode_to_vec() + .into(); - tracing::info!("waiting for playlist update to exit"); + self.tasker.upload_metadata(key, data); + } - if let Err(err) = release_lock( - &global, - &redis_mutex_key(&self.req.stream_id), - &self.req.request_id, - ) - .timeout(Duration::from_secs(2)) - .await - { - tracing::error!("failed to release lock: {:#}", err); + fn update_rendition_manifest(&mut self, rendition: Rendition) { + if !self.ready { + return; + } + + let mut info_map = self + .track_state + .iter() + .map(|(rendition, ts)| { + ( + rendition.to_string(), + live_rendition_manifest::RenditionInfo { + next_part_idx: ts.next_part_idx(), + next_segment_idx: ts.next_segment_idx(), + next_segment_part_idx: ts.next_segment_part_idx(), + }, + ) + }) + .collect::>(); + + let info = info_map.remove(&rendition.to_string()).unwrap(); + + let state = self.track_state.get_mut(&rendition).unwrap(); + + let manifest = LiveRenditionManifest { + info: Some(info), + other_info: info_map, + completed: state.complete() + && self.shutdown == Some(ingest_watch_response::Shutdown::Stream), + timescale: state.timescale(), + total_duration: state.total_duration(), + recording_ulid: None, + segments: state + .segments() + .map(|s| live_rendition_manifest::Segment { + idx: s.idx, + id: None, + parts: s + .parts + .iter() + .map(|p| live_rendition_manifest::Part { + idx: p.idx, + duration: p.duration, + independent: p.independent, + }) + .collect(), + }) + .collect(), }; - tracing::info!("stream shut down"); - } + if &manifest == self.manifests.get(&rendition).unwrap() { + return; + } - async fn complete_loop( - &mut self, - pid: Pid, - mut ffmpeg: impl futures::Future>> + Unpin, - mut variants: impl futures::Future + Unpin, - ) { - tracing::info!("waiting for ffmpeg to exit"); - - let pid = pid.as_raw(); - - let mut timeout = - pin!((&mut ffmpeg) - .timeout(Duration::from_millis(1000)) - .then(|r| async { - if let Ok(r) = r { - tracing::info!("ffmpeg exited: {:?}", r); - - Some(match r.as_ref() { - Ok(r) => !r.status.success(), - Err(_) => true, - }) - } else { - signal::kill(Pid::from_raw(pid), signal::Signal::SIGTERM).ok(); - tracing::debug!("ffmpeg did not exit in time, sending SIGTERM"); + let data = Bytes::from(manifest.encode_to_vec()); - None - } - })); - - let mut variants_done = false; - let r = select! { - r = &mut timeout => r, - _ = &mut variants => { - tracing::info!("variants exited"); - variants_done = true; - timeout.await - }, - }; + let key = utils::keys::rendition_manifest( + self.organization_id, + self.room_id, + self.connection_id, + rendition, + ); + self.tasker.upload_metadata(key, data); - let failed = if let Some(r) = r { - Some(r) - } else { - let timeout = ffmpeg.timeout(Duration::from_secs(2)).then(|r| async { - if let Ok(r) = r { - tracing::info!("ffmpeg exited: {:?}", r); - - Some(match r.as_ref() { - Ok(r) => !r.status.success(), - Err(_) => true, - }) - } else { - None - } - }); + self.manifests.insert(rendition, manifest); + } - if variants_done { - timeout.await - } else { - variants_done = true; - tokio::join!(timeout, &mut variants).0 - } + async fn fetch_manifests(&mut self, global: &Arc) -> Result<()> { + let rendition_manfiests = async { + futures_util::future::try_join_all(self.track_state.keys().map(|rendition| { + global + .metadata_store + .get(utils::keys::rendition_manifest( + self.organization_id, + self.room_id, + self.connection_id, + *rendition, + )) + .map_ok(|v| (*rendition, v)) + })) + .await }; - let failed = failed.unwrap_or_else(|| { - tracing::error!("ffmpeg did not exit in time, sending SIGKILL"); - signal::kill(Pid::from_raw(pid), signal::Signal::SIGKILL).ok(); - true - }); + let manifest = async { + global + .metadata_store + .get(utils::keys::manifest( + self.organization_id, + self.room_id, + self.connection_id, + )) + .await + }; - if !variants_done { - tracing::info!("waiting for variants to exit"); - variants.await; - } + let (rendition_manfiests, manifest) = try_join!(rendition_manfiests, manifest)?; - if failed { - self.report_error("ffmpeg exited with non-zero status", false) - .await; + if rendition_manfiests.iter().all(|(_, v)| v.is_none()) && manifest.is_none() { + return Ok(()); } - tracing::debug!("ffmpeg exited"); - } - - async fn handle_msg( - &mut self, - msg: Option>, - stdin: &mut ChildStdin, - ) -> bool { - tracing::debug!("recieved message"); - let msg = match msg { - Some(Ok(msg)) => msg.data, - _ => { - // We should have gotten a shutting down event - // TODO: report this to API server - tracing::error!("unexpected stream closed"); - return false; - } + let Some(manifest) = manifest else { + anyhow::bail!("missing manifest"); }; - let Some(msg) = msg else { - tracing::error!("recieved empty response"); - return false; - }; + let manifest = LiveManifest::decode(manifest)?; - match msg { - watch_stream_response::Data::InitSegment(data) => { - if stdin.write_all(&data).await.is_err() { - // This is almost always because ffmpeg crashed - // We report an error when we check the exit code - return false; - } - } - watch_stream_response::Data::MediaSegment(ms) => { - if stdin.write_all(&ms.data).await.is_err() { - // This is almost always because ffmpeg crashed - // We report an error when we check the exit code - return false; - } - } - watch_stream_response::Data::ShuttingDown(stream) => { - tracing::info!(stream = stream, "shutting down"); - return false; - } - } + self.screenshot_idx = manifest.screenshot_idx; - true - } + for (rendition, data) in rendition_manfiests { + let Some(data) = data else { + anyhow::bail!("missing manifest for rendition {}", rendition); + }; - async fn report_error(&mut self, err: impl ToString + Send + Sync, fatal: bool) { - if let Err(err) = self - .client - .transcoder_event(TranscoderEventRequest { - request_id: self.req.request_id.clone(), - stream_id: self.req.stream_id.clone(), - event: Some(transcoder_event_request::Event::Error( - transcoder_event_request::Error { - message: err.to_string(), - fatal, - }, - )), - }) - .timeout(Duration::from_secs(2)) - .await - { - tracing::error!("failed to report error: {}", err); + let manifest = LiveRenditionManifest::decode(data)?; + + self.track_state + .get_mut(&rendition) + .unwrap() + .apply_manifest(&manifest); + + self.manifests.insert(rendition, manifest); } + + Ok(()) } } diff --git a/video/transcoder/src/transcoder/job/renditions.rs b/video/transcoder/src/transcoder/job/renditions.rs index 123772c3..10d3362a 100644 --- a/video/transcoder/src/transcoder/job/renditions.rs +++ b/video/transcoder/src/transcoder/job/renditions.rs @@ -1,51 +1,139 @@ -use std::{collections::HashMap, sync::atomic::AtomicU32}; +use pb::scuffle::video::v1::types::{AudioConfig, Rendition, TranscodingConfig, VideoConfig}; -#[derive(Debug, Default)] -pub struct RenditionMap { - map: HashMap, -} +use mp4::codec::VideoCodec; + +pub fn determine_output_renditions( + video_input: &VideoConfig, + audio_input: &AudioConfig, + transcoding_config: &TranscodingConfig, +) -> (Vec, Vec) { + let mut audio_configs = vec![]; + let mut video_configs = vec![]; -impl RenditionMap { - pub fn new() -> Self { - Self::default() + if transcoding_config + .renditions + .contains(&Rendition::AudioSource.into()) + { + audio_configs.push(AudioConfig { + rendition: Rendition::AudioSource as i32, + codec: audio_input.codec.clone(), + bitrate: audio_input.bitrate, + channels: audio_input.channels, + sample_rate: audio_input.sample_rate, + }); } - pub fn renditions(&self) -> Vec { - self.map - .iter() - .map(|(id, r)| Rendition { - id: id.clone(), - last_msn: r.last_msn.load(std::sync::atomic::Ordering::Relaxed), - last_part: r.last_part.load(std::sync::atomic::Ordering::Relaxed), - }) - .collect() + if transcoding_config + .renditions + .contains(&Rendition::VideoSource.into()) + { + video_configs.push(VideoConfig { + rendition: Rendition::VideoSource as i32, + codec: video_input.codec.clone(), + bitrate: video_input.bitrate, + fps: video_input.fps, + height: video_input.height, + width: video_input.width, + }); + } + + let aspect_ratio = video_input.width as f64 / video_input.height as f64; + + struct Resolution { + rendition: Rendition, + side: u32, + framerate: u32, + bitrate: u32, } - pub fn set(&self, id: &str, last_msn: u32, last_part: u32) -> bool { - if let Some(r) = self.map.get(id) { - r.last_msn - .store(last_msn, std::sync::atomic::Ordering::Relaxed); - r.last_part - .store(last_part, std::sync::atomic::Ordering::Relaxed); - true + let mut resolutions = vec![]; + + if transcoding_config + .renditions + .contains(&Rendition::VideoHd.into()) + { + resolutions.push(Resolution { + rendition: Rendition::VideoHd, + bitrate: 4000 * 1024, + framerate: video_input.fps.min(60) as u32, + side: 720, + }); + } + + if transcoding_config + .renditions + .contains(&Rendition::VideoSd.into()) + { + resolutions.push(Resolution { + rendition: Rendition::VideoSd, + bitrate: 2000 * 1024, + framerate: video_input.fps.min(30) as u32, + side: 480, + }); + } + + if transcoding_config + .renditions + .contains(&Rendition::VideoLd.into()) + { + resolutions.push(Resolution { + rendition: Rendition::VideoLd, + bitrate: 1000 * 1024, + framerate: video_input.fps.min(30) as u32, + side: 360, + }) + } + + for res in resolutions { + // This prevents us from upscaling the video + // We only want to downscale the video + let (width, height) = if aspect_ratio > 1.0 && video_input.height as u32 > res.side { + ((res.side as f64 * aspect_ratio).round() as u32, res.side) + } else if aspect_ratio < 1.0 && video_input.width as u32 > res.side { + (res.side, (res.side as f64 / aspect_ratio).round() as u32) } else { - false + continue; + }; + + // We dont want to transcode video with resolutions less than 100px on either side + // We also do not want to transcode anything more expensive than 720p on a 16:9 aspect ratio (720 * 1280) + // This prevents us from transcoding a "720p" with an aspect ratio of 4:1 (720 * 2880) which is extremely expensive. + // Just some insight, 2880 / 1280 = 2.25, so this video is 2.25 times more expensive than a normal 720p video. + // 1080 * 1920 = 2073600 + // 720 * 2880 = 2073600 + // So a 720p video with an aspect ratio of 4:1 is just as expensive as a 1080p video with a 16:9 aspect ratio. + if width < 100 || height < 100 || width * height > 720 * 1280 { + continue; } - } - pub fn insert(&mut self, id: String) { - self.map.insert(id, AtomicRendition::default()); + video_configs.push(VideoConfig { + rendition: res.rendition as i32, + codec: VideoCodec::Avc { + profile: 100, // High + level: 51, // 5.1 + constraint_set: 0, + } + .to_string(), + bitrate: res.bitrate as i64, + fps: res.framerate as i32, + height: height as i32, + width: width as i32, + }) } -} -#[derive(Debug, Default)] -struct AtomicRendition { - last_msn: AtomicU32, - last_part: AtomicU32, + (video_configs, audio_configs) } -pub struct Rendition { - pub id: String, - pub last_msn: u32, - pub last_part: u32, +pub fn screenshot_size(video_input: &VideoConfig) -> (i32, i32) { + let aspect_ratio = video_input.width as f64 / video_input.height as f64; + + let (width, height) = if aspect_ratio > 1.0 && video_input.height as u32 > 720 { + ((720.0 * aspect_ratio).round() as i32, 720) + } else if aspect_ratio < 1.0 && video_input.width as u32 > 720 { + (720, (720.0 / aspect_ratio).round() as i32) + } else { + (video_input.width, video_input.height) + }; + + (width, height) } diff --git a/video/transcoder/src/transcoder/job/track_parser.rs b/video/transcoder/src/transcoder/job/track_parser.rs index dcf4ed9f..5b966b0e 100644 --- a/video/transcoder/src/transcoder/job/track_parser.rs +++ b/video/transcoder/src/transcoder/job/track_parser.rs @@ -1,4 +1,4 @@ -use std::io; +use std::{io, pin::pin}; use anyhow::anyhow; use async_stream::stream; @@ -15,7 +15,7 @@ pub enum TrackOut { // a Ftyp and Moov box are always sent at the start of a stream Moov(Moov), // A moof and mdat box are sent for each segment - Sample(TrackSample), + Samples(Vec), } #[derive(Debug, Clone)] @@ -27,11 +27,13 @@ pub struct TrackSample { } pub fn track_parser( - mut input: impl Stream> + Unpin, -) -> impl Stream> { + input: impl Stream> + Send, +) -> impl Stream> + Send { stream!({ let mut buffer = BytesMut::new(); + let mut input = pin!(input); + // Main loop for parsing the stream while let Some(data) = input.next().await { buffer.extend_from_slice(&data?); @@ -134,34 +136,39 @@ pub fn track_parser( } let mut mdat_cursor = io::Cursor::new(mdat.data[0].clone()); - for sample in samples { - let data = if let Some(size) = sample.size { - mdat_cursor.read_slice(size as usize).map_err(|e| { - io::Error::new( + yield Ok(TrackOut::Samples( + samples + .map(|sample| { + let data = + if let Some(size) = sample.size { + mdat_cursor.read_slice(size as usize).map_err(|e| { + io::Error::new( io::ErrorKind::InvalidData, anyhow!("mdat data size not big enough for sample: {}", e), ) - })? - } else { - mdat_cursor.get_remaining() - }; - - yield Ok(TrackOut::Sample(TrackSample { - duration: sample.duration.unwrap_or_default(), - keyframe: sample - .flags - .map(|f| f.sample_depends_on == 2) - .unwrap_or_default(), - sample, - data, - })); - } + })? + } else { + mdat_cursor.extract_remaining() + }; + + io::Result::Ok(TrackSample { + duration: sample.duration.unwrap_or_default(), + keyframe: sample + .flags + .map(|f| f.sample_depends_on == 2) + .unwrap_or_default(), + sample, + data, + }) + }) + .collect::, _>>()?, + )); } _ => {} } } - buffer.extend_from_slice(&cursor.get_remaining()); + buffer.extend_from_slice(&cursor.extract_remaining()); } }) } diff --git a/video/transcoder/src/transcoder/job/utils.rs b/video/transcoder/src/transcoder/job/utils.rs deleted file mode 100644 index 810c00cd..00000000 --- a/video/transcoder/src/transcoder/job/utils.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::global::GlobalState; -use anyhow::{anyhow, Result}; -use async_stream::stream; -use bytes::Bytes; -use bytesio::{bytesio::BytesIO, bytesio_errors::BytesIOError}; -use common::prelude::FutureTimeout; -use fred::interfaces::KeysInterface; -use fred::types::{Expiration, SetOptions}; -use futures_util::FutureExt; -use std::time::Duration; -use std::{io, sync::Arc}; -use tokio::net::UnixListener; -use tokio_util::sync::CancellationToken; - -pub fn unix_stream( - listener: UnixListener, - buffer_size: usize, -) -> impl futures::Stream> { - stream!({ - let (sock, _) = match listener.accept().timeout(Duration::from_secs(1)).await { - Ok(Ok(connection)) => connection, - Ok(Err(err)) => { - yield Err(err); - return; - } - Err(_) => { - // Timeout - tracing::debug!("unix stream timeout"); - return; - } - }; - - tracing::debug!("accepted connection"); - - let mut bio = BytesIO::with_capacity(sock, buffer_size); - - loop { - match bio.read().await { - Ok(bytes) => { - yield Ok(bytes.freeze()); - } - Err(err) => match err { - BytesIOError::ClientClosed => { - return; - } - _ => { - yield Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - anyhow!("failed to read from socket: {}", err), - )); - } - }, - } - } - }) -} - -pub struct SharedFuture> { - inner: F, - output: Option>, -} - -impl + Unpin> SharedFuture { - pub fn new(inner: F) -> Self { - Self { - inner, - output: None, - } - } -} - -impl + Unpin> futures::Future for SharedFuture { - type Output = Arc; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - let this = self.get_mut(); - if let Some(output) = this.output.as_ref() { - return std::task::Poll::Ready(output.clone()); - } - - let output = futures::ready!(this.inner.poll_unpin(cx)); - let output = Arc::new(output); - this.output = Some(output.clone()); - std::task::Poll::Ready(output) - } -} - -pub async fn set_lock( - global: Arc, - key: String, - req_id: String, - owned: CancellationToken, -) -> Result<()> { - loop { - let have_lock: String = global - .redis - .set( - &key, - &req_id, - Some(Expiration::EX(5)), - Some(SetOptions::NX), - true, - ) - .await?; - if have_lock == req_id { - break; - } - - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - } - - owned.cancel(); - - let mut timer = tokio::time::interval(tokio::time::Duration::from_secs(1)); - loop { - timer.tick().await; - - let lock_owner: String = global - .redis - .set( - &key, - &req_id, - Some(Expiration::EX(5)), - Some(SetOptions::XX), - true, - ) - .await?; - if lock_owner != req_id { - return Err(anyhow!("lost lock")); - } - } -} - -pub async fn release_lock(global: &Arc, key: &str, request_id: &str) -> Result<()> { - let lock_owner: String = global.redis.get(key).await?; - - if lock_owner == request_id { - global.redis.del(key).await?; - } - - Ok(()) -} diff --git a/video/transcoder/src/transcoder/job/utils/breakpoint.rs b/video/transcoder/src/transcoder/job/utils/breakpoint.rs new file mode 100644 index 00000000..6266bb05 --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/breakpoint.rs @@ -0,0 +1,218 @@ +use std::collections::VecDeque; + +use crate::transcoder::job::track_parser::TrackSample; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BreakType { + Part, + Segment, +} + +#[derive(Debug)] +pub struct BreakpointState<'a> { + samples: &'a VecDeque, + timescale: u32, + idx: usize, + break_points: Vec<(usize, BreakType)>, + durations: Durations, + potential_breaks: PotentialBreaks, +} + +#[derive(Debug)] +struct Durations { + part: u32, + segment: u32, + last_part: u32, +} + +#[derive(Debug)] +struct PotentialBreaks { + part: Option<(usize, u32, u32)>, + segment: Option<(usize, f64)>, +} + +impl<'a> BreakpointState<'a> { + pub fn new(timescale: u32, segment_duration: u32, samples: &'a VecDeque) -> Self { + Self { + samples, + timescale, + idx: 0, + break_points: vec![], + durations: Durations { + part: 0, + segment: segment_duration, + last_part: 0, + }, + potential_breaks: PotentialBreaks { + part: None, + segment: None, + }, + } + } + + pub fn into_breakpoints(self) -> Vec<(usize, BreakType)> { + self.break_points + } + + pub fn increment(&mut self) { + self.idx += 1; + } + + pub fn add_duration(&mut self) { + let duration = self.current_sample_duration(); + + self.durations.part += duration; + self.durations.segment += duration; + } + + pub fn process_segment_break( + &mut self, + target_segment_duration: f64, + max_part_duration: f64, + ) -> bool { + if self.is_segment_break(target_segment_duration) + && self.add_segment_break(max_part_duration) + { + true + } else if let Some(idx) = self.check_potential_segment_break(max_part_duration) { + self.idx = idx; + self.force_segment_break(max_part_duration); + true + } else { + false + } + } + + pub fn process_part_break( + &mut self, + target_part_duration: f64, + max_part_duration: f64, + ) -> bool { + if self.is_part_break(target_part_duration) && self.add_part_break() { + true + } else if let Some((idx, part_duration, segment_duration)) = + self.check_potential_part_break(max_part_duration) + { + self.idx = idx; + self.durations.part = part_duration; + self.durations.segment = segment_duration; + self.force_part_break(); + true + } else { + false + } + } + + fn segment_time(&self) -> f64 { + (self.durations.segment - self.current_sample_duration()) as f64 / self.timescale as f64 + } + + fn part_time(&self) -> f64 { + self.durations.part as f64 / self.timescale as f64 + } + + fn is_perfect_segment_break(&self) -> bool { + (self.segment_time() * 1000.0).fract() == 0.0 + } + + fn is_perfect_part_break(&self) -> bool { + (self.part_time() * 1000.0).fract() == 0.0 + } + + fn is_part_break(&self, target_part_duration: f64) -> bool { + self.potential_breaks.segment.is_none() && self.part_time() >= target_part_duration + } + + fn is_segment_break(&self, target_segment_duration: f64) -> bool { + self.current_sample() + .map(|s| s.keyframe) + .unwrap_or_default() + && self.segment_time() >= target_segment_duration + } + + fn merge_last_breakpoint(&mut self, max_part_duration: f64) { + if let Some((_, breaktype)) = self.break_points.last() { + if *breaktype == BreakType::Part + && (self.durations.last_part + self.durations.part - self.current_sample_duration()) + as f64 + / self.timescale as f64 + <= max_part_duration + { + // If the last break point was a part break, and the last part duration + this part duration is less than the max part duration, we remove the last break point. + // This is because we want to merge the last part with this part. + self.break_points.pop(); + } + } + } + + fn check_potential_segment_break(&mut self, max_part_duration: f64) -> Option { + if let Some((idx, t)) = self.potential_breaks.segment { + if t + max_part_duration < self.segment_time() { + return Some(idx); + } + } + + None + } + + fn check_potential_part_break(&self, max_part_duration: f64) -> Option<(usize, u32, u32)> { + if self.part_time() >= max_part_duration { + self.potential_breaks.part + } else { + None + } + } + + fn add_segment_break(&mut self, max_part_duration: f64) -> bool { + if self.is_perfect_segment_break() { + self.force_segment_break(max_part_duration); + true + } else if self.potential_breaks.segment.is_none() { + self.potential_breaks.segment = Some((self.idx, self.segment_time())); + false + } else { + false + } + } + + fn add_part_break(&mut self) -> bool { + if self.is_perfect_part_break() { + self.force_part_break(); + true + } else if self.potential_breaks.part.is_none() { + self.potential_breaks.part = + Some((self.idx, self.durations.part, self.durations.segment)); + false + } else { + false + } + } + + fn force_segment_break(&mut self, max_part_duration: f64) { + self.merge_last_breakpoint(max_part_duration); + + self.break_points.push((self.idx, BreakType::Segment)); + self.durations.segment = self.current_sample_duration(); + self.durations.part = self.current_sample_duration(); + self.potential_breaks.part = None; + self.potential_breaks.segment = None; + } + + fn force_part_break(&mut self) { + self.break_points.push((self.idx + 1, BreakType::Part)); + self.durations.last_part = self.durations.part; + self.durations.part = 0; + self.potential_breaks.part = None; + self.potential_breaks.segment = None; + } + + pub fn current_sample(&self) -> Option<&TrackSample> { + self.samples.get(self.idx) + } + + fn current_sample_duration(&self) -> u32 { + self.current_sample() + .map(|s| s.duration) + .unwrap_or_default() + } +} diff --git a/video/transcoder/src/transcoder/job/utils/ffmpeg.rs b/video/transcoder/src/transcoder/job/utils/ffmpeg.rs new file mode 100644 index 00000000..16bd916b --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/ffmpeg.rs @@ -0,0 +1,279 @@ +use std::{os::unix::process::CommandExt, path::Path}; + +use common::vec_of_strings; +use mp4::codec::{AudioCodec, VideoCodec}; +use pb::scuffle::video::v1::types::{AudioConfig, Rendition as PbRendition, VideoConfig}; +use tokio::process::{Child, Command}; +use video_database::rendition::Rendition; + +pub fn spawn_ffmpeg( + gid: u32, + uid: u32, + socket_dir: &Path, + video_output: &[VideoConfig], + audio_output: &[AudioConfig], +) -> anyhow::Result { + let filter_graph_items = video_output + .iter() + .filter(|v| v.rendition() != PbRendition::VideoSource) + .collect::>(); + + let filter_graph = filter_graph_items + .iter() + .enumerate() + .map(|(i, settings)| { + let previous = if i == 0 { + "[0:v]".to_string() + } else { + format!("[{}_out]", i - 1) + }; + + let rendition = Rendition::from(settings.rendition()); + + format!( + "{}scale={}:{},pad=ceil(iw/2)*2:ceil(ih/2)*2{}", + previous, + settings.width, + settings.height, + if i == filter_graph_items.len() - 1 { + format!("[{rendition}]") + } else { + format!(",split=2[{rendition}][{i}_out]") + } + ) + }) + .collect::>() + .join(";"); + + const MP4_FLAGS: &str = "+frag_keyframe+empty_moov+default_base_moof"; + + #[rustfmt::skip] + let mut args = vec_of_strings![ + "-v", "error", + "-i", "-", + "-probesize", "250M", + "-analyzeduration", "250M", + "-max_muxing_queue_size", "1024", + ]; + + if !filter_graph.is_empty() { + args.extend(vec_of_strings!["-filter_complex", filter_graph]); + } + + for output in video_output { + let rendition = Rendition::from(output.rendition()); + + if output.rendition() == PbRendition::VideoSource { + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", "0:v", + "-c:v", "copy", + ]); + } else { + let codec: VideoCodec = match output.codec.parse() { + Ok(c) => c, + Err(err) => { + anyhow::bail!("invalid video codec: {}", err); + } + }; + + match codec { + VideoCodec::Avc { profile, level, .. } => { + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", format!("[{rendition}]"), + "-c:v", "libx264", + "-preset", "medium", + "-b:v", format!("{}", output.bitrate), + "-maxrate", format!("{}", output.bitrate), + "-bufsize", format!("{}", output.bitrate * 2), + "-profile:v", match profile { + 66 => "baseline", + 77 => "main", + 100 => "high", + _ => { + anyhow::bail!("invalid avc profile: {}", profile); + }, + }, + "-level:v", match level { + 30 => "3.0", + 31 => "3.1", + 32 => "3.2", + 40 => "4.0", + 41 => "4.1", + 42 => "4.2", + 50 => "5.0", + 51 => "5.1", + 52 => "5.2", + 60 => "6.0", + 61 => "6.1", + 62 => "6.2", + _ => { + anyhow::bail!("invalid avc level: {}", level); + }, + }, + "-pix_fmt", "yuv420p", + "-g", format!("{}", output.fps * 2), + "-keyint_min", format!("{}", output.fps * 2), + "-sc_threshold", "0", + "-r", format!("{}", output.fps), + "-crf", "23", + "-tune", "zerolatency", + ]); + } + VideoCodec::Av1 { .. } => { + anyhow::bail!("av1 is not supported"); + } + VideoCodec::Hevc { .. } => { + anyhow::bail!("hevc is not supported"); + } + } + } + + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-f", "mp4", + "-movflags", MP4_FLAGS, + "-frag_duration", "1", + format!( + "unix://{}", + socket_dir.join(format!("{}.sock", rendition)).display() + ), + ]); + } + + for output in audio_output { + let rendition = Rendition::from(output.rendition()); + + if output.rendition() == PbRendition::AudioSource { + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", "0:a", + "-c:a", "copy", + ]); + } else { + let codec: AudioCodec = match output.codec.parse() { + Ok(c) => c, + Err(err) => { + anyhow::bail!("invalid audio codec: {}", err); + } + }; + + match codec { + AudioCodec::Aac { object_type } => { + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", "0:a", + "-c:a", "aac", + "-b:a", format!("{}", output.bitrate), + "-ar", format!("{}", output.sample_rate), + "-ac", format!("{}", output.channels), + "-profile:a", + match object_type { + aac::AudioObjectType::AacLowComplexity => { + "aac_low" + } + aac::AudioObjectType::AacMain => { + "aac_main" + } + aac::AudioObjectType::Unknown(profile) => { + anyhow::bail!("invalid aac profile: {}", profile); + } + }, + ]); + } + AudioCodec::Opus => { + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", "0:a", + "-c:a", "libopus", + "-b:a", format!("{}", output.bitrate), + "-ar", format!("{}", output.sample_rate), + "-ac", format!("{}", output.channels), + ]); + } + } + } + + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-f", "mp4", + "-movflags", MP4_FLAGS, + "-frag_duration", "1", + format!( + "unix://{}", + socket_dir.join(format!("{}.sock", rendition)).display() + ), + ]); + } + + let mut child = std::process::Command::new("ffmpeg"); + + child + .args(&args) + .stdin(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::null()) + .process_group(0) + .uid(uid) + .gid(gid) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()); + + Ok(match Command::from(child).kill_on_drop(true).spawn() { + Ok(c) => c, + Err(err) => { + anyhow::bail!("failed to spawn ffmpeg: {}", err); + } + }) +} + +pub fn spawn_ffmpeg_screenshot( + gid: u32, + uid: u32, + width: i32, + height: i32, +) -> anyhow::Result { + let args = vec_of_strings![ + "-v", + "error", + "-i", + "-", + "-threads", + "1", + "-analyzeduration", + "32", + "-probesize", + "32", + "-frames:v", + "1", + "-f", + "image2pipe", + "-c:v", + "mjpeg", + "-vf", + format!("scale={}:{}", width, height), + "-y", + "-", + ]; + + let mut child = std::process::Command::new("ffmpeg"); + + child + .args(&args) + .stdin(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::piped()) + .process_group(0) + .uid(uid) + .gid(gid) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()); + + Ok(match Command::from(child).kill_on_drop(true).spawn() { + Ok(c) => c, + Err(err) => { + anyhow::bail!("failed to spawn ffmpeg: {}", err); + } + }) +} diff --git a/video/transcoder/src/transcoder/job/utils/keys.rs b/video/transcoder/src/transcoder/job/utils/keys.rs new file mode 100644 index 00000000..ba3e77fc --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/keys.rs @@ -0,0 +1,38 @@ +use ulid::Ulid; +use video_database::rendition::Rendition; + +pub fn part( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, + part_idx: u32, +) -> String { + format!("{organization_id}.{room_id}.{connection_id}.part.{rendition}.{part_idx}",) +} + +pub fn rendition_manifest( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, +) -> String { + format!("{organization_id}.{room_id}.{connection_id}.manifest.{rendition}",) +} + +pub fn manifest(organization_id: Ulid, room_id: Ulid, connection_id: Ulid) -> String { + format!("{organization_id}.{room_id}.{connection_id}.manifest",) +} + +pub fn init( + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, + rendition: Rendition, +) -> String { + format!("{organization_id}.{room_id}.{connection_id}.init.{rendition}",) +} + +pub fn screenshot(organization_id: Ulid, room_id: Ulid, connection_id: Ulid, idx: u32) -> String { + format!("{organization_id}.{room_id}.{connection_id}.screenshot.{idx}",) +} diff --git a/video/transcoder/src/transcoder/job/utils/mod.rs b/video/transcoder/src/transcoder/job/utils/mod.rs new file mode 100644 index 00000000..bdd1bb2b --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/mod.rs @@ -0,0 +1,14 @@ +mod breakpoint; +mod ffmpeg; +mod sql_operations; +mod tasker; +mod track_state; +mod unix_stream; + +pub mod keys; + +pub use ffmpeg::{spawn_ffmpeg, spawn_ffmpeg_screenshot}; +pub use sql_operations::{perform_sql_operations, SqlOperations}; +pub use tasker::{TaskError, TaskJob, Tasker}; +pub use track_state::{Part, Segment, TrackState}; +pub use unix_stream::{bind_socket, unix_stream}; diff --git a/video/transcoder/src/transcoder/job/utils/sql_operations.rs b/video/transcoder/src/transcoder/job/utils/sql_operations.rs new file mode 100644 index 00000000..8b1451e4 --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/sql_operations.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use pb::scuffle::video::v1::types::{ + AudioConfig, RecordingConfig, Rendition, TranscodingConfig, VideoConfig, +}; +use prost::Message; +use ulid::Ulid; +use uuid::Uuid; +use video_database::room::Room; + +use crate::{global::GlobalState, transcoder::job::renditions::determine_output_renditions}; + +pub struct SqlOperations { + pub transcoding_config: TranscodingConfig, + pub recording_config: Option, + pub video_input: VideoConfig, + pub audio_input: AudioConfig, + pub video_output: Vec, + pub audio_output: Vec, +} + +pub async fn perform_sql_operations( + global: &Arc, + organization_id: Ulid, + room_id: Ulid, + connection_id: Ulid, +) -> anyhow::Result { + let room: Option = match sqlx::query_as( + r#" + SELECT + * + FROM rooms + WHERE + organization_id = $1 AND + id = $2 AND + active_ingest_connection_id = $3 + "#, + ) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(Uuid::from(connection_id)) + .fetch_optional(global.db.as_ref()) + .await + { + Ok(r) => r, + Err(err) => { + anyhow::bail!("failed to query room: {}", err); + } + }; + + let Some(room) = room else { + anyhow::bail!("room not found"); + }; + + let Some(video_input) = room.video_input else { + anyhow::bail!("room has no video input"); + }; + let video_input = video_input.0; + + let Some(audio_input) = room.audio_input else { + anyhow::bail!("room has no audio input"); + }; + let audio_input = audio_input.0; + + let recording_config = if let Some(recording_config) = room.active_recording_config { + Some(recording_config.0) + } else if let Some(recording_config_id) = &room.recording_config_id { + Some( + match sqlx::query_as::<_, video_database::recording_config::RecordingConfig>( + "SELECT * FROM recording_configs WHERE organization_id = $1 AND id = $2", + ) + .bind(Uuid::from(organization_id)) + .bind(recording_config_id) + .fetch_one(global.db.as_ref()) + .await + { + Ok(r) => r.into_proto(), + Err(err) => { + anyhow::bail!("failed to query recording config: {}", err); + } + }, + ) + } else { + None + }; + + let transcoding_config = if let Some(transcoding_config) = room.active_transcoding_config { + transcoding_config.0 + } else if let Some(transcoding_config_id) = &room.transcoding_config_id { + match sqlx::query_as::<_, video_database::transcoding_config::TranscodingConfig>( + "SELECT * FROM transcoding_configs WHERE organization_id = $1 AND id = $2", + ) + .bind(Uuid::from(organization_id)) + .bind(*transcoding_config_id) + .fetch_one(global.db.as_ref()) + .await + { + Ok(r) => r.into_proto(), + Err(err) => { + anyhow::bail!("failed to query transcoding config: {}", err); + } + } + } else { + TranscodingConfig { + renditions: vec![Rendition::AudioSource.into(), Rendition::VideoSource.into()], + ..Default::default() + } + }; + + let (video_output, audio_output) = + determine_output_renditions(&video_input, &audio_input, &transcoding_config); + + sqlx::query( + r#" + UPDATE rooms + SET + updated_at = NOW(), + active_transcoding_config = $1, + active_recording_config = $2, + video_output = $3, + audio_output = $4 + WHERE + organization_id = $5 AND + id = $6 AND + active_ingest_connection_id = $7 + "#, + ) + .bind(transcoding_config.encode_to_vec()) + .bind(recording_config.as_ref().map(|v| v.encode_to_vec())) + .bind( + video_output + .iter() + .map(|v| v.encode_to_vec()) + .collect::>(), + ) + .bind( + audio_output + .iter() + .map(|v| v.encode_to_vec()) + .collect::>(), + ) + .bind(Uuid::from(organization_id)) + .bind(Uuid::from(room_id)) + .bind(Uuid::from(connection_id)) + .execute(global.db.as_ref()) + .await?; + + Ok(SqlOperations { + transcoding_config, + recording_config, + video_input, + audio_input, + video_output, + audio_output, + }) +} diff --git a/video/transcoder/src/transcoder/job/utils/tasker.rs b/video/transcoder/src/transcoder/job/utils/tasker.rs new file mode 100644 index 00000000..48e7aaa0 --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/tasker.rs @@ -0,0 +1,184 @@ +use std::{collections::VecDeque, sync::Arc}; + +use bytes::Bytes; +use futures_util::future::BoxFuture; + +use crate::global::GlobalState; + +pub type TaskFuture = BoxFuture<'static, Result<(), TaskError>>; + +type TaskGenerator = Arc) -> TaskFuture + Send + Sync>; + +#[derive(Clone)] +pub enum TaskJob { + UploadMetadata(Bytes), + UploadMedia(Bytes), + DeleteMedia, + Custom(TaskGenerator), +} + +impl std::fmt::Debug for TaskJob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskJob::UploadMetadata(_) => write!(f, "UploadMetadata"), + TaskJob::UploadMedia(_) => write!(f, "UploadMedia"), + TaskJob::DeleteMedia => write!(f, "DeleteMedia"), + TaskJob::Custom(_) => write!(f, "Custom"), + } + } +} + +#[derive(Clone, Debug)] +pub struct Task { + job: TaskJob, + key: String, + retry_count: u32, +} + +impl Task { + pub fn new(key: String, job: TaskJob) -> Self { + Self { + job, + key, + retry_count: 0, + } + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn retry(&mut self) { + self.retry_count += 1; + } + + pub fn retry_count(&self) -> u32 { + self.retry_count + } + + fn run(&self, global: &Arc) -> TaskFuture { + let global = global.clone(); + + match &self.job { + TaskJob::UploadMetadata(data) => { + let key = self.key.clone(); + let data = data.clone(); + Box::pin(async move { + global.metadata_store.put(&key, data).await?; + Ok(()) + }) + } + TaskJob::UploadMedia(data) => { + let key = self.key.clone(); + let data = data.clone(); + Box::pin(async move { + let mut cursor = std::io::Cursor::new(data); + + global.media_store.put(key.as_str(), &mut cursor).await?; + Ok(()) + }) + } + TaskJob::DeleteMedia => { + let key = self.key.clone(); + Box::pin(async move { + global.media_store.delete(&key).await?; + Ok(()) + }) + } + TaskJob::Custom(f) => f(&self.key, global), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TaskError { + #[error("failed to upload metadata: {0}")] + UploadMetadata(#[from] async_nats::jetstream::kv::PutError), + #[error("failed to upload media: {0}")] + UploadMedia(#[from] async_nats::jetstream::object_store::PutError), + #[error("failed to delete metadata: {0}")] + DeleteMetadata(#[from] async_nats::jetstream::kv::UpdateError), + #[error("failed to delete media: {0}")] + DeleteMedia(#[from] async_nats::jetstream::object_store::DeleteError), + #[error("custom task failed: {0}")] + Custom(#[from] anyhow::Error), +} + +struct ActiveTask { + task: Task, + future: TaskFuture, +} + +pub struct Tasker { + tasks: VecDeque, + active_task: Option, +} + +impl Tasker { + pub fn new() -> Self { + Self { + tasks: VecDeque::new(), + active_task: None, + } + } + + pub fn requeue(&mut self, mut task: Task) { + task.retry(); + self.tasks.push_front(task); + } + + pub fn custom( + &mut self, + key: String, + f: impl Fn(&str, Arc) -> BoxFuture<'static, Result<(), TaskError>> + + Send + + Sync + + 'static, + ) { + self.abort_task(&key); + self.tasks + .push_back(Task::new(key, TaskJob::Custom(Arc::new(f)))); + } + + pub fn upload_metadata(&mut self, key: String, data: Bytes) { + self.abort_task(&key); + self.tasks + .push_back(Task::new(key, TaskJob::UploadMetadata(data))); + } + + pub fn upload_media(&mut self, key: String, data: Bytes) { + self.abort_task(&key); + self.tasks + .push_back(Task::new(key, TaskJob::UploadMedia(data))); + } + + pub fn delete_media(&mut self, key: String) { + self.abort_task(&key); + self.tasks.push_back(Task::new(key, TaskJob::DeleteMedia)); + } + + pub fn abort_task(&mut self, key: &str) { + self.tasks.retain(|task| task.key() != key); + } + + pub async fn next_task( + &mut self, + global: &Arc, + ) -> Option> { + if self.active_task.is_none() { + let task = self.tasks.pop_front()?; + let future = task.run(global); + self.active_task = Some(ActiveTask { task, future }); + } + + let active_task = self.active_task.as_mut().unwrap(); + let result = active_task.future.as_mut().await; + let active_task = self.active_task.take().unwrap(); + + if let Err(e) = result { + Some(Err((active_task.task, e))) + } else { + Some(Ok(active_task.task)) + } + } +} diff --git a/video/transcoder/src/transcoder/job/utils/track_state.rs b/video/transcoder/src/transcoder/job/utils/track_state.rs new file mode 100644 index 00000000..81a2ea74 --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/track_state.rs @@ -0,0 +1,417 @@ +use std::collections::VecDeque; + +use bytes::Bytes; +use bytesio::bytes_writer::BytesWriter; +use mp4::{ + types::{ + ftyp::{FourCC, Ftyp}, + mdat::Mdat, + mfhd::Mfhd, + moof::Moof, + moov::Moov, + mvex::Mvex, + mvhd::Mvhd, + tfdt::Tfdt, + tfhd::Tfhd, + traf::Traf, + trex::Trex, + trun::Trun, + }, + BoxType, +}; +use pb::scuffle::video::internal::LiveRenditionManifest; + +use crate::transcoder::job::track_parser::TrackSample; + +use super::breakpoint::{BreakType, BreakpointState}; + +#[derive(Default, Clone)] +pub struct Part { + pub data: Bytes, + pub duration: u32, + pub idx: u32, + pub independent: bool, +} + +impl std::fmt::Debug for Part { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Part") + .field("duration", &self.duration) + .field("idx", &self.idx) + .field("independent", &self.independent) + .finish() + } +} + +#[derive(Default, Clone, Debug)] +pub struct Segment { + pub parts: Vec, + pub idx: u32, +} + +impl Segment { + pub fn part(&self, idx: u32) -> Option<&Part> { + self.parts.iter().find(|p| p.idx == idx) + } +} + +#[derive(Default, Clone)] +pub struct TrackState { + samples: VecDeque, + + timescale: u32, + + segments: VecDeque, + + init_segment: Option, + + total_duration: u64, + + next_part_idx: u32, + next_segment_idx: u32, + next_segment_part_idx: u32, + + complete: bool, +} + +impl std::fmt::Debug for TrackState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrackState") + .field("timescale", &self.timescale) + .field("segments", &self.segments) + .field("total_duration", &self.total_duration) + .field("next_part_idx", &self.next_part_idx) + .field("next_segment_idx", &self.next_segment_idx) + .field("complete", &self.complete) + .finish() + } +} + +impl TrackState { + pub fn timescale(&self) -> u32 { + self.timescale + } + + pub fn total_duration(&self) -> u64 { + self.total_duration + } + + pub fn segments(&self) -> impl Iterator { + self.segments.iter() + } + + pub fn next_part_idx(&self) -> u32 { + self.next_part_idx + } + + pub fn next_segment_idx(&self) -> u32 { + self.next_segment_idx + } + + pub fn next_segment_part_idx(&self) -> u32 { + self.next_segment_part_idx + } + + pub fn apply_manifest(&mut self, manifest: &LiveRenditionManifest) { + let Some(info) = manifest.info.as_ref() else { + return; + }; + + self.next_part_idx = info.next_part_idx; + self.next_segment_idx = info.next_segment_idx; + self.total_duration = manifest.total_duration; + self.timescale = manifest.timescale; + self.next_segment_part_idx = info.next_segment_part_idx; + + let mut segments = manifest + .segments + .iter() + .map(|s| Segment { + idx: s.idx, + parts: s + .parts + .iter() + .map(|p| Part { + data: Bytes::new(), + duration: p.duration, + idx: p.idx, + independent: p.independent, + }) + .collect(), + }) + .collect::>(); + + segments.sort_unstable_by_key(|s| s.idx); + + self.segments = segments.into(); + } + + pub fn complete(&self) -> bool { + self.complete + } + + pub fn segment(&self, idx: u32) -> Option<&Segment> { + let segment = self.segments.front()?; + if idx >= segment.idx { + self.segments.get((idx - segment.idx) as usize) + } else { + None + } + } + + pub fn part(&self, segment_idx: u32, part_idx: u32) -> Option<&Part> { + self.segment(segment_idx)?.part(part_idx) + } + + pub fn retain_segments(&mut self, count: usize) -> Vec { + (0..self.segments.len().saturating_sub(count)) + .filter_map(|_| self.segments.pop_front()) + .collect() + } + + pub fn last_segment_duration(&self) -> u32 { + self.segments + .back() + .map(|segment| segment.parts.iter().map(|p| p.duration).sum::()) + .unwrap_or_default() + } + + pub fn set_moov(&mut self, mut moov: Moov) { + let mut trak = moov.traks.remove(0); + + trak.edts = None; + trak.tkhd.track_id = 1; + + self.timescale = trak.mdia.mdhd.timescale; + + let ftyp = Ftyp::new( + FourCC::Iso5, + 512, + vec![FourCC::Iso5, FourCC::Iso6, FourCC::Mp41], + ); + let moov = Moov::new( + Mvhd::new(0, 0, 1000, 0, 2), + vec![trak], + Some(Mvex::new(vec![Trex::new(1)], None)), + ); + + let mut writer = BytesWriter::default(); + ftyp.mux(&mut writer).unwrap(); + moov.mux(&mut writer).unwrap(); + + self.init_segment = Some(writer.dispose()); + } + + fn compute_break_points( + &self, + target_part_duration: f64, + max_part_duration: f64, + target_segment_duration: f64, + ) -> Vec<(usize, BreakType)> { + let mut state = + BreakpointState::new(self.timescale, self.last_segment_duration(), &self.samples); + + while state.current_sample().is_some() { + state.add_duration(); + + if !state.process_segment_break(target_segment_duration, max_part_duration) { + state.process_part_break(target_part_duration, max_part_duration); + } + + state.increment(); + } + + state.into_breakpoints() + } + + pub fn finish(&mut self) -> Option<(u32, u32)> { + self.complete = true; + + if self.samples.is_empty() { + return None; + } + + let samples = self.samples.drain(..).collect(); + let part = self.make_part(samples); + let part_idx = part.idx; + let segment_idx = if let Some(segment) = self.segments.back_mut() { + segment.parts.push(part); + segment.idx + } else { + self.segments.push_back(Segment { + parts: vec![part], + idx: self.next_segment_idx, + }); + self.next_segment_idx += 1; + self.next_segment_part_idx = 1; + self.next_segment_idx - 1 + }; + + Some((segment_idx, part_idx)) + } + + fn make_part(&mut self, samples: Vec) -> Part { + let contains_keyframe = samples.iter().any(|sample| sample.keyframe); + let duration = samples.iter().map(|sample| sample.duration).sum::(); + + let mut moof = Moof::new( + Mfhd::new(self.next_part_idx), + vec![{ + let mut traf = Traf::new( + Tfhd::new(1, None, None, None, None, None), + Some(Trun::new( + samples.iter().map(|s| s.sample.clone()).collect(), + None, + )), + Some(Tfdt::new(self.total_duration)), + ); + + traf.optimize(); + + traf + }], + ); + + let moof_size = moof.size(); + moof.traf + .get_mut(0) + .unwrap() + .trun + .as_mut() + .unwrap() + .data_offset = Some(moof_size as i32 + 8); + + let mdat = Mdat::new(samples.into_iter().map(|s| s.data).collect::>()); + + let mut writer = BytesWriter::default(); + moof.mux(&mut writer).unwrap(); + mdat.mux(&mut writer).unwrap(); + + let part = Part { + data: writer.dispose(), + duration, + idx: self.next_part_idx, + independent: contains_keyframe, + }; + + self.next_part_idx += 1; + self.next_segment_part_idx += 1; + self.total_duration += duration as u64; + + part + } + + pub fn append_samples(&mut self, samples: Vec) { + self.samples.extend(samples); + } + + pub fn init_segment(&self) -> Option<&Bytes> { + self.init_segment.as_ref() + } + + pub fn split_samples( + &mut self, + target_part_duration: f64, + max_part_duration: f64, + target_segment_duration: f64, + ) -> Vec<(u32, Vec)> { + let break_points = self.compute_break_points( + target_part_duration, + max_part_duration, + target_segment_duration, + ); + + let segments = self.split_into_segments(break_points); + + self.initialize_segments_if_empty(); + + self.convert_to_parts_and_extend_segments(segments) + } + + fn split_into_segments( + &mut self, + break_points: Vec<(usize, BreakType)>, + ) -> Vec>> { + let mut segments = vec![]; + let mut current_segment = vec![]; + let mut previous_break_idx = 0; + + for (break_idx, break_type) in break_points { + let part = self + .samples + .drain(..break_idx - previous_break_idx) + .collect::>(); + previous_break_idx = break_idx; + + match break_type { + BreakType::Part => { + debug_assert!(!part.is_empty()); + if current_segment.is_empty() && !segments.is_empty() { + debug_assert!(part[0].keyframe); + } + current_segment.push(part); + } + BreakType::Segment => { + if !part.is_empty() { + current_segment.push(part); + } + segments.push(current_segment); + current_segment = vec![]; + } + } + } + + if !current_segment.is_empty() { + segments.push(current_segment); + } + + segments + } + + fn initialize_segments_if_empty(&mut self) { + if self.segments.is_empty() { + self.segments.push_back(Segment { + parts: vec![], + idx: self.next_segment_idx, + }); + self.next_segment_idx += 1; + self.next_segment_part_idx = 0; + } + } + + fn convert_to_parts_and_extend_segments( + &mut self, + segments: Vec>>, + ) -> Vec<(u32, Vec)> { + let segment_count = segments.len(); + + segments + .into_iter() + .enumerate() + .map(|(idx, parts)| { + let parts = parts + .into_iter() + .map(|samples| self.make_part(samples)) + .collect::>(); + let current_segment = self.segments.back_mut().unwrap(); + + let part_ids = parts.iter().map(|p| p.idx).collect::>(); + + current_segment.parts.extend(parts); + + let segment_idx = current_segment.idx; + + if idx != segment_count - 1 { + self.segments.push_back(Segment { + parts: vec![], + idx: self.next_segment_idx, + }); + self.next_segment_idx += 1; + self.next_segment_part_idx = 0; + } + + (segment_idx, part_ids) + }) + .collect() + } +} diff --git a/video/transcoder/src/transcoder/job/utils/unix_stream.rs b/video/transcoder/src/transcoder/job/utils/unix_stream.rs new file mode 100644 index 00000000..bf83844b --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils/unix_stream.rs @@ -0,0 +1,70 @@ +use std::{io, path::Path, time::Duration}; + +use bytes::Bytes; +use bytesio::{bytesio::BytesIO, bytesio_errors::BytesIOError}; +use common::prelude::FutureTimeout; +use tokio::net::UnixListener; + +pub fn bind_socket(path: &Path, uid: u32, gid: u32) -> anyhow::Result { + tracing::debug!(sock_path = %path.display(), "creating socket"); + let socket = match UnixListener::bind(path) { + Ok(s) => s, + Err(err) => { + anyhow::bail!("failed to bind socket: {}", err) + } + }; + + // Change user and group of the socket. + if let Err(err) = nix::unistd::chown( + path.as_os_str(), + Some(nix::unistd::Uid::from_raw(uid)), + Some(nix::unistd::Gid::from_raw(gid)), + ) { + anyhow::bail!("failed to change ownership socket: {}", err) + } + + Ok(socket) +} + +pub fn unix_stream( + listener: UnixListener, + buffer_size: usize, +) -> impl futures::Stream> + Send { + async_stream::stream!({ + let (sock, _) = match listener.accept().timeout(Duration::from_secs(4)).await { + Ok(Ok(connection)) => connection, + Ok(Err(err)) => { + yield Err(err); + return; + } + Err(_) => { + // Timeout + tracing::debug!("unix stream timeout"); + return; + } + }; + + tracing::debug!("accepted connection"); + + let mut bio = BytesIO::with_capacity(sock, buffer_size); + + loop { + match bio.read().await { + Ok(bytes) => { + yield Ok(bytes.freeze()); + } + Err(err) => match err { + BytesIOError::ClientClosed => { + return; + } + _ => { + yield Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + anyhow::anyhow!("failed to read from socket: {}", err), + )); + } + }, + } + } + }) +} diff --git a/video/transcoder/src/transcoder/job/variant/consts.rs b/video/transcoder/src/transcoder/job/variant/consts.rs deleted file mode 100644 index 55f7fd76..00000000 --- a/video/transcoder/src/transcoder/job/variant/consts.rs +++ /dev/null @@ -1,38 +0,0 @@ -pub const ACTIVE_EXPIRE_SECONDS: i64 = 450; -pub const INACTIVE_EXPIRE_SECONDS: i64 = 4; -pub const ACTIVE_SEGMENT_COUNT: u32 = 4; // segments -pub const ACTIVE_FRAGMENT_SEGMENT_COUNT: u32 = 2; // segments -pub const FRAGMENT_CUT_TARGET_DURATION: f64 = 0.25; // seconds -pub const FRAGMENT_CUT_MAX_DURATION: f64 = 0.35; // seconds -pub const SEGMENT_CUT_TARGET_DURATION: f64 = 2.0; // seconds - -#[inline(always)] -pub fn redis_init_key(stream_id: &str, variant_id: &str) -> String { - format!("transcoder:{}:{}:init", stream_id, variant_id) -} - -#[inline(always)] -pub fn redis_mutex_key(stream_id: &str, variant_id: &str) -> String { - format!("transcoder:{}:{}:mutex", stream_id, variant_id) -} - -#[inline(always)] -pub fn redis_state_key(stream_id: &str, variant_id: &str) -> String { - format!("transcoder:{}:{}:state", stream_id, variant_id) -} - -#[inline(always)] -pub fn redis_segment_state_key(stream_id: &str, variant_id: &str, segment_idx: u32) -> String { - format!( - "transcoder:{}:{}:{}:state", - stream_id, variant_id, segment_idx - ) -} - -#[inline(always)] -pub fn redis_segment_data_key(stream_id: &str, variant_id: &str, segment_idx: u32) -> String { - format!( - "transcoder:{}:{}:{}:data", - stream_id, variant_id, segment_idx - ) -} diff --git a/video/transcoder/src/transcoder/job/variant/mod.rs b/video/transcoder/src/transcoder/job/variant/mod.rs deleted file mode 100644 index 1c8bc6ad..00000000 --- a/video/transcoder/src/transcoder/job/variant/mod.rs +++ /dev/null @@ -1,984 +0,0 @@ -use crate::global::GlobalState; -use anyhow::{anyhow, Context, Result}; -use bytes::Bytes; -use bytesio::bytes_writer::BytesWriter; -use chrono::SecondsFormat; -use common::prelude::FutureTimeout; -use fred::{ - prelude::{HashesInterface, KeysInterface}, - types::{Expiration, RedisValue}, -}; -use futures_util::StreamExt; -use mp4::{ - types::{ - ftyp::{FourCC, Ftyp}, - mdat::Mdat, - mfhd::Mfhd, - moof::Moof, - moov::Moov, - mvex::Mvex, - mvhd::Mvhd, - tfdt::Tfdt, - tfhd::Tfhd, - traf::Traf, - trex::Trex, - trun::Trun, - }, - BoxType, -}; -use std::{collections::HashMap, io, pin::pin, sync::Arc, time::Duration}; -use tokio::{net::UnixListener, select, sync::mpsc}; -use tokio_util::sync::CancellationToken; - -use super::{ - renditions::RenditionMap, - track_parser::{track_parser, TrackOut, TrackSample}, - utils::{release_lock, set_lock, unix_stream}, -}; - -mod consts; -pub(crate) mod state; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Operation { - Init, - Fragments, -} - -#[derive(Default, Clone)] -struct TrackState { - moov: Option, - timescale: u32, - samples: Vec<(usize, TrackSample)>, -} - -struct Variant { - stream_id: String, - variant_id: String, - request_id: String, - operation: Operation, - lock_owner: CancellationToken, - tracks: Vec, - redis_state: state::PlaylistState, - should_discontinuity: bool, - segment_state: HashMap)>, - ready: mpsc::Sender<()>, - is_ready: bool, - renditions: Arc, -} - -pub async fn handle_variant( - global: Arc, - ready: mpsc::Sender<()>, - stream_id: String, - variant_id: String, - request_id: String, - track: UnixListener, - renditions: Arc, -) -> Result { - let mut variant = Variant::new(ready, 1, stream_id, variant_id, request_id, renditions); - - variant - .run( - global, - pin!(track_parser(pin!(unix_stream(track, 256 * 1024))).map(|r| (r, 1))), - ) - .await?; - - Ok(variant.variant_id) -} - -impl Variant { - pub fn new( - ready: mpsc::Sender<()>, - trak_count: u32, - stream_id: String, - variant_id: String, - request_id: String, - renditions: Arc, - ) -> Self { - Self { - stream_id, - variant_id, - request_id, - tracks: vec![TrackState::default(); trak_count as usize], - operation: Operation::Init, - lock_owner: CancellationToken::new(), - redis_state: state::PlaylistState::default(), - segment_state: HashMap::new(), - should_discontinuity: false, - ready, - renditions, - is_ready: false, - } - } - - #[tracing::instrument(skip(self, global, tracks), fields(stream_id = %self.stream_id, variant_id = %self.variant_id, request_id = %self.request_id))] - pub async fn run( - &mut self, - global: Arc, - tracks: impl futures::Stream, u32)> + Unpin, - ) -> Result<(), ()> { - let mut set_lock_fut = pin!(set_lock( - global.clone(), - consts::redis_mutex_key(&self.stream_id.to_string(), &self.variant_id.to_string()), - self.request_id.clone(), - self.lock_owner.clone(), - )); - - let mut tracks = tracks.enumerate(); - - let mut result = Ok(()); - - loop { - select! { - item = tracks.next() => { - match item { - Some((_, (Ok(TrackOut::Moov(moov)), track_id))) => { - let idx = track_id as usize - 1; - - if self.tracks.len() <= idx { - tracing::error!("track {} unexpected but moov received", track_id); - result = Err(()); - break; - } - - self.tracks[idx].moov = Some(moov); - } - Some((stream_idx, (Ok(TrackOut::Sample(sample)), track_id))) => { - let idx = track_id as usize - 1; - - if self.tracks.len() <= idx { - tracing::error!("track {:#} unexpected but moov received", track_id); - result = Err(()); - break; - } - - self.tracks[idx].samples.push((stream_idx, sample)); - } - Some((_, (Err(err), idx))) => { - tracing::error!("track {} error: {:#}", idx, err); - result = Err(()); - break; - } - None => { - tracing::debug!("tracks closed"); - break; - } - } - } - r = &mut set_lock_fut => { - if let Err(err) = r { - tracing::error!("set lock error: {:#}", err); - } else { - tracing::warn!("set lock done prematurely without error"); - } - - break; - } - } - - select! { - r = self.process(&global) => { - if let Err(err) = r { - tracing::error!("process error: {:#}", err); - result = Err(()); - break; - } - } - r = &mut set_lock_fut => { - if let Err(err) = r { - tracing::error!("set lock error: {:#}", err); - } else { - tracing::warn!("set lock done prematurely without error"); - } - - break; - } - } - } - - tracing::debug!("track closed"); - - if let Err(err) = self.handle_shutdown(&global).await { - tracing::error!("handle shutdown error: {:#}", err); - } - - tracing::debug!("track flushed"); - - if let Err(err) = release_lock( - &global, - &consts::redis_mutex_key(&self.stream_id, &self.variant_id), - &self.request_id, - ) - .await - { - tracing::error!("release lock error: {:#}", err); - } - - tracing::debug!("track complete"); - - result - } - - async fn handle_shutdown(&mut self, global: &Arc) -> Result<()> { - if self.operation == Operation::Init { - return Ok(()); - } - - let samples = self - .tracks - .iter_mut() - .map(|track| track.samples.drain(..).map(|s| s.1).collect::>()) - .collect::>(); - if samples.iter().any(|s| !s.is_empty()) { - tracing::info!( - "flushing remaining samples {:?}", - samples.iter().map(|s| s.len()).collect::>() - ); - if let std::collections::hash_map::Entry::Vacant(e) = self - .segment_state - .entry(self.redis_state.current_segment_idx()) - { - // This really sucks because we have to create an entire new segment for these few ending samples - self.redis_state.set_current_fragment_idx(0); - e.insert(Default::default()); - } - - self.create_fragment(samples)?; - } - - if self - .segment_state - .contains_key(&self.redis_state.current_segment_idx()) - { - self.segment_state - .get_mut(&self.redis_state.current_segment_idx()) - .unwrap() - .0 - .set_ready(true); - self.redis_state.set_current_fragment_idx(0); - self.redis_state - .set_current_segment_idx(self.redis_state.current_segment_idx() + 1); - } - - let pipeline = global.redis.pipeline(); - if self.update_keys(&pipeline).await? { - self.refresh_keys(&pipeline).await?; - pipeline.all().await?; - } - - Ok(()) - } - - async fn process(&mut self, global: &Arc) -> Result<()> { - match self.operation { - Operation::Init => { - self.construct_init(global).await?; - } - Operation::Fragments => { - self.handle_sample(global).await?; - self.update_renditions(); - - let pipeline = global.redis.pipeline(); - if self.update_keys(&pipeline).await? { - self.refresh_keys(&pipeline).await?; - pipeline.all().await?; - - if !self.is_ready { - self.is_ready = true; - self.ready.send(()).await?; - } - } - } - } - - Ok(()) - } - - fn update_renditions(&self) { - let current_segment_idx = self.redis_state.current_segment_idx() - - if self.redis_state.current_fragment_idx() == 0 - && self.redis_state.current_segment_idx() != 0 - { - 1 - } else { - 0 - }; - let current_fragment_idx = self - .segment_state - .get(¤t_segment_idx) - .map(|(segment, _)| segment.fragments().len()) - .unwrap_or(0) as u32; - let current_fragment_idx = if current_fragment_idx != 0 { - current_fragment_idx - 1 - } else { - 0 - }; - - self.renditions - .set(&self.variant_id, current_segment_idx, current_fragment_idx); - } - - async fn construct_init(&mut self, global: &Arc) -> Result<()> { - if self.tracks.iter().any(|track| track.moov.is_none()) { - return Ok(()); - } - - let (traks, trexs) = self - .tracks - .iter_mut() - .enumerate() - .map(|(idx, track)| { - let track_id = idx as u32 + 1; - let mut moov = track.moov.take().unwrap(); - - if moov.traks.len() != 1 { - return Err(anyhow!("expected 1 trak")); - } - - let mut trak = moov.traks.remove(0); - - trak.edts = None; - trak.tkhd.track_id = track_id; - - track.timescale = trak.mdia.mdhd.timescale; - - Ok((trak, Trex::new(track_id))) - }) - .collect::>>()? - .into_iter() - .unzip::<_, _, Vec<_>, Vec<_>>(); - - let ftyp = Ftyp::new( - FourCC::Iso5, - 512, - vec![FourCC::Iso5, FourCC::Iso6, FourCC::Mp41], - ); - let moov = Moov::new( - Mvhd::new(0, 0, 1000, 0, 2), - traks, - Some(Mvex::new(trexs, None)), - ); - - let mut writer = BytesWriter::default(); - ftyp.mux(&mut writer)?; - moov.mux(&mut writer)?; - - self.initial_redis_state(global, writer.dispose()).await?; - - self.operation = Operation::Fragments; - - Ok(()) - } - - async fn initial_redis_state( - &mut self, - global: &Arc, - init_segment: Bytes, - ) -> Result<()> { - // At this point we know enough about the stream to look at redis to see if we are resuming, or starting fresh. - // If we are resuming we need to load some state about what we have already sent to the client. - // If we are starting fresh we need to create some state so that we can resume later (if needed). - // We also need to make sure that the previous instance is finished, if not we need to wait for it to finish. - if self - .lock_owner - .cancelled() - .timeout(Duration::from_secs(5)) - .await - .is_err() - { - return Err(anyhow!("timeout waiting for lock")); - } - - // We now are the proud owner of the stream and can do whatever we want with it! - // Get the redis state for the stream. - let state: HashMap = global - .redis - .hgetall(consts::redis_state_key(&self.stream_id, &self.variant_id)) - .await - .context("failed to get redis state")?; - - if !state.is_empty() { - // We need to validate the state we got from redis. - let state = state::PlaylistState::from(state); - if self.tracks.len() != state.track_count() { - return Err(anyhow!("track count mismatch")); - } - - for (idx, track) in self.tracks.iter().enumerate() { - if track.timescale != state.track_timescale(idx).unwrap_or(0) { - return Err(anyhow!("track {} timescale mismatch", idx)); - } - } - - self.redis_state = state; - } else { - self.redis_state = state::PlaylistState::default(); - self.tracks.iter().for_each(|track| { - self.redis_state.insert_track(state::Track { - duration: 0, - timescale: track.timescale, - }); - }); - } - - // Since we now know the redis_state we can see if we are starting fresh or resuming. - if self.redis_state.current_segment_idx() != 0 - || self.redis_state.current_fragment_idx() != 0 - { - // We now need to fetch the segments from redis. - let start_idx = (self.redis_state.current_segment_idx() as i32 - 4).max(0) as u32; - let end_idx = self.redis_state.current_segment_idx() - + if self.redis_state.current_fragment_idx() == 0 { - 0 - } else { - 1 - }; - for idx in start_idx..end_idx { - let segment: HashMap = global - .redis - .hgetall(consts::redis_segment_state_key( - &self.stream_id, - &self.variant_id, - idx, - )) - .await - .context("failed to get redis segment state")?; - let segment = state::SegmentState::from(segment); - self.segment_state.insert(idx, (segment, HashMap::new())); - } - } - - let pipeline = global.redis.pipeline(); - - let _: RedisValue = pipeline - .set( - consts::redis_init_key(&self.stream_id, &self.variant_id), - init_segment, - Some(Expiration::EX(consts::ACTIVE_EXPIRE_SECONDS)), - None, - false, - ) - .await - .context("failed to set redis init")?; - - self.update_keys(&pipeline) - .await - .context("failed to update redis keys")?; - - self.refresh_keys(&pipeline) - .await - .context("failed to refresh redis keys")?; - - let _: Vec<()> = pipeline - .all() - .await - .context("failed to execute redis pipeline")?; - - self.should_discontinuity = self.redis_state.current_fragment_idx() != 0; - - Ok(()) - } - - async fn handle_sample(&mut self, _global: &Arc) -> Result<()> { - // We need to check if we have enough samples to create a fragment. - // And then check if we do, if we have enough fragments to create a segment. - if self.should_discontinuity { - // Discontinuities are a bit of a special case. - // Any samples before the keyframe on track 1 are discarded. - // Samples on other tracks will be added to the next fragment. - let Some(idx) = self.tracks[0] - .samples - .iter() - .position(|(_, sample)| sample.keyframe) - else { - // We dont have a place to create a discontinuity yet, so we need to wait a little bit. - return Ok(()); - }; - - // We need to discard all samples on track[0] before the keyframe. (there shouldnt be any, but just in case) - self.tracks[0].samples.drain(..idx); - - self.redis_state.set_current_fragment_idx(0); - let current_idx = self.redis_state.current_segment_idx(); - - self.redis_state.set_current_segment_idx(current_idx + 1); - self.redis_state - .set_discontinuity_sequence(self.redis_state.discontinuity_sequence() + 1); - - // make sure the previous segment is marked as ready. - if let Some((previous, _)) = self.segment_state.get_mut(¤t_idx) { - previous.set_ready(true); - } - - self.should_discontinuity = false; - let mut segment = state::SegmentState::default(); - segment.set_discontinuity(true); - self.segment_state.insert( - self.redis_state.current_segment_idx(), - (segment, HashMap::new()), - ); - } - - // We need to check if we have enough samples to create a new fragment - // We only care about the duration from track 1. Other tracks will just be added regardless if they cut or not. - - // We need to also know about the current segment duration incase we can cut a new segment. - let total_segment_timescale_duration = self - .segment_state - .entry(self.redis_state.current_segment_idx()) - .or_insert_with(Default::default) - .0 - .fragments() - .iter() - .map(|fragment| fragment.duration) - .sum::(); - - // We need to see if the next samples can create a segment. - // To do this we need to iterate over the samples to find out if any of them are keyframes. - // If we have a keyframe we need to check if the samples before the keyframe can create a fragment. - let (sample_durations, segment_durations) = { - let mut total_sample_timescale_duration = 0; - self.tracks[0] - .samples - .iter() - .map(|(_, sample)| { - total_sample_timescale_duration += sample.duration; - ( - total_sample_timescale_duration as f64 / self.tracks[0].timescale as f64, - // We only calculate the segment duration if we have a keyframe. - // Since we cant cut a segment without a keyframe. - if sample.keyframe { - (total_sample_timescale_duration + total_segment_timescale_duration - - sample.duration) as f64 - / self.tracks[0].timescale as f64 - } else { - 0.0 - }, - ) - }) - .unzip::<_, _, Vec<_>, Vec<_>>() // unzip the tuples into two vectors. - }; - - let idx = segment_durations - .iter() - .position(|duration| *duration >= consts::SEGMENT_CUT_TARGET_DURATION); - - // If we have an index we can cut a new segment. - if let Some(idx) = idx { - let Some((segment, _)) = self - .segment_state - .get_mut(&self.redis_state.current_segment_idx()) - else { - // This should never happen, but just in case. - return Err(anyhow!("failed to get current segment state")); - }; - - let last_sample_stream_idx = self.tracks[0].samples[idx].0; - let samples = self - .tracks - .iter_mut() - .map(|track| { - let upper_bound = track - .samples - .iter() - .enumerate() - .find_map(|(idx, (stream_idx, _))| { - if *stream_idx >= last_sample_stream_idx { - // We dont add 1 here because we want to include the last sample. - return Some(idx); - } - - None - }) - .unwrap_or_default(); - - track - .samples - .drain(..upper_bound) - .map(|(_, sample)| sample) - .collect::>() - }) - .collect::>(); - - segment.set_ready(true); - - if !samples[0].is_empty() { - self.create_fragment(samples)?; - } - - self.redis_state - .set_current_segment_idx(self.redis_state.current_segment_idx() + 1); - self.redis_state.set_current_fragment_idx(0); - - return Ok(()); - } - - // We want to find out if we have enough samples to create a fragment. - let Some(idx) = sample_durations - .iter() - .enumerate() - .find_map(|(idx, duration)| { - if *duration >= consts::FRAGMENT_CUT_TARGET_DURATION - && (*duration * 1000.0).fract() == 0.0 - { - return Some(Some(idx)); - } - - if *duration >= consts::FRAGMENT_CUT_MAX_DURATION { - return Some(None); - } - - None - }) - else { - // We dont have a place to create a fragment yet, so we need to wait a little bit. - return Ok(()); - }; - - let Some(idx) = idx.or_else(|| { - sample_durations - .iter() - .position(|d| *d >= consts::FRAGMENT_CUT_TARGET_DURATION) - }) else { - // We dont have a place to create a fragment yet, so we need to wait a little bit. - return Ok(()); - }; - - let last_sample_stream_idx = self.tracks[0].samples[idx].0; - - // We now extract all the samples we need to create the next fragment. - let samples = self - .tracks - .iter_mut() - .map(|track| { - let upper_bound = track - .samples - .iter() - .position(|(stream_idx, _)| *stream_idx >= last_sample_stream_idx) - .map(|idx| idx + 1) - .unwrap_or_else(|| track.samples.len()); - - track - .samples - .drain(..upper_bound) - .map(|(_, sample)| sample) - .collect::>() - }) - .collect::>(); - - self.create_fragment(samples)?; - - Ok(()) - } - - fn create_fragment(&mut self, samples: Vec>) -> Result<()> { - // Get the current segment - let Some((segment, segment_data_state)) = self - .segment_state - .get_mut(&self.redis_state.current_segment_idx()) - else { - return Err(anyhow!("failed to get current segment")); - }; - - let contains_keyframe = samples[0].iter().any(|sample| sample.keyframe); - segment.insert_fragment(state::Fragment { - duration: samples[0].iter().map(|sample| sample.duration).sum(), - keyframe: contains_keyframe, - }); - - let mut moof = Moof::new( - Mfhd::new(self.redis_state.sequence_number()), - samples - .iter() - .enumerate() - .map(|(idx, samples)| { - let mut traf = Traf::new( - Tfhd::new(idx as u32 + 1, None, None, None, None, None), - Some(Trun::new( - samples.iter().map(|s| s.sample.clone()).collect(), - None, - )), - Some(Tfdt::new(self.redis_state.track_duration(idx).unwrap())), - ); - - traf.optimize(); - - traf - }) - .collect(), - ); - - let moof_size = moof.size(); - - let track_sizes = samples - .iter() - .map(|s| s.iter().map(|s| s.data.len()).sum::()) - .collect::>(); - - moof.traf.iter_mut().enumerate().for_each(|(idx, traf)| { - let trun = traf.trun.as_mut().unwrap(); - - // The base is moof, so we offset by the moof, then we offset by the size of all the previous tracks + 8 bytes for the mdat header. - trun.data_offset = - Some(moof_size as i32 + track_sizes[..idx].iter().sum::() as i32 + 8); - }); - - let mdat = Mdat::new( - samples - .iter() - .flat_map(|s| s.iter().map(|s| s.data.clone())) - .collect(), - ); - - let mut writer = BytesWriter::default(); - moof.mux(&mut writer)?; - mdat.mux(&mut writer)?; - - segment_data_state.insert(self.redis_state.current_fragment_idx(), writer.dispose()); - - self.redis_state - .set_sequence_number(self.redis_state.sequence_number() + 1); - self.redis_state - .set_current_fragment_idx(self.redis_state.current_fragment_idx() + 1); - samples.iter().enumerate().for_each(|(idx, s)| { - self.redis_state.set_track_duration( - idx, - self.redis_state.track_duration(idx).unwrap() - + s.iter().map(|s| s.duration).sum::() as u64, - ); - }); - - Ok(()) - } - - async fn update_keys( - &mut self, - redis: &R, - ) -> Result { - self.generate_playlist() - .context("failed to generate playlist")?; - - let mut updated = false; - { - let mutations = self.redis_state.extract_mutations(); - if !mutations.is_empty() { - updated = true; - let _: RedisValue = redis - .hmset( - consts::redis_state_key(&self.stream_id, &self.variant_id), - mutations, - ) - .await - .context("failed to set redis state")?; - } - } - - for (idx, (segment, segment_data_state)) in self.segment_state.iter_mut() { - let mutations = segment.extract_mutations(); - if !mutations.is_empty() { - updated = true; - let _: RedisValue = redis - .hmset( - consts::redis_segment_state_key(&self.stream_id, &self.variant_id, *idx), - mutations, - ) - .await - .context("failed to set redis segment state")?; - } - - let data_state_mutations = std::mem::take(segment_data_state); - if !data_state_mutations.is_empty() { - updated = true; - let _: RedisValue = redis - .hmset( - consts::redis_segment_data_key(&self.stream_id, &self.variant_id, *idx), - data_state_mutations, - ) - .await - .context("failed to set redis segment data state")?; - } - } - - Ok(updated) - } - - async fn refresh_keys( - &mut self, - redis: &R, - ) -> Result<()> { - let mut keys = vec![ - consts::redis_state_key(&self.stream_id, &self.variant_id), - consts::redis_init_key(&self.stream_id, &self.variant_id), - ]; - - let lower_bound = (self.redis_state.current_segment_idx() as i32 - 4).max(0) as u32; - - keys.extend( - (lower_bound..self.redis_state.current_segment_idx() + 1).flat_map(|idx| { - [ - consts::redis_segment_state_key(&self.stream_id, &self.variant_id, idx), - consts::redis_segment_data_key(&self.stream_id, &self.variant_id, idx), - ] - }), - ); - - for key in keys.iter() { - let _: RedisValue = redis - .expire(key, consts::ACTIVE_EXPIRE_SECONDS) - .await - .context("failed to expire redis expire")?; - } - - for key in self - .segment_state - .keys() - .filter(|idx| **idx < lower_bound) - .copied() - .collect::>() - .into_iter() - .flat_map(|idx| { - self.segment_state.remove(&idx); - [ - consts::redis_segment_state_key(&self.stream_id, &self.variant_id, idx), - consts::redis_segment_data_key(&self.stream_id, &self.variant_id, idx), - ] - }) - { - let _: RedisValue = redis - .expire(key, consts::INACTIVE_EXPIRE_SECONDS) - .await - .context("failed to expire redis key")?; - } - - Ok(()) - } - - fn generate_playlist(&mut self) -> Result<()> { - let mut playlist = String::new(); - - let oldest_segment_idx = (self.redis_state.current_segment_idx() as i32 - - consts::ACTIVE_SEGMENT_COUNT as i32) - .max(0) as u32; - let oldest_fragment_display_idx = (self.redis_state.current_segment_idx() as i32 - - consts::ACTIVE_FRAGMENT_SEGMENT_COUNT as i32) - .max(0) as u32; - let newest_segment_idx = self.redis_state.current_segment_idx() - + if self.redis_state.current_fragment_idx() == 0 { - 0 - } else { - 1 - }; - - let mut discontinuity_sequence = self.redis_state.discontinuity_sequence() as i32; - let mut segment_data = String::new(); - - // According to spec this should never change. - // But we will just keep it changing to the largest fragment duration in this set of segments. - // However, this should be a good enough approximation. - // Baring that the framerate is not less than 8fps. (which is unlikely) - // If it is less than 8fps, then it will automatically increase to the longest fragment duration. - let mut longest_fragment_duration: f64 = consts::FRAGMENT_CUT_TARGET_DURATION; - - for idx in oldest_segment_idx..newest_segment_idx { - let Some((segment, _)) = self.segment_state.get(&idx) else { - return Err(anyhow::anyhow!("missing segment state: {}", idx)); - }; - - if segment.discontinuity() { - discontinuity_sequence -= 1; - segment_data.push_str("#EXT-X-DISCONTINUITY\n"); - } - - let track_1_timescale = self.redis_state.track_timescale(0).unwrap_or(1); - - let mut total_duration = 0; - for (f_idx, fragment) in segment.fragments().iter().enumerate() { - total_duration += fragment.duration; - - longest_fragment_duration = longest_fragment_duration - .max(fragment.duration as f64 / track_1_timescale as f64); - - if idx >= oldest_fragment_display_idx { - segment_data.push_str(&format!( - "#EXT-X-PART:DURATION={:.5},URI=\"{}.{}.mp4\"{}\n", - fragment.duration as f64 / track_1_timescale as f64, - idx, - f_idx, - if fragment.keyframe { - ",INDEPENDENT=YES" - } else { - "" - } - )); - } - } - - let segment_duration = total_duration as f64 / track_1_timescale as f64; - if segment_duration > self.redis_state.longest_segment() { - self.redis_state.set_longest_segment(segment_duration); - } - - if segment.ready() { - segment_data.push_str(&format!( - "#EXT-X-PROGRAM-DATE-TIME:{}\n", - segment - .timestamp() - .to_rfc3339_opts(SecondsFormat::Millis, true) - )); - - segment_data.push_str(&format!("#EXTINF:{:.5},\n", segment_duration)); - segment_data.push_str(&format!("{}.mp4\n", idx)); - } - } - - segment_data.push_str(&format!( - "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{}.{}.mp4\"\n", - self.redis_state.current_segment_idx(), - self.redis_state.current_fragment_idx() - )); - - playlist.push_str("#EXTM3U\n"); - playlist.push_str(&format!( - "#EXT-X-TARGETDURATION:{}\n", - self.redis_state.longest_segment().ceil() as u32 * 2, - )); - playlist.push_str("#EXT-X-VERSION:9\n"); - playlist.push_str(&format!( - "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={:.5}\n", - longest_fragment_duration * 2.0 - )); - playlist.push_str(&format!( - "#EXT-X-PART-INF:PART-TARGET={:.5}\n", - longest_fragment_duration - )); - playlist.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", oldest_segment_idx)); - playlist.push_str(&format!( - "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", - discontinuity_sequence.max(0) - )); - - playlist.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n"); - - playlist.push_str(&segment_data); - - playlist.push('\n'); - - for rendition in self - .renditions - .renditions() - .into_iter() - .filter(|rendition| rendition.id != self.variant_id) - { - playlist.push_str(&format!( - "#EXT-X-RENDITION-REPORT:URI=\"../{}/index.m3u8\",LAST-MSN={},LAST-PART={}\n", - rendition.id, rendition.last_msn, rendition.last_part - )); - } - - self.redis_state.set_playlist(playlist); - - Ok(()) - } -} diff --git a/video/transcoder/src/transcoder/job/variant/state.rs b/video/transcoder/src/transcoder/job/variant/state.rs deleted file mode 100644 index 91ddf221..00000000 --- a/video/transcoder/src/transcoder/job/variant/state.rs +++ /dev/null @@ -1,411 +0,0 @@ -use std::collections::HashMap; - -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct PlaylistState { - mutations: HashMap, - current_segment_idx: u32, - current_fragment_idx: u32, - discontinuity_sequence: u32, - sequence_number: u32, - tracks: Vec, - playlist: String, - longest_segment: f64, -} - -impl Default for PlaylistState { - fn default() -> Self { - Self { - mutations: HashMap::from_iter(vec![ - ("current_segment_idx".to_string(), "0".to_string()), - ("current_fragment_idx".to_string(), "0".to_string()), - ("discontinuity_sequence".to_string(), "0".to_string()), - ("sequence_number".to_string(), "0".to_string()), - ("longest_segment".to_string(), "0.0".to_string()), - ("track_count".to_string(), "0".to_string()), - ("playlist".to_string(), String::new()), - ]), - current_segment_idx: 0, - current_fragment_idx: 0, - discontinuity_sequence: 0, - sequence_number: 0, - tracks: Vec::new(), - longest_segment: 0.0, - playlist: String::new(), - } - } -} - -impl PlaylistState { - pub fn set_current_segment_idx(&mut self, value: u32) { - if value != self.current_fragment_idx { - self.mutations - .insert("current_segment_idx".to_string(), value.to_string()); - self.current_segment_idx = value; - } - } - - pub fn set_current_fragment_idx(&mut self, value: u32) { - if value != self.current_fragment_idx { - self.mutations - .insert("current_fragment_idx".to_string(), value.to_string()); - self.current_fragment_idx = value; - } - } - - pub fn set_discontinuity_sequence(&mut self, value: u32) { - if value != self.discontinuity_sequence { - self.mutations - .insert("discontinuity_sequence".to_string(), value.to_string()); - self.discontinuity_sequence = value; - } - } - - pub fn set_sequence_number(&mut self, value: u32) { - if value != self.sequence_number { - self.mutations - .insert("sequence_number".to_string(), value.to_string()); - self.sequence_number = value; - } - } - - pub fn insert_track(&mut self, track: Track) { - self.mutations.insert( - format!("track_{}_duration", self.tracks.len() + 1), - track.duration.to_string(), - ); - self.mutations.insert( - format!("track_{}_timescale", self.tracks.len() + 1), - track.timescale.to_string(), - ); - self.mutations - .insert("track_count".into(), (self.tracks.len() + 1).to_string()); - - self.tracks.push(track); - } - - pub fn set_longest_segment(&mut self, value: f64) { - if value != self.longest_segment { - self.mutations - .insert("longest_segment".to_string(), value.to_string()); - self.longest_segment = value; - } - } - - pub fn set_track_duration(&mut self, track_idx: usize, value: u64) { - if let Some(track) = self.tracks.get_mut(track_idx) { - if value != track.duration { - self.mutations.insert( - format!("track_{}_duration", track_idx + 1), - value.to_string(), - ); - track.duration = value; - } - } - } - - pub fn set_playlist(&mut self, value: String) { - if value != self.playlist { - self.mutations.insert("playlist".to_string(), value.clone()); - self.playlist = value; - } - } - - #[inline(always)] - pub fn current_segment_idx(&self) -> u32 { - self.current_segment_idx - } - - #[inline(always)] - pub fn current_fragment_idx(&self) -> u32 { - self.current_fragment_idx - } - - #[inline(always)] - pub fn discontinuity_sequence(&self) -> u32 { - self.discontinuity_sequence - } - - #[inline(always)] - pub fn sequence_number(&self) -> u32 { - self.sequence_number - } - - #[inline(always)] - pub fn track_count(&self) -> usize { - self.tracks.len() - } - - #[inline(always)] - pub fn track_duration(&self, track_idx: usize) -> Option { - self.tracks.get(track_idx).map(|t| t.duration) - } - - #[inline(always)] - pub fn track_timescale(&self, track_idx: usize) -> Option { - self.tracks.get(track_idx).map(|t| t.timescale) - } - - #[inline(always)] - pub fn longest_segment(&self) -> f64 { - self.longest_segment - } - - pub fn extract_mutations(&mut self) -> HashMap { - std::mem::take(&mut self.mutations) - } -} - -#[derive(Debug, Clone)] -pub struct Track { - pub duration: u64, - pub timescale: u32, -} - -impl From> for PlaylistState { - fn from(value: HashMap) -> Self { - let mut mutations = HashMap::new(); - - let current_segment_idx = value - .get("current_segment_idx") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("current_segment_idx".to_string(), "0".to_string()); - 0 - }); - - let current_fragment_idx = value - .get("current_fragment_idx") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("current_fragment_idx".to_string(), "0".to_string()); - 0 - }); - - let discontinuity_sequence = value - .get("discontinuity_sequence") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("discontinuity_sequence".to_string(), "0".to_string()); - 0 - }); - - let track_count = value - .get("track_count") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("track_count".to_string(), "0".to_string()); - 0 - }); - - let sequence_number = value - .get("sequence_number") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("sequence_number".to_string(), "0".to_string()); - 0 - }); - - let playlist = value - .get("playlist") - .map(|v| v.to_string()) - .unwrap_or_else(|| { - mutations.insert("playlist".to_string(), "".to_string()); - "".to_string() - }); - - let mut tracks = Vec::with_capacity(track_count); - - for i in 0..track_count { - let duration = value - .get(&format!("track_{}_duration", i + 1)) - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert(format!("track_{}_duration", i + 1), "0".to_string()); - 0 - }); - - let timescale = value - .get(&format!("track_{}_timescale", i + 1)) - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert(format!("track_{}_timescale", i + 1), "0".to_string()); - 0 - }); - - tracks.push(Track { - duration, - timescale, - }); - } - - let longest_segment = value - .get("longest_segment") - .and_then(|v| v.parse::().ok()) - .unwrap_or_default(); - - Self { - mutations, - current_segment_idx, - current_fragment_idx, - discontinuity_sequence, - tracks, - longest_segment, - sequence_number, - playlist, - } - } -} - -#[derive(Debug, Clone)] -pub struct SegmentState { - mutations: HashMap, - ready: bool, - discontinuity: bool, - timestamp: DateTime, - fragments: Vec, -} - -#[derive(Debug, Clone)] -pub struct Fragment { - pub duration: u32, - pub keyframe: bool, -} - -impl Default for SegmentState { - fn default() -> Self { - Self { - mutations: HashMap::from_iter(vec![ - ("ready".into(), "false".into()), - ("discontinuity".into(), "false".into()), - ("timestamp".into(), Utc::now().to_rfc3339()), - ("fragment_count".into(), "0".into()), - ]), - ready: false, - discontinuity: false, - timestamp: Utc::now(), - fragments: Vec::new(), - } - } -} - -impl From> for SegmentState { - fn from(value: HashMap) -> Self { - let mut mutations = HashMap::new(); - - let ready = value - .get("ready") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("ready".into(), "false".into()); - false - }); - - let discontinuity = value - .get("discontinuity") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("discontinuity".into(), "false".into()); - false - }); - let timestamp = value - .get("timestamp") - .and_then(|v| { - DateTime::parse_from_rfc3339(v) - .map(|t| t.with_timezone(&Utc)) - .ok() - }) - .unwrap_or_else(|| { - let now = Utc::now(); - mutations.insert("timestamp".into(), now.to_rfc3339()); - now - }); - let fragment_count = value - .get("fragment_count") - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert("fragment_count".into(), "0".into()); - 0 - }); - - let mut fragments = Vec::with_capacity(fragment_count); - for i in 0..fragment_count { - let duration = value - .get(&format!("fragment_{}_duration", i)) - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert(format!("fragment_{}_duration", i), "0".into()); - 0 - }); - let keyframe = value - .get(&format!("fragment_{}_keyframe", i)) - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - mutations.insert(format!("fragment_{}_keyframe", i), "false".into()); - false - }); - fragments.push(Fragment { duration, keyframe }); - } - - Self { - mutations, - ready, - discontinuity, - timestamp, - fragments, - } - } -} - -impl SegmentState { - #[inline(always)] - pub fn ready(&self) -> bool { - self.ready - } - - #[inline(always)] - pub fn discontinuity(&self) -> bool { - self.discontinuity - } - - #[inline(always)] - pub fn timestamp(&self) -> DateTime { - self.timestamp - } - - #[inline(always)] - pub fn fragments(&self) -> &[Fragment] { - &self.fragments - } - - pub fn set_ready(&mut self, ready: bool) { - self.mutations.insert("ready".into(), ready.to_string()); - self.ready = ready; - } - - pub fn set_discontinuity(&mut self, discontinuity: bool) { - self.mutations - .insert("discontinuity".into(), discontinuity.to_string()); - self.discontinuity = discontinuity; - } - - pub fn insert_fragment(&mut self, fragment: Fragment) { - let idx = self.fragments.len(); - self.mutations.insert( - format!("fragment_{}_duration", idx), - fragment.duration.to_string(), - ); - self.mutations.insert( - format!("fragment_{}_keyframe", idx), - fragment.keyframe.to_string(), - ); - self.mutations - .insert("fragment_count".into(), (idx + 1).to_string()); - self.fragments.push(fragment); - } - - pub fn extract_mutations(&mut self) -> HashMap { - std::mem::take(&mut self.mutations) - } -} diff --git a/video/transcoder/src/transcoder/mod.rs b/video/transcoder/src/transcoder/mod.rs index 164ef1ec..656e339b 100644 --- a/video/transcoder/src/transcoder/mod.rs +++ b/video/transcoder/src/transcoder/mod.rs @@ -1,8 +1,8 @@ -use std::{pin::pin, sync::Arc}; +use std::{sync::Arc, time::Duration}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; +use async_nats::jetstream::consumer::pull::Config; use futures::StreamExt; -use lapin::{options::BasicConsumeOptions, types::FieldTable}; use tokio::select; use tokio_util::sync::CancellationToken; @@ -11,38 +11,51 @@ use crate::{global::GlobalState, transcoder::job::handle_message}; pub(crate) mod job; pub async fn run(global: Arc) -> Result<()> { - let mut consumer = pin!(global.rmq.basic_consume( - &global.config.transcoder.rmq_queue, - &global.config.name, - BasicConsumeOptions::default(), - FieldTable::default() - )); + let stream = global + .jetstream + .get_stream(global.config.transcoder.transcoder_request_subject.clone()) + .await?; + + let consumer = stream + .create_consumer(Config { + name: Some("transcoder".to_string()), + durable_name: Some("transcoder".to_string()), + ..Default::default() + }) + .await?; + + let mut messages = consumer.messages().await?; let shutdown_token = CancellationToken::new(); let child_token = shutdown_token.child_token(); - let _drop_token = shutdown_token.drop_guard(); + let _drop_guard = shutdown_token.clone().drop_guard(); loop { select! { - m = consumer.next() => { + m = messages.next() => { let Some(m) = m else { - tracing::debug!("rmq stream closed"); - return Err(anyhow!("rmq stream closed")); + bail!("nats stream closed"); }; let m = m.map_err(|e| { - tracing::debug!("failed to get message: {}", e); anyhow!("failed to get message: {}", e) })?; - tracing::debug!("got message: {:?}", m); - tokio::spawn(handle_message(global.clone(), m, child_token.clone())); }, _ = global.ctx.done() => { tracing::debug!("context done"); - return Ok(()); + break; } } } + + drop(messages); + drop(consumer); + + tokio::time::sleep(Duration::from_millis(100)).await; + + global.nats.flush().await?; + + Ok(()) }