diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e250816 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +jobs = 8 + +[env] +FFMPEG_DIR = { value = ".cache/ffmpeg", relative = true } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dbfcaae..9b8ae18 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,14 +1,10 @@ name: CI - on: push: - branches: - - master + branches: [master] pull_request: - permissions: contents: read - jobs: check: runs-on: ubuntu-24.04 @@ -17,30 +13,20 @@ jobs: with: fetch-depth: 0 filter: blob:none - - uses: actions/setup-go@v7 - id: go - with: - go-version-file: go.mod - cache: false - - name: Restore Go modules, compiler, and linter caches - id: go-cache - uses: actions/cache@v6 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - .cache/golangci-lint - key: go-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum') }}-${{ github.sha }} - restore-keys: | - go-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum') }}- - go-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}- - - uses: actions/setup-node@v6 - with: - node-version: "26" + - uses: pkgxdev/setup@v5 - uses: moonrepo/setup-toolchain@v0 with: moon-version: "2.5.5" - - run: mkdir -p "$HOME/.cache/go-build" + - run: rustup toolchain install 1.98.1 --profile minimal --component clippy,rustfmt + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + .cache/ffmpeg + ~/.pkgx + key: rust-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'prepare-ffmpeg.py') }} - name: Check affected tasks id: ci run: moon ci diff --git a/.gitignore b/.gitignore index 0c1fa3d..341f543 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,5 @@ -/listenbox - -.task/ -.cache/ -dist/ -build/ -coverage/ -.test-results/ -test-results/ -*.test -*.prof -*.pprof -dump-*.json -**/.moon/cache/ +/target/ +/.cache/ +/dist/ +/.moon/cache/ +__pycache__/ diff --git a/.golangci.yaml b/.golangci.yaml deleted file mode 100644 index 5007950..0000000 --- a/.golangci.yaml +++ /dev/null @@ -1,68 +0,0 @@ -version: "2" - -run: - timeout: 5m - tests: true - relative-path-mode: gomod - -linters: - default: all - disable: - - arangolint - - clickhouselint - - depguard - - err113 - - exhaustruct - - exhaustruct_v5 - - forbidigo - - ginkgolinter - - gochecknoglobals - - goheader - - gomodguard - - gomodguard_v2 - - loggercheck - - nilnil - - nlreturn - - noinlineerr - - protogetter - - rowserrcheck - - sloglint - - spancheck - - sqlclosecheck - - tagliatelle - - testifylint - - testpackage - - unqueryvet - - varnamelen - - wrapcheck - - wsl - - wsl_v5 - - zerologlint - settings: - cyclop: - max-complexity: 12 - package-average: 8 - exclusions: - generated: lax - warn-unused: true - paths: - - internal/cliconfig/.* - - publicapi/.* - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - -issues: - max-issues-per-linter: 0 - max-same-issues: 0 - -formatters: - enable: - - gci - - gofumpt - - goimports - exclusions: - generated: strict - warn-unused: true diff --git a/.moon/tasks/all.yml b/.moon/tasks/all.yml index 4dc1d06..54bbc2e 100644 --- a/.moon/tasks/all.yml +++ b/.moon/tasks/all.yml @@ -1,8 +1,5 @@ -env: - GOCACHE: ${HOME}/.cache/go-build - GOTMPDIR: ${HOME}/.cache/go-build taskOptions: - cache: false + cache: true unixShell: bash outputStyle: stream implicitInputs: diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bfc335f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3920 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "cipher", + "ctr", + "ctutils", + "ghash", +] + +[[package]] +name = "aes-kw" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" +dependencies = [ + "aes", + "const-oid", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotlic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f552f56f302af0006c32b50bfa2bdb4696fd6ba33c3ab9f6225fefdb1efdc680" +dependencies = [ + "brotlic-sys", +] + +[[package]] +name = "brotlic-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdec5c62bc97b56349053cf66ba503af5c2448591be61c3ad70a5f11b57e574" +dependencies = [ + "cc", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clang" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ed30b30e1e17966b55ddd0b9dd2758fd824f0a6da6875c8abb17e6aba47c28" +dependencies = [ + "clang-sys", + "libc", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "der_derive", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "ffmpeg-sys-the-third" +version = "6.0.0+ffmpeg-9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8beaa5d31a4dee3c5a93caf6c0dd3e9d49fb9849c96354f8d619ec6b062036f2" +dependencies = [ + "bindgen", + "cc", + "clang", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ffmpeg-the-third" +version = "6.0.0+ffmpeg-9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf29dd4474510215c218b491acf6c06d27c42cf3579abdc20936290a3817ca31" +dependencies = [ + "bitflags 2.13.2", + "ffmpeg-sys-the-third", + "libc", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "libz-ng-sys", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7685beb53fc20efc2605f32f5d51e9ba18b8ef237961d1760169d2290d3bee" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libz-ng-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879917b256f6317769b9f374b435805a8697013098aacce5a38ac106cd6a9469" +dependencies = [ + "cmake", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "listenbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "ffmpeg-the-third", + "futures-util", + "hex", + "image", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "tempfile", + "tokio", + "tokio-util", + "url", + "uuid", + "youtubei", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "llrt_abort" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f3b00def04dc42f1f16e6b7f09993e007e39aaaf15e1cccd7a78f4de6f0d07" +dependencies = [ + "llrt_async_hooks", + "llrt_events", + "llrt_exceptions", + "llrt_timers", + "llrt_utils_watts", + "rquickjs", +] + +[[package]] +name = "llrt_async_hooks" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b198a6271ad9140062133a4a6633890f2f21158606e60ea0669e3de1a69ba74" +dependencies = [ + "llrt_hooking", + "llrt_utils_watts", + "rquickjs", + "tracing", +] + +[[package]] +name = "llrt_buffer" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73954ae0a8202982b83797ba4a982cdbb6f7bd9d99aafcc0aa45e98026fd7ab8" +dependencies = [ + "itoa", + "llrt_encoding", + "llrt_utils_watts", + "rquickjs", + "ryu", +] + +[[package]] +name = "llrt_build" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e770e9f9d0031d5788ab2f73254cdc9c516042b55db8bddf64d2f409b7015f4a" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "llrt_compression" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711835a1111f82abca9eb14c41e47b579f40e82e649405df43c1c559feccab45" +dependencies = [ + "brotlic", + "flate2", + "zstd", +] + +[[package]] +name = "llrt_context" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb4df06dfbfe41aee465176a0fce1823982247230054053642f2f3df76a7449" +dependencies = [ + "llrt_build", + "llrt_utils_watts", + "rquickjs", + "tokio", + "tracing", +] + +[[package]] +name = "llrt_crypto" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53427a112c52b67ccff06c3f09d66c76058784a9b30a4842a71ac968dc81391d" +dependencies = [ + "aes", + "aes-gcm", + "aes-kw", + "cbc", + "const-oid", + "crc32c", + "crc32fast", + "ctr", + "der", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "hkdf", + "hmac", + "llrt_buffer", + "llrt_context", + "llrt_encoding", + "llrt_json", + "llrt_utils_watts", + "md-5", + "once_cell", + "p256", + "p384", + "p521", + "pbkdf2", + "pkcs8", + "rand", + "rquickjs", + "rsa", + "sha1 0.11.0", + "spki", + "x25519-dalek", +] + +[[package]] +name = "llrt_dns_cache" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97794e08c2219ac370739b5a14b50ba151058b13c9c7c2b58cc31fbddf8e9b40" +dependencies = [ + "hyper-util", + "llrt_context", + "llrt_utils_watts", + "quick_cache", + "tokio", + "tower-service", +] + +[[package]] +name = "llrt_encoding" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb7748e8a3cfe2f8091fd6428c604c9bbf636a6972e5480e35fa6c245f92ea5" +dependencies = [ + "base64-simd", + "hex-simd", + "llrt_build", + "memchr", + "phf", +] + +[[package]] +name = "llrt_events" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c2d4a84603ed26e4ab681a0f5dd6561d59d4a99d581e6dba63dbde4c05675f6" +dependencies = [ + "llrt_utils_watts", + "rquickjs", + "tracing", +] + +[[package]] +name = "llrt_exceptions" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70409346fb3393fcb0c99dc7290ed28f3ca975fc8e587b8a52c7ab289efc8a" +dependencies = [ + "llrt_utils_watts", + "rquickjs", +] + +[[package]] +name = "llrt_fetch" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d789946fa9bbbe074e203292f28fcb43d04a79c5cf8a11cf7b9e8c6fe8361b8a" +dependencies = [ + "bytes", + "either", + "http-body-util", + "hyper", + "itoa", + "llrt_abort", + "llrt_buffer", + "llrt_compression", + "llrt_context", + "llrt_encoding", + "llrt_http", + "llrt_json", + "llrt_url", + "llrt_utils_watts", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rand", + "rquickjs", + "tokio", + "tracing", +] + +[[package]] +name = "llrt_hooking" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7cd1f745876fa47c2ea9e14edc44c4ba6dc68154e09a3eb9c834adde793c3f" +dependencies = [ + "llrt_utils_watts", + "once_cell", + "rquickjs", +] + +[[package]] +name = "llrt_http" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f151f67488a38c50d94c9c7bf7411b3caab1a3fe4732e02bb352bcef59f945fe" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "llrt_dns_cache", + "llrt_tls", + "llrt_utils_watts", + "once_cell", + "rquickjs", + "rustls", +] + +[[package]] +name = "llrt_intl" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320916bc3d8ac4b74674110a0f9e6b48704dc217502022367ef1f31efb3fabd2" +dependencies = [ + "itoa", + "jiff", + "llrt_utils_watts", + "rquickjs", +] + +[[package]] +name = "llrt_json" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad9ed9d3126d28a85ba63cbec88597774a2cf60fe53a0f38ac19b47140a8385" +dependencies = [ + "itoa", + "llrt_build", + "llrt_utils_watts", + "rquickjs", + "ryu", + "simd-json", +] + +[[package]] +name = "llrt_logging" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196dc2fa18cccb79273d2aaf0c1236e5ef341ec2a3f5114c0c08ab022910f01f" +dependencies = [ + "itoa", + "llrt_context", + "llrt_encoding", + "llrt_json", + "llrt_numbers", + "llrt_utils_watts", + "rquickjs", + "ryu", +] + +[[package]] +name = "llrt_modules" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ab0cb00684271451cc23380b8114a34f0ba6bee5182ac918a59a33eabc0882" +dependencies = [ + "home", + "llrt_abort", + "llrt_buffer", + "llrt_crypto", + "llrt_events", + "llrt_exceptions", + "llrt_fetch", + "llrt_hooking", + "llrt_http", + "llrt_intl", + "llrt_json", + "llrt_path", + "llrt_stream_web", + "llrt_timers", + "llrt_tls", + "llrt_url", + "llrt_util", + "llrt_utils_watts", + "once_cell", + "rquickjs", + "simd-json", + "tokio", + "tracing", +] + +[[package]] +name = "llrt_numbers" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e60c7403a79a173962c7db3259fffcce3ef21da54a43dd469ecd1ba059fb3dc7" +dependencies = [ + "itoa", + "llrt_utils_watts", + "rquickjs", + "ryu", +] + +[[package]] +name = "llrt_path" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20986d84f115bbcd8231c2a4c9af8de7e801bcff68f3819b7802d70ce2557815" +dependencies = [ + "llrt_utils_watts", + "memchr", + "rquickjs", +] + +[[package]] +name = "llrt_stream_web" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f53bc6376bb38daefecd18c7af425fb443c3c061ea5e8b81d9c96a1b5c709c3f" +dependencies = [ + "llrt_abort", + "llrt_utils_watts", + "rquickjs", +] + +[[package]] +name = "llrt_timers" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371aa29ecc3ff576b5dbab7380eb1e399f1139a101ba378c3f6270b74c799a5d" +dependencies = [ + "llrt_context", + "llrt_hooking", + "llrt_utils_watts", + "once_cell", + "rquickjs", + "tokio", +] + +[[package]] +name = "llrt_tls" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b564e349bce175d31e04266d01b67d5b11e86d1b36868c391ee08605461b80f" +dependencies = [ + "once_cell", + "rustls", + "tracing", + "webpki-roots", +] + +[[package]] +name = "llrt_url" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c194b668c0dba8c3d2b877b2f8589a337dc214f5c00c4dfdc83088cdb96c6cbb" +dependencies = [ + "llrt_utils_watts", + "rquickjs", + "url", +] + +[[package]] +name = "llrt_util" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6fcda9ff20b1683d5a1f6bb49f033c7891891a31d468c4bd6306d61e60e7380" +dependencies = [ + "llrt_context", + "llrt_encoding", + "llrt_logging", + "llrt_utils_watts", + "rquickjs", +] + +[[package]] +name = "llrt_utils_watts" +version = "0.8.1-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ace6f63a6b60a3bf738bf19f393c834c83a06d9e52c31d1c30a526584519569" +dependencies = [ + "libc", + "llrt_build", + "rquickjs", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "wnaf", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rquickjs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a" +dependencies = [ + "either", + "indexmap", + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" +dependencies = [ + "async-lock", + "chrono", + "dlopen2", + "either", + "hashbrown 0.16.1", + "indexmap", + "phf", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap", + "phf_generator", + "phf_shared", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.119", +] + +[[package]] +name = "rquickjs-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83" +dependencies = [ + "cc", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd-json" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32d7ab2678282d21e53374fbead7119b7eacbede73685dcaac472870a29a11c" +dependencies = [ + "halfbrown", + "ref-cast", + "simdutf8", + "value-trait", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "value-trait" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f4b4a98dfe54bc9ed3641af7ffcb837240269627dbd5cb047d13daa39736cc" +dependencies = [ + "float-cmp", + "halfbrown", + "itoa", + "ryu", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[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" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "youtubei" +version = "0.1.0" +source = "git+https://github.com/listenbox/youtubei.git?rev=a394160a92d3809bde4d4376d480373bc44ae82e#a394160a92d3809bde4d4376d480373bc44ae82e" +dependencies = [ + "flate2", + "futures-util", + "llrt_modules", + "llrt_utils_watts", + "reqwest", + "rquickjs", + "serde", + "serde_json", + "sha1 0.10.7", + "sha2 0.10.9", + "tar", + "tokio", + "toml", + "uuid", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..919d70a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "listenbox" +version = "0.1.0" +edition = "2024" +rust-version = "1.98" +description = "Publish and manage podcasts with Listenbox" +license = "MIT" +publish = false + +[dependencies] +anyhow = "1" +ffmpeg = { package = "ffmpeg-the-third", version = "=6.0.0", default-features = false, features = ["static", "format", "software-resampling"] } +chrono = { version = "0.4", default-features = false, features = ["std"] } +clap = { version = "4", features = ["derive"] } +futures-util = "0.3" +hex = "0.4" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tempfile = "3" +tokio = { version = "1", features = ["rt", "macros", "signal", "process", "fs", "io-util", "time"] } +tokio-util = { version = "0.7", features = ["io", "rt"] } +url = "2" +uuid = { version = "1", features = ["v4"] } +youtubei = { git = "https://github.com/listenbox/youtubei.git", rev = "a394160a92d3809bde4d4376d480373bc44ae82e" } + +[profile.release] +strip = true +lto = "thin" + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" diff --git a/README.md b/README.md index e507cbb..19d94da 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,86 @@ # Listenbox CLI -Official command-line client for publishing and managing podcasts on -[Listenbox](https://listenbox.app). +Publish podcasts and manage Listenbox shows, episodes, and members from your terminal. -The CLI uses Listenbox's public API. YouTube imports use [kkdai/youtube](https://github.com/kkdai/youtube) to download on your computer and statically linked FFmpeg 9.0.2 through [go-astiav](https://github.com/asticode/go-astiav) to prepare media before uploading it directly to Listenbox's media storage. +## Build and install -## Install - -Build a native distribution with Go 1.27+, Node.js 26, Moon, a C compiler, Make, pkg-config, and tar installed: +Install Rust (rustup), Python 3.12+, [Moon](https://moonrepo.dev), +[pkgx](https://pkgx.sh), a C compiler, make, and tar. Rust is pinned by `rust-toolchain.toml`. ```sh +git clone https://github.com/listenbox/cli.git +cd cli moon run cli:package -tar -xzf dist/listenbox.tar.gz -C /your/bin/directory +mkdir -p "$HOME/.local/bin" +cp dist/release/listenbox "$HOME/.local/bin/listenbox" ``` -The package contains one executable and its license notices. FFmpeg is built from a checksum-verified source archive, then linked into the CLI through CGO. No FFmpeg/FFprobe installation or media subprocess is needed at runtime. Linux builds still use the system C library. `prepare-ffmpeg.ts` records the exact codecs and build options; the additional avdevice, avfilter, and swscale libraries satisfy go-astiav's binding requirements. +Put `$HOME/.local/bin` on your PATH. `dist/listenbox.tar.gz` contains the release +executable and license notices. The executable embeds YouTube.js in QuickJS and +links FFmpeg libraries through `ffmpeg-the-third`. The installed CLI is self-contained. -Make sure the installation directory is on `PATH`, then authorize the CLI: +FFmpeg 9.0.2 is built from a checksum-verified source archive with the codecs needed +for AAC audio and AVC video packages. See `prepare-ffmpeg.py` for its configuration +and the packaged `FFmpeg-NOTICE.txt` for source and LGPL terms. -```sh -listenbox login -``` +YouTube extraction uses the typed Rust bindings in +[listenbox/youtubei](https://github.com/listenbox/youtubei), pinned by commit in +Cargo.toml and Cargo.lock. That crate downloads and verifies the published upstream CF-worker bundle during +Cargo builds. No JavaScript toolchain is needed. Playlist policy, media +selection, HTTP cancellation and upload cleanup remain in Rust here. -## Usage +## Use ```sh +listenbox login +listenbox auth status listenbox shows list -listenbox import "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID" +listenbox shows create --title "Field notes" --slug field-notes --type video --language en +listenbox episodes create --show field-notes --title "A new episode" --file episode.mp4 +listenbox episodes list --show field-notes +listenbox import --slug my-show https://example.com/feed.xml +listenbox import --slug my-videos 'https://www.youtube.com/playlist?list=PLAYLIST_ID' +listenbox members list listenbox help ``` -Run `listenbox help ` for command-specific usage. +Source uploads default to a draft. Add `--publication publish` to publish after +processing. A local resume record makes interrupted source uploads resumable by +running the same command again. YouTube downloads and media preparation happen +locally; only the finished MP4 and HLS package is uploaded. Failed or interrupted +package uploads request server cleanup, preserving already completed episodes. -## Develop +`login` prints a URL to approve in your browser. Credentials are atomically stored +in `~/.config/listenbox/auth.json` with private permissions. Optional configuration +lives in `~/.config/listenbox/config.yaml`, or at `--config PATH`: -Generated API client and configuration files are committed, so a fresh clone -builds without another repository or a code-generation step. - -```sh -moon run cli:build cli:test +```yaml +api_origin: https://v1.listenbox.app +dashboard_origin: https://web.listenbox.app +print_trace_ids: false ``` -[Moon](https://moonrepo.dev) runs the complete project check: +## Development ```sh moon run cli:check ``` -CI follows [Moon's CI guide](https://moonrepo.dev/docs/guides/ci): full Git -history, source-based affected selection, and plain `moon ci`. It runs the same -project tasks used locally; aggregate checks and maintenance commands are excluded -from automatic selection. The same `moon.yml` also works as a Listenbox submodule. - -Go modules, compiler output, and golangci-lint data are cached. Moon task results -are not restored across CI runs. Sources, module files, fixtures, templates, and -configuration determine affected tasks; `$CI` is not an input. CI verifies -formatting, and selected Go tests bypass Go's test-result cache. Native Moon -reports are attached to the workflow, including on failure. - -## License - -See [LICENSE](LICENSE). +The public API client in `src/publicapi/mod.rs` is generated by +[Oasmith](https://github.com/responsibleapi/oasmith) and committed so this repository +builds independently. In the Listenbox workspace, `openapi:generate-public-rust-client` +regenerates it from the canonical public contract before building. +`openapi:generate-cli-rust-structs` generates `src/cliconfig/mod.rs` from +`cli.responsible.ts`; YAML is decoded into that generated `CLIConfig`, then both +origins are validated. No schema interpreter runs during startup. Integrated +coverage lives in `apps/api/e2e` in that workspace and runs this Rust executable +against deterministic local services, including YouTube responses. + +Production origins and disabled trace-ID output are compiled into the same binary +used in E2E tests. Tests supply an isolated YAML config with their dynamic service +origins and `print_trace_ids: true`; no test-only executable or Cargo feature is +required. + +The historical [YouTube import benchmark](bench/README.md) compares the earlier Rust bridge +against Go with statically linked go-astiav and kkdai/youtube, measuring the full +import's wall time, CPU time, CPU utilization, peak RAM, and binary size. diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..fefd925 --- /dev/null +++ b/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,319 @@ +AAC preparation adapted from Mazit (https://github.com/meoyawn/mazit). + +MIT License + +Copyright (c) 2026 Mazit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Embedded upstream dependencies from listenbox/youtubei: + +@bufbuild/protobuf@2.15.0 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021-2026 Buf Technologies, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. + + +fflate@0.8.3 +MIT License + +Copyright (c) 2026 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +meriyah@7.3.3 +ISC License + +Copyright (c) 2019 and later, KFlash and others. + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +youtubei.js@18.1.0 +MIT License + +Copyright (c) 2021 LuanRT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..3dd67d9 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,75 @@ +# YouTube import benchmark + +Results for the Rust implementation using the shared `listenbox/youtubei` crate, +compared with the Go baseline from PR #7. Exact revisions are recorded below. + +One command, from process launch through successful publication: + +```sh +listenbox import --slug 'https://www.youtube.com/watch?v=w' +``` + +| Metric | Go | Rust | Rust vs. Go | +|---|---:|---:|---:| +| Wall time, including startup | 350 ms | 484 ms | +38.2% | +| CPU time (user + system) | 200 ms | 313 ms | +56.5% | +| CPU utilization | 56.3% | 65.6% | +9.2 pp | +| Peak RAM (RSS) | 30.1 MiB | 34.0 MiB | +12.8% | +| Binary size | 21.6 MiB | 19.9 MiB | -7.8% | + +Measured on 2026-09-24. Each runtime figure is the median of 20 imports after four +warmups per build. On this fixture, Rust takes 38.2% longer, uses 56.5% more CPU time and +12.8% more peak RAM, and has a 7.8% smaller executable. Deltas use unrounded +medians; CPU utilization is reported in percentage points (pp). + +## What runs + +The repository's YouTube web mock serves a two-second, 1080p AVC video (1.50 MiB) +and separate Opus audio (43 KiB). Both CLIs run their actual extraction library, +download both streams, transcode Opus to AAC with bundled FFmpeg, prepare MP4/HLS, +upload to the local API/storage, and publish the episode. Each import gets a fresh +video ID and show. The driver rejects failed imports, unpublished video, and runs +that did not download the Opus fixture. + +Go is [3d0f569](https://github.com/listenbox/cli/commit/3d0f56927a9fc0ae8a97d1cb64d209505ea2ae14): +**go-astiav 0.43.0 + kkdai/youtube 2.10.6**, from [PR #7](https://github.com/listenbox/cli/pull/7). +Rust is [637e7c2](https://github.com/listenbox/cli/commit/637e7c28b36fe1fb7263726987e72f4caf7508ab): +**ffmpeg-the-third 6.0.0 + YouTube.js 18.1.0 through rquickjs 0.11.0**, using +[`listenbox/youtubei` at a394160](https://github.com/listenbox/youtubei/commit/a394160a92d3809bde4d4376d480373bc44ae82e) +and the published CF-worker bundle. +Both are optimized, stripped release executables with statically linked FFmpeg +9.0.2. No external FFmpeg or JavaScript process runs during the import. + +## Measurement + +Linux x86-64, AMD EPYC-Rome VM, eight vCPUs with normal affinity. Go 1.27.1; +Rust 1.98.1. Builds and fixture setup finish before timing. The E2E driver repeats +Go/Rust/Rust/Go twelve times, discarding the first two groups as warmups. File +caches are warm; no builds or other test suites run alongside the samples. + +[`measure.c`](measure.c) measures the CLI child from `posix_spawn` to process exit +using a monotonic clock and `wait4`. Wall time includes startup, local HTTP waits, +and the complete import. CPU time combines user and system time across CLI +threads. CPU utilization is CPU time divided by wall time, expressed as a +percentage (100% means one fully occupied CPU). RAM is peak resident memory. +The supervisor and backend services' CPU/RAM are excluded. There is no separate +startup-only claim: startup is included in the one command's wall time. + +These results describe this short, deterministic local workload, not live YouTube +latency, signature challenges, long-video memory growth, or languages in general. +[Raw samples, binary hashes and summary](results/youtube-import.json) · +[Build and host details](results/environment.json). + +## Reproduce + +In a separate checkout of Go commit `3d0f569`, run `moon run cli:build`. +Then, from the parent Listenbox workspace with this Rust CLI submodule: + +```sh +LISTENBOX_BENCHMARK_GO=/absolute/go-checkout/dist/listenbox moon run api:benchmark-cli +``` + +That target builds the Rust release executable and measurement wrapper, starts +the canonical E2E environment, measures imports, and summarizes them with +[`summarize.py`](summarize.py). Results are written to +`apps/api/.test-results/cli-import-summary.json`. No Go correctness suite is run. diff --git a/bench/measure.c b/bench/measure.c new file mode 100644 index 0000000..6d7c754 --- /dev/null +++ b/bench/measure.c @@ -0,0 +1,52 @@ +// Linux process accounting for one real CLI invocation. The E2E harness owns +// fixtures and cancellation; this child retains its supervisor's process group. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +static double milliseconds(struct timespec value) { + return value.tv_sec * 1000.0 + value.tv_nsec / 1000000.0; +} + +static double cpu_ms(struct timeval value) { + return value.tv_sec * 1000.0 + value.tv_usec / 1000.0; +} + +int main(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, "usage: %s METRICS_JSON BINARY [ARGS...]\n", argv[0]); + return 1; + } + struct timespec begin, end; + struct rusage usage; + pid_t child; + int status; + if (clock_gettime(CLOCK_MONOTONIC, &begin)) { perror("clock"); return 1; } + int error = posix_spawn(&child, argv[2], NULL, NULL, argv + 2, environ); + if (error) { fprintf(stderr, "spawn: %s\n", strerror(error)); return 1; } + while (wait4(child, &status, 0, &usage) < 0) { + if (errno != EINTR) { perror("wait4"); return 1; } + } + if (clock_gettime(CLOCK_MONOTONIC, &end)) { perror("clock"); return 1; } + if (!WIFEXITED(status) || WEXITSTATUS(status)) { + fprintf(stderr, "CLI failed: wait status %d\n", status); + return 1; + } + FILE *output = fopen(argv[1], "wx"); + if (!output) { perror("metrics file"); return 1; } + double wall = milliseconds(end) - milliseconds(begin); + double cpu = cpu_ms(usage.ru_utime) + cpu_ms(usage.ru_stime); + int written = fprintf(output, + "{\"wall_ms\":%.6f,\"cpu_ms\":%.3f,\"cpu_percent\":%.6f,\"peak_rss_mib\":%.6f}\n", + wall, cpu, 100.0 * cpu / wall, usage.ru_maxrss / 1024.0); + int closed = fclose(output); + if (written < 0 || closed) { perror("write metrics"); return 1; } + return 0; +} diff --git a/bench/results/environment.json b/bench/results/environment.json new file mode 100644 index 0000000..b8d94df --- /dev/null +++ b/bench/results/environment.json @@ -0,0 +1,51 @@ +{ + "date_utc": "2026-09-24", + "platform": "Linux-7.0.0-29-generic-x86_64-with-glibc2.43", + "cpu": "AMD EPYC-Rome, KVM guest, 8 vCPUs, 1 thread/core", + "kernel": "7.0.0-29-generic", + "go": "go1.27.1 linux/amd64", + "rust": "rustc 1.98.1 (48a229cea 2026-09-01)", + "cargo": "cargo 1.98.1 (797e8a9bc 2026-08-05)", + "c_compiler": "GCC 15.2.0", + "ffmpeg": "9.0.2, static, same source archive and codec selection", + "go_astiav": "v0.43.0", + "go_youtube": "github.com/kkdai/youtube/v2 v2.10.6", + "rust_ffmpeg": "ffmpeg-the-third 6.0.0+ffmpeg-9.0", + "rust_youtube": "youtubei.js 18.1.0 published CF-worker bundle through listenbox/youtubei and rquickjs 0.11.0", + "go_flags": "CGO_ENABLED=1 go build -trimpath -ldflags=\"-s -w\"", + "rust_flags": "cargo build --locked --release; opt-level=3, strip=true, lto=thin", + "default_affinity": "0-7", + "unset_environment": [ + "GOMAXPROCS", + "GOGC", + "GOMEMLIMIT", + "RUST_LOG" + ], + "go_shared_libraries": [ + "libm.so.6", + "libc.so.6" + ], + "rust_shared_libraries": [ + "libgcc_s.so.1", + "libm.so.6", + "libc.so.6" + ], + "warmups_per_build": 4, + "samples_per_build": 20, + "workload": "One fresh YouTube video import: local Innertube responses, 1080p AVC + Opus download, AAC transcode, MP4/HLS packaging, upload and publication", + "measurement": "CLOCK_MONOTONIC and wait4 for the CLI child only; supervisor, fixture setup and backend CPU/RAM excluded", + "fixture_workspace_commit": "cd978227eb7ad6ac9f7d01189dea8aaf7fd33dbc", + "fixture_files": { + "apps/fixtures/e2e/youtube/dash-range/1080/manifest-stream0.mp4": { + "bytes": 1570121, + "sha256": "7b94dd83068e85136002f6eec994112368b9c3d077351f1ee2a8b5bca60c3b94" + }, + "apps/fixtures/e2e/youtube/audio/opus.webm": { + "bytes": 44326, + "sha256": "0a5eaec5205f2add1314c71362e1b0720a49eaec41a28a77d655dc53ff8a56ae" + } + }, + "measurement_started_utc": "2026-09-24T23:12:03.040679982Z", + "measurement_finished_utc": "2026-09-24T23:12:33.126200161Z", + "rust_youtubei_commit": "a394160a92d3809bde4d4376d480373bc44ae82e" +} diff --git a/bench/results/youtube-import.json b/bench/results/youtube-import.json new file mode 100644 index 0000000..ddf99ac --- /dev/null +++ b/bench/results/youtube-import.json @@ -0,0 +1,420 @@ +{ + "command": "listenbox import --slug https://www.youtube.com/watch?v=w", + "warmups_per_build": 4, + "samples_per_build": 20, + "statistic": "median for each metric; CPU time is user + system; CPU utilization is 100 * CPU / wall per sample", + "summary": { + "go": { + "wall_ms": 350.198122, + "cpu_ms": 199.74200000000002, + "cpu_percent": 56.3455515, + "peak_rss_mib": 30.1464845, + "binary_mib": 21.590682983398438 + }, + "rust": { + "wall_ms": 483.86568350000005, + "cpu_ms": 312.5295, + "cpu_percent": 65.5943135, + "peak_rss_mib": 34.019531, + "binary_mib": 19.900833129882812 + } + }, + "binaries": { + "go": { + "commit": "3d0f56927a9fc0ae8a97d1cb64d209505ea2ae14", + "bytes": 22639472, + "sha256": "b14d4f4fe699d2995d21f9da7211a36fab4f1a2c7c2c98c9aa7c1101ed6a9692" + }, + "rust": { + "commit": "637e7c28b36fe1fb7263726987e72f4caf7508ab", + "bytes": 20867536, + "sha256": "8e98348e150d69701e87db2ace3409c773b5cee997aa8bcfd27a8d4acfb8c16f" + } + }, + "samples": [ + { + "build": "go", + "wall_ms": 566.890734, + "cpu_ms": 276.849, + "cpu_percent": 48.836395, + "peak_rss_mib": 30.191406, + "warmup": true + }, + { + "build": "rust", + "wall_ms": 493.682221, + "cpu_ms": 328.171, + "cpu_percent": 66.474138, + "peak_rss_mib": 33.796875, + "warmup": true + }, + { + "build": "rust", + "wall_ms": 437.512006, + "cpu_ms": 305.193, + "cpu_percent": 69.756486, + "peak_rss_mib": 34.542969, + "warmup": true + }, + { + "build": "go", + "wall_ms": 346.540331, + "cpu_ms": 174.064, + "cpu_percent": 50.229074, + "peak_rss_mib": 30.417969, + "warmup": true + }, + { + "build": "go", + "wall_ms": 397.396702, + "cpu_ms": 209.732, + "cpu_percent": 52.776482, + "peak_rss_mib": 29.664062, + "warmup": true + }, + { + "build": "rust", + "wall_ms": 493.302278, + "cpu_ms": 286.589, + "cpu_percent": 58.096022, + "peak_rss_mib": 34.328125, + "warmup": true + }, + { + "build": "rust", + "wall_ms": 478.671711, + "cpu_ms": 319.18, + "cpu_percent": 66.680356, + "peak_rss_mib": 34.394531, + "warmup": true + }, + { + "build": "go", + "wall_ms": 321.654088, + "cpu_ms": 176.034, + "cpu_percent": 54.727736, + "peak_rss_mib": 29.773438, + "warmup": true + }, + { + "build": "go", + "wall_ms": 374.371557, + "cpu_ms": 210.866, + "cpu_percent": 56.325326, + "peak_rss_mib": 30.78125, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 472.360875, + "cpu_ms": 315.546, + "cpu_percent": 66.801892, + "peak_rss_mib": 33.960938, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 509.836489, + "cpu_ms": 324.802, + "cpu_percent": 63.707092, + "peak_rss_mib": 34.503906, + "warmup": false + }, + { + "build": "go", + "wall_ms": 421.890348, + "cpu_ms": 195.042, + "cpu_percent": 46.230496, + "peak_rss_mib": 29.558594, + "warmup": false + }, + { + "build": "go", + "wall_ms": 364.600731, + "cpu_ms": 225.97, + "cpu_percent": 61.977385, + "peak_rss_mib": 30.898438, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 487.754444, + "cpu_ms": 294.835, + "cpu_percent": 60.447425, + "peak_rss_mib": 34.277344, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 474.217802, + "cpu_ms": 282.084, + "cpu_percent": 59.48406, + "peak_rss_mib": 33.9375, + "warmup": false + }, + { + "build": "go", + "wall_ms": 347.938077, + "cpu_ms": 196.118, + "cpu_percent": 56.365777, + "peak_rss_mib": 30.503906, + "warmup": false + }, + { + "build": "go", + "wall_ms": 406.044559, + "cpu_ms": 186.727, + "cpu_percent": 45.986825, + "peak_rss_mib": 30.847656, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 445.987678, + "cpu_ms": 300.962, + "cpu_percent": 67.482134, + "peak_rss_mib": 33.917969, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 503.753612, + "cpu_ms": 332.076, + "cpu_percent": 65.920321, + "peak_rss_mib": 33.917969, + "warmup": false + }, + { + "build": "go", + "wall_ms": 346.803945, + "cpu_ms": 195.14, + "cpu_percent": 56.268103, + "peak_rss_mib": 29.316406, + "warmup": false + }, + { + "build": "go", + "wall_ms": 384.447966, + "cpu_ms": 203.681, + "cpu_percent": 52.980122, + "peak_rss_mib": 30.054688, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 479.885352, + "cpu_ms": 316.016, + "cpu_percent": 65.852395, + "peak_rss_mib": 34.773438, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 485.37252, + "cpu_ms": 294.375, + "cpu_percent": 60.649293, + "peak_rss_mib": 34.570312, + "warmup": false + }, + { + "build": "go", + "wall_ms": 355.474996, + "cpu_ms": 198.839, + "cpu_percent": 55.936142, + "peak_rss_mib": 30.371094, + "warmup": false + }, + { + "build": "go", + "wall_ms": 372.135717, + "cpu_ms": 203.533, + "cpu_percent": 54.693218, + "peak_rss_mib": 29.617188, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 505.91411, + "cpu_ms": 334.422, + "cpu_percent": 66.102525, + "peak_rss_mib": 33.882812, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 511.042363, + "cpu_ms": 349.241, + "cpu_percent": 68.338953, + "peak_rss_mib": 33.828125, + "warmup": false + }, + { + "build": "go", + "wall_ms": 358.865365, + "cpu_ms": 215.936, + "cpu_percent": 60.17187, + "peak_rss_mib": 29.980469, + "warmup": false + }, + { + "build": "go", + "wall_ms": 319.176533, + "cpu_ms": 183.732, + "cpu_percent": 57.564382, + "peak_rss_mib": 29.722656, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 477.181471, + "cpu_ms": 304.426, + "cpu_percent": 63.796693, + "peak_rss_mib": 34.03125, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 463.010398, + "cpu_ms": 327.131, + "cpu_percent": 70.653057, + "peak_rss_mib": 33.835938, + "warmup": false + }, + { + "build": "go", + "wall_ms": 339.632163, + "cpu_ms": 209.037, + "cpu_percent": 61.548058, + "peak_rss_mib": 30.859375, + "warmup": false + }, + { + "build": "go", + "wall_ms": 348.717731, + "cpu_ms": 200.645, + "cpu_percent": 57.53794, + "peak_rss_mib": 30.730469, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 459.089252, + "cpu_ms": 297.49, + "cpu_percent": 64.800036, + "peak_rss_mib": 34.003906, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 482.358847, + "cpu_ms": 320.036, + "cpu_percent": 66.348114, + "peak_rss_mib": 34.730469, + "warmup": false + }, + { + "build": "go", + "wall_ms": 330.77345, + "cpu_ms": 183.59, + "cpu_percent": 55.503245, + "peak_rss_mib": 30.667969, + "warmup": false + }, + { + "build": "go", + "wall_ms": 338.709059, + "cpu_ms": 203.153, + "cpu_percent": 59.978614, + "peak_rss_mib": 30.582031, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 471.605094, + "cpu_ms": 308.129, + "cpu_percent": 65.336232, + "peak_rss_mib": 34.628906, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 430.996455, + "cpu_ms": 300.511, + "cpu_percent": 69.724703, + "peak_rss_mib": 34.320312, + "warmup": false + }, + { + "build": "go", + "wall_ms": 337.654557, + "cpu_ms": 203.717, + "cpu_percent": 60.332963, + "peak_rss_mib": 29.914062, + "warmup": false + }, + { + "build": "go", + "wall_ms": 356.244833, + "cpu_ms": 184.089, + "cpu_percent": 51.674855, + "peak_rss_mib": 29.453125, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 493.267973, + "cpu_ms": 338.054, + "cpu_percent": 68.533539, + "peak_rss_mib": 33.84375, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 486.189113, + "cpu_ms": 307.476, + "cpu_percent": 63.242058, + "peak_rss_mib": 34.304688, + "warmup": false + }, + { + "build": "go", + "wall_ms": 351.678513, + "cpu_ms": 183.28, + "cpu_percent": 52.11578, + "peak_rss_mib": 29.246094, + "warmup": false + }, + { + "build": "go", + "wall_ms": 305.517302, + "cpu_ms": 197.765, + "cpu_percent": 64.731195, + "peak_rss_mib": 30.238281, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 486.141846, + "cpu_ms": 312.563, + "cpu_percent": 64.294609, + "peak_rss_mib": 34.878906, + "warmup": false + }, + { + "build": "rust", + "wall_ms": 490.380277, + "cpu_ms": 312.496, + "cpu_percent": 63.725238, + "peak_rss_mib": 34.007812, + "warmup": false + }, + { + "build": "go", + "wall_ms": 338.778489, + "cpu_ms": 209.768, + "cpu_percent": 61.918925, + "peak_rss_mib": 29.933594, + "warmup": false + } + ] +} diff --git a/bench/summarize.py b/bench/summarize.py new file mode 100644 index 0000000..60367b7 --- /dev/null +++ b/bench/summarize.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Summarize api:benchmark-cli: one median per metric, plus auditable raw samples.""" +import hashlib +import json +from pathlib import Path +import statistics +import subprocess +import sys + +log_path, go_binary, rust_binary = map(Path, sys.argv[1:]) +samples = [] +passed = 0 +suite_passed = False +for line in log_path.read_text().splitlines(): + event = json.loads(line) + if event.get("Action") == "pass": + if event.get("Test") == "TestCLIYouTubeImportBenchmark": + passed += 1 + elif "Test" not in event: + suite_passed = True + _, marker, sample = event.get("Output", "").partition("CLI_IMPORT_SAMPLE ") + if marker: + build, metrics = sample.split(" ", 1) + samples.append({"build": build, **json.loads(metrics)}) +if passed != 12 or not suite_passed or len(samples) != 48: + raise SystemExit("Expected 12 passing measurement cases and 48 successful imports") +if [row["build"] for row in samples] != ["go", "rust", "rust", "go"] * 12: + raise SystemExit("Unexpected import order") + +summary = {} +binaries = {} +for build, binary in (("go", go_binary), ("rust", rust_binary)): + rows = [row for row in samples if row["build"] == build] + for index, row in enumerate(rows): + row["warmup"] = index < 4 + measured = rows[4:] + metrics = ("wall_ms", "cpu_ms", "cpu_percent", "peak_rss_mib") + if any(row[key] <= 0 for row in rows for key in metrics): + raise SystemExit("Nonpositive process accounting value") + summary[build] = {key: statistics.median(row[key] for row in measured) for key in metrics} + data = binary.read_bytes() + summary[build]["binary_mib"] = len(data) / 1024**2 + binaries[build] = { + "commit": subprocess.check_output( + ["git", "-C", str(binary.parent), "rev-parse", "HEAD"], text=True + ).strip(), + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } +json.dump({ + "command": "listenbox import --slug https://www.youtube.com/watch?v=w", + "warmups_per_build": 4, + "samples_per_build": 20, + "statistic": "median for each metric; CPU time is user + system; CPU utilization is 100 * CPU / wall per sample", + "summary": summary, + "binaries": binaries, + "samples": samples, +}, sys.stdout, indent=2) +print() diff --git a/cmd/listenbox/main.go b/cmd/listenbox/main.go deleted file mode 100644 index a937c24..0000000 --- a/cmd/listenbox/main.go +++ /dev/null @@ -1,8 +0,0 @@ -// Command listenbox publishes and manages Listenbox podcasts. -package main - -import "github.com/listenbox/listenbox-cli/internal/cli" - -func main() { - cli.Execute() -} diff --git a/ffmpeg-env.sh b/ffmpeg-env.sh deleted file mode 100644 index ce78a7c..0000000 --- a/ffmpeg-env.sh +++ /dev/null @@ -1,4 +0,0 @@ -# Source from the project root before compiling packages that use go-astiav. -export CGO_ENABLED=1 -export PKG_CONFIG_PATH="$PWD/.cache/ffmpeg/lib/pkgconfig" -export CGO_LDFLAGS="$(pkg-config --static --libs libavcodec libavdevice libavfilter libavformat libswresample libswscale libavutil)" diff --git a/go.mod b/go.mod deleted file mode 100644 index 6c2ddef..0000000 --- a/go.mod +++ /dev/null @@ -1,35 +0,0 @@ -module github.com/listenbox/listenbox-cli - -go 1.27.0 - -require ( - github.com/Oudwins/zog v0.23.0 - github.com/asticode/go-astiav v0.43.0 - github.com/kkdai/youtube/v2 v2.10.6 - github.com/knadh/koanf/parsers/yaml v1.1.1 - github.com/knadh/koanf/providers/file v1.2.1 - github.com/knadh/koanf/providers/rawbytes v1.0.1 - github.com/knadh/koanf/v2 v2.3.6 - github.com/mattn/go-isatty v0.0.24 - go.uber.org/goleak v1.3.0 - gotest.tools/v3 v3.5.2 -) - -require ( - github.com/asticode/go-astikit v0.42.0 // indirect - github.com/bitly/go-simplejson v0.5.1 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect - github.com/knadh/koanf/maps v0.1.2 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.35.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index bbdf6de..0000000 --- a/go.sum +++ /dev/null @@ -1,66 +0,0 @@ -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Oudwins/zog v0.23.0 h1:aNjOcy7mE5NWOQVR9sFO1L0YCKfEjWMlaUTNquKGRQY= -github.com/Oudwins/zog v0.23.0/go.mod h1:yznncrIHC5tQeOui28FMz67LEGZV3nAoCdWL5t8QsNM= -github.com/asticode/go-astiav v0.43.0 h1:Pn+lQSdDs9ifSh5KcmesyUAjCC0/qJwAWFa1TYnGxBw= -github.com/asticode/go-astiav v0.43.0/go.mod h1:GI0pHw6K2/pl/o8upCtT49P/q4KCwhv/8nGLlCsZLdA= -github.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w= -github.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= -github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= -github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= -github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= -github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/kkdai/youtube/v2 v2.10.6 h1:4sKaX6GtjbsDRnPINrf2rtBIxRKz5eXQZ5ccUVPjkyg= -github.com/kkdai/youtube/v2 v2.10.6/go.mod h1:Oj3uSagusCkXuPiripRDgAXyCaKIjAHAY90qC8Sd+m4= -github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= -github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= -github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= -github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= -github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= -github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= -github.com/knadh/koanf/providers/rawbytes v1.0.1 h1:JCQoly+djX23Okr8kqtS19R7UXKleTAp62Vib2VrVYs= -github.com/knadh/koanf/providers/rawbytes v1.0.1/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo= -github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= -github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/cli/arguments.go b/internal/cli/arguments.go deleted file mode 100644 index ca9f24f..0000000 --- a/internal/cli/arguments.go +++ /dev/null @@ -1,131 +0,0 @@ -package cli - -import ( - "fmt" - "regexp" - "sort" - "strings" - - z "github.com/Oudwins/zog" - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -type showsCreateArguments struct { - Artwork string - Language string - Title string - Slug string - Type string -} - -type episodesCreateArguments struct { - Show string - Title string - Description *string - File string - Publication publicapi.EpisodePublication -} - -var showSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) - -var showsCreateArgumentsSchema = z.Struct(z.Shape{ - titleFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")), - slugFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")).Match( - showSlugPattern, - z.Message("must use lowercase letters, numbers, and single hyphens"), - ), - typeFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")).OneOf( - []string{"audio", "video"}, - z.Message("must be audio or video"), - ), - languageFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")).Match( - regexp.MustCompile(`^[a-z]{2}(?:-[A-Z]{2})?$`), - z.Message("must use a language code such as en or en-US"), - ), -}) - -var episodesCreateArgumentsSchema = z.Struct(z.Shape{ - showFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")).Match( - showSlugPattern, - z.Message("must be a valid show slug"), - ), - titleFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")), - descriptionFlagName: z.Ptr( - z.String().Trim().Required(z.Message("must not be blank when provided")).Min( - 1, - z.Message("must not be blank when provided"), - ), - ), - fileFlagName: z.String().Trim().Required(z.Message("is required")).Min(1, z.Message("is required")), -}) - -type cliArgumentValidationError struct { - command string - issues z.ZogIssueList - order []string -} - -func (err *cliArgumentValidationError) Error() string { - positions := make(map[string]int, len(err.order)) - for index, field := range err.order { - positions[field] = index - } - issues := append(z.ZogIssueList(nil), err.issues...) - sort.SliceStable(issues, func(i, j int) bool { - return issuePosition(issues[i], positions) < issuePosition(issues[j], positions) - }) - var output strings.Builder - _, _ = fmt.Fprintf(&output, "%s: invalid arguments", err.command) - for _, issue := range issues { - _, _ = fmt.Fprintf(&output, "\n --%s %s", issueField(issue), issue.Message) - } - return output.String() -} - -func validateShowsCreateArguments(args *showsCreateArguments) error { - issues := showsCreateArgumentsSchema.Validate(args) - if len(issues) == 0 { - return nil - } - return &cliArgumentValidationError{ - command: "shows create", issues: issues, order: []string{titleFlagName, slugFlagName, typeFlagName, languageFlagName}, - } -} - -func validateEpisodesCreateArguments(args *episodesCreateArguments) error { - issues := episodesCreateArgumentsSchema.Validate(args) - switch args.Publication { - case publicapi.EpisodePublicationDraft, publicapi.EpisodePublicationPublish: - default: - message := "must be draft or publish" - if strings.TrimSpace(string(args.Publication)) == "" { - message = "is required" - } - issues = append(issues, &z.ZogIssue{ - Path: []string{publicationFlagName}, Value: args.Publication, - Message: message, - }) - } - if len(issues) == 0 { - return nil - } - return &cliArgumentValidationError{ - command: "episodes create", issues: issues, - order: []string{showFlagName, titleFlagName, descriptionFlagName, fileFlagName, publicationFlagName}, - } -} - -func issuePosition(issue *z.ZogIssue, positions map[string]int) int { - position, ok := positions[issueField(issue)] - if !ok { - return len(positions) - } - return position -} - -func issueField(issue *z.ZogIssue) string { - if issue == nil || len(issue.Path) == 0 { - return "argument" - } - return issue.Path[0] -} diff --git a/internal/cli/arguments_test.go b/internal/cli/arguments_test.go deleted file mode 100644 index 2e5a50e..0000000 --- a/internal/cli/arguments_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//nolint:lll // Fixtures keep each invocation together. -package cli - -import ( - "bytes" - "context" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" - "gotest.tools/v3/assert" -) - -//nolint:funlen // Validation table covers each required and optional field boundary. -func TestEpisodesCreateArgumentValidation(t *testing.T) { - t.Parallel() - - tests := make([]struct { - name string - args episodesCreateArguments - want string - }, 0, 8) - tests = append(tests, - struct { - name string - args episodesCreateArguments - want string - }{name: "show missing", args: episodesCreateArguments{Title: testEpisodeTitle, File: testEpisodeSource}, want: "--show is required"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "title missing", args: episodesCreateArguments{Show: testShowsCreateSlug, File: testEpisodeSource}, want: "--title is required"}, - ) - tests = append(tests, - struct { - name string - args episodesCreateArguments - want string - }{name: "file missing", args: episodesCreateArguments{Show: testShowsCreateSlug, Title: testEpisodeTitle}, want: "--file is required"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "show whitespace", args: episodesCreateArguments{Show: " ", Title: testEpisodeTitle, File: testEpisodeSource}, want: "--show is required"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "title whitespace", args: episodesCreateArguments{Show: testShowsCreateSlug, Title: " ", File: testEpisodeSource}, want: "--title is required"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "file whitespace", args: episodesCreateArguments{Show: testShowsCreateSlug, Title: testEpisodeTitle, File: " "}, want: "--file is required"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "show invalid", args: episodesCreateArguments{Show: "Not A Slug", Title: testEpisodeTitle, File: testEpisodeSource}, want: "--show must be a valid show slug"}, - struct { - name string - args episodesCreateArguments - want string - }{name: "publication invalid", args: episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, File: testEpisodeSource, - Publication: publicapi.EpisodePublication("later"), - }, want: "--publication must be draft or publish"}, - ) - blank := " " - tests = append(tests, struct { - name string - args episodesCreateArguments - want string - }{name: "description blank when present", args: episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, Description: &blank, File: testEpisodeSource, - }, want: "--description must not be blank when provided"}) - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - err := validateEpisodesCreateArguments(&testCase.args) - assert.ErrorContains(t, err, testCase.want) - }) - } -} - -func TestEpisodesCreateReportsAllMissingArgumentsDeterministically(t *testing.T) { - t.Parallel() - want := "episodes create: invalid arguments\n" + - " --show is required\n" + - " --title is required\n" + - " --file is required\n" + - " --publication is required" - for range 20 { - args := episodesCreateArguments{} - assert.Equal(t, validateEpisodesCreateArguments(&args).Error(), want) - } -} - -func TestEpisodesCreateDescriptionMayBeOmitted(t *testing.T) { - t.Parallel() - args := episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, File: testEpisodeSource, - Publication: publicapi.EpisodePublicationDraft, - } - assert.NilError(t, validateEpisodesCreateArguments(&args)) -} - -func TestShowsCreateArgumentOrderIsDeterministic(t *testing.T) { - t.Parallel() - want := "shows create: invalid arguments\n" + - " --title is required\n" + - " --slug is required\n" + - " --type is required\n" + - " --language is required" - for range 20 { - args := showsCreateArguments{} - assert.Equal(t, validateShowsCreateArguments(&args).Error(), want) - } -} - -func TestEpisodesCreateMissingFlagValueIsControlled(t *testing.T) { - t.Parallel() - err := runEpisodesCreateCommand( - context.Background(), []string{"--show"}, &bytes.Buffer{}, &bytes.Buffer{}, - t.TempDir(), "", false, loadedCLIConfig{}, - ) - assert.ErrorContains(t, err, "flag needs an argument: -show") -} - -func TestEpisodesCreateValidationMakesNoHTTPRequest(t *testing.T) { - t.Parallel() - var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - calls.Add(1) - })) - defer server.Close() - home := t.TempDir() - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - err := runEpisodesCreateCommand( - context.Background(), []string{"--show", testShowsCreateSlug}, &bytes.Buffer{}, &bytes.Buffer{}, - home, "", false, - loadedCLIConfig{apiOrigin: server.URL, dashboardOrigin: server.URL}, - ) - assert.ErrorContains(t, err, "--title is required") - assert.ErrorContains(t, err, "--file is required") - assert.Equal(t, calls.Load(), int64(0)) -} - -func TestShowsCreateRejectsWhitespaceAndInvalidSlug(t *testing.T) { - t.Parallel() - arguments := showsCreateArguments{Title: strings.Repeat(" ", 3), Slug: "Bad Slug", Type: "audio", Language: "en"} - err := validateShowsCreateArguments(&arguments) - assert.ErrorContains(t, err, "--title is required") - assert.ErrorContains(t, err, "--slug must use lowercase letters, numbers, and single hyphens") -} diff --git a/internal/cli/auth.go b/internal/cli/auth.go deleted file mode 100644 index c5a2388..0000000 --- a/internal/cli/auth.go +++ /dev/null @@ -1,122 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -const ( - authDirectoryMode os.FileMode = 0o700 - authFileMode os.FileMode = 0o600 -) - -type storedAuth struct { - APIKey string `json:"api_key"` -} - -func defaultAuthPath(home string) string { - return filepath.Join(home, ".config", "listenbox", "auth.json") -} - -func readUsableStoredAuth(path string) (storedAuth, bool) { - raw, err := os.ReadFile(path) - if err != nil || len(raw) > 64<<10 { - return storedAuth{}, false - } - var auth storedAuth - if err := json.Unmarshal(raw, &auth); err != nil { - return storedAuth{}, false - } - if !validHeaderCredential(auth.APIKey) { - return storedAuth{}, false - } - return auth, true -} - -func validHeaderCredential(value string) bool { - if value == "" || strings.TrimSpace(value) != value { - return false - } - for _, char := range value { - if char < 0x21 || char > 0x7e { - return false - } - } - return true -} - -func writeStoredAuth(path string, auth storedAuth) error { - directory := filepath.Dir(path) - if err := os.MkdirAll(directory, authDirectoryMode); err != nil { - return fmt.Errorf("create auth directory %q: %w", directory, err) - } - if err := os.Chmod(directory, authDirectoryMode); err != nil { - return fmt.Errorf("set auth directory permissions %q: %w", directory, err) - } - - temporaryPath, err := writeTemporaryAuth(directory, auth) - if err != nil { - return err - } - defer func() { _ = os.Remove(temporaryPath) }() - if err := os.Rename(temporaryPath, path); err != nil { - return fmt.Errorf("replace auth file %q: %w", path, err) - } - if err := os.Chmod(path, authFileMode); err != nil { - return fmt.Errorf("set auth file permissions %q: %w", path, err) - } - return syncAuthDirectory(directory) -} - -func writeTemporaryAuth(directory string, auth storedAuth) (string, error) { - temporary, err := os.CreateTemp(directory, ".auth.json-*") - if err != nil { - return "", fmt.Errorf("create temporary auth file in %q: %w", directory, err) - } - temporaryPath := temporary.Name() - keep := false - defer func() { - if !keep { - _ = os.Remove(temporaryPath) - } - }() - - if err := temporary.Chmod(authFileMode); err != nil { - _ = temporary.Close() - return "", fmt.Errorf("set temporary auth permissions %q: %w", temporaryPath, err) - } - encoder := json.NewEncoder(temporary) - encoder.SetIndent("", " ") - //nolint:gosec // Persisting the approved API key is this function's purpose. - if err := encoder.Encode(auth); err != nil { - _ = temporary.Close() - return "", fmt.Errorf("encode temporary auth file %q: %w", temporaryPath, err) - } - if err := temporary.Sync(); err != nil { - _ = temporary.Close() - return "", fmt.Errorf("sync temporary auth file %q: %w", temporaryPath, err) - } - if err := temporary.Close(); err != nil { - return "", fmt.Errorf("close temporary auth file %q: %w", temporaryPath, err) - } - keep = true - return temporaryPath, nil -} - -func syncAuthDirectory(directory string) error { - dirHandle, err := os.Open(directory) - if err != nil { - return fmt.Errorf("open auth directory %q for sync: %w", directory, err) - } - if err := dirHandle.Sync(); err != nil { - _ = dirHandle.Close() - return fmt.Errorf("sync auth directory %q: %w", directory, err) - } - if err := dirHandle.Close(); err != nil { - return fmt.Errorf("close auth directory %q: %w", directory, err) - } - return nil -} diff --git a/internal/cli/cli.dev.yaml b/internal/cli/cli.dev.yaml deleted file mode 100644 index 6cf01f9..0000000 --- a/internal/cli/cli.dev.yaml +++ /dev/null @@ -1,3 +0,0 @@ -api_origin: http://localhost:8080 -dashboard_origin: http://localhost:5174 -print_trace_ids: true diff --git a/internal/cli/cli.prod.yaml b/internal/cli/cli.prod.yaml deleted file mode 100644 index 87cc557..0000000 --- a/internal/cli/cli.prod.yaml +++ /dev/null @@ -1,3 +0,0 @@ -api_origin: https://v1.listenbox.app -dashboard_origin: https://web.listenbox.app -print_trace_ids: false diff --git a/internal/cli/command.go b/internal/cli/command.go deleted file mode 100644 index 98db2ae..0000000 --- a/internal/cli/command.go +++ /dev/null @@ -1,721 +0,0 @@ -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - configFlagName = "config" - titleFlagName = "title" - slugFlagName = "slug" - typeFlagName = "type" - showFlagName = "show" - episodeFlagName = "episode" - descriptionFlagName = "description" - fileFlagName = "file" - publicationFlagName = "publication" - artworkFlagName = "artwork" - languageFlagName = "language" - limitFlagName = "limit" - helpCommandName = "help" - shortHelpFlag = "-h" - longHelpFlag = "--help" - importCommandName = "import" - authCommandName = "auth" - loginCommandName = "login" - statusCommandName = "status" - showsCommandName = "shows" - episodesCommandName = "episodes" - membersCommandName = "members" - listCommandName = "list" - createCommandName = "create" - deleteCommandName = "delete" - inviteCommandName = "invite" - roleCommandName = "role" - removeCommandName = "remove" - yesFlagName = "yes" - nestedCommandArgs = 2 -) - -func runCLI(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("resolve user home directory: %w", err) - } - return runCLIWithHome(ctx, args, stdout, stderr, home) -} - -func runCLIWithHome( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, -) error { - defaultConfig, err := loadEmbeddedCLIConfig() - if err != nil { - return err - } - rootFlags := flag.NewFlagSet("listenbox", flag.ContinueOnError) - rootFlags.SetOutput(stderr) - configPath := "" - rootFlags.StringVar(&configPath, configFlagName, "", "path to CLI config file") - rootFlags.Usage = func() { writeRootUsage(stderr) } - if err := rootFlags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse listenbox flags: %w", err) - } - - configExplicit := flagWasSet(rootFlags, configFlagName) - remaining := rootFlags.Args() - if len(remaining) == 0 { - writeRootUsage(stderr) - return fmt.Errorf("command is required in arguments %v", args) - } - return dispatchCLICommand( - ctx, - remaining, - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - ) -} - -func dispatchCLICommand( - ctx context.Context, - remaining []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - switch remaining[0] { - case helpCommandName: - return runHelp(remaining[1:], stdout) - case loginCommandName: - return runLoginCommand( - ctx, - remaining[1:], - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - ) - case authCommandName: - return runAuthCommand( - ctx, - remaining[1:], - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - ) - case importCommandName: - return runImportCommand( - ctx, - remaining[1:], - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - ) - case showsCommandName, episodesCommandName, membersCommandName: - return dispatchTeamCommand( - ctx, remaining, stdout, stderr, home, configPath, configExplicit, defaultConfig, - ) - default: - writeRootUsage(stderr) - return fmt.Errorf("unknown command %q", remaining[0]) - } -} - -func dispatchTeamCommand( - ctx context.Context, - remaining []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - args := remaining[1:] - switch remaining[0] { - case showsCommandName: - return runShowsCommand(ctx, args, stdout, stderr, home, configPath, configExplicit, defaultConfig) - case episodesCommandName: - return runEpisodesCommand(ctx, args, stdout, stderr, home, configPath, configExplicit, defaultConfig) - case membersCommandName: - return runMembersCommand(ctx, args, stdout, stderr, home, configPath, configExplicit, defaultConfig) - default: - return fmt.Errorf("unknown team command %q", remaining[0]) - } -} - -func runAuthCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - if len(args) == 0 { - writeAuthUsage(stderr) - return errors.New("auth command is required") - } - switch args[0] { - case helpCommandName, shortHelpFlag, longHelpFlag: - if len(args) == 1 { - writeAuthUsage(stdout) - return nil - } - if len(args) == nestedCommandArgs && writeAuthCommandUsage(args[1], stdout) { - return nil - } - return fmt.Errorf("auth help received unknown command %q", args[1]) - case statusCommandName: - return runAuthStatusCommand( - ctx, - args[1:], - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - ) - default: - writeAuthUsage(stderr) - return fmt.Errorf("unknown auth command %q", args[0]) - } -} - -func runImportCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox import", flag.ContinueOnError) - flags.SetOutput(stderr) - slug := "" - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&slug, slugFlagName, "", "requested global show slug") - flags.Usage = func() { writeImportUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse import flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - slugExplicit := flagWasSet(flags, slugFlagName) - switch flags.NArg() { - case 0: - return errors.New("import requires exactly one source URL") - case 1: - default: - return fmt.Errorf("import received unexpected argument %q", flags.Arg(1)) - } - sourceURL := flags.Arg(0) - return importPodcast( - ctx, - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - sourceURL, - slug, - slugExplicit, - ) -} - -func runShowsCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - if len(args) == 0 { - writeShowsUsage(stderr) - return errors.New("shows command is required") - } - if isShowsHelpCommand(args[0]) { - return runShowsHelpCommand(args, stdout) - } - switch args[0] { - case listCommandName: - return runShowsListCommand(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - case createCommandName: - return runShowsCreateCommand(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - case deleteCommandName: - return runShowsDeleteCommand(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - default: - writeShowsUsage(stderr) - return fmt.Errorf("unknown shows command %q", args[0]) - } -} - -func isShowsHelpCommand(value string) bool { - return value == helpCommandName || value == shortHelpFlag || value == longHelpFlag -} - -func runShowsHelpCommand(args []string, stdout io.Writer) error { - if len(args) == 1 { - writeShowsUsage(stdout) - return nil - } - if len(args) != nestedCommandArgs { - return fmt.Errorf("shows help received unknown command %q", args[1]) - } - switch args[1] { - case createCommandName: - writeShowsCreateUsage(stdout) - case listCommandName: - writeShowsListUsage(stdout) - case deleteCommandName: - writeShowsDeleteUsage(stdout) - default: - return fmt.Errorf("shows help received unknown command %q", args[1]) - } - return nil -} - -func runShowsListCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox shows list", flag.ContinueOnError) - flags.SetOutput(stderr) - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.Usage = func() { writeShowsListUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse shows list flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("shows list received unexpected argument %q", flags.Arg(0)) - } - return listShows(ctx, stdout, home, configPath, configExplicit, defaultConfig) -} - -func runShowsCreateCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox shows create", flag.ContinueOnError) - flags.SetOutput(stderr) - arguments := showsCreateArguments{} - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&arguments.Title, titleFlagName, "", "show title (required)") - flags.StringVar(&arguments.Slug, slugFlagName, "", "global show slug (required)") - flags.StringVar(&arguments.Type, typeFlagName, "", "kind of podcast: audio or video (required)") - flags.StringVar(&arguments.Artwork, artworkFlagName, "", "path to optional show artwork") - flags.StringVar(&arguments.Language, languageFlagName, "", "podcast language code") - flags.Usage = func() { writeShowsCreateUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse shows create flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("shows create received unexpected argument %q", flags.Arg(0)) - } - if err := validateShowsCreateArguments(&arguments); err != nil { - return err - } - return createShow( - ctx, - stdout, - home, - configPath, - configExplicit, - defaultConfig, - arguments.Title, - arguments.Slug, - publicapi.ShowSourceKind(arguments.Type), - arguments.Artwork, - arguments.Language, - ) -} - -func runLoginCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - loginFlags := flag.NewFlagSet("listenbox login", flag.ContinueOnError) - loginFlags.SetOutput(stderr) - loginFlags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - loginFlags.Usage = func() { writeLoginUsage(stderr) } - if err := loginFlags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse login flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(loginFlags, configFlagName) - if loginFlags.NArg() != 0 { - return fmt.Errorf("login received unexpected argument %q", loginFlags.Arg(0)) - } - - return login(ctx, stdout, home, configPath, configExplicit, defaultConfig) -} - -func runAuthStatusCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox auth status", flag.ContinueOnError) - flags.SetOutput(stderr) - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.Usage = func() { writeAuthStatusUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse auth status flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("auth status received unexpected argument %q", flags.Arg(0)) - } - - return authStatus(ctx, stdout, home, configPath, configExplicit, defaultConfig) -} - -func runHelp(args []string, stdout io.Writer) error { - if len(args) == 0 { - writeRootUsage(stdout) - return nil - } - if len(args) == 1 && writeCommandUsage(args[0], stdout) { - return nil - } - if len(args) == nestedCommandArgs && writeNestedCommandUsage(args[0], args[1], stdout) { - return nil - } - return fmt.Errorf("help received unknown command %q", args[0]) -} - -func writeNestedCommandUsage(command string, nested string, writer io.Writer) bool { - switch command { - case authCommandName: - return writeAuthCommandUsage(nested, writer) - case showsCommandName: - return writeShowsCommandUsage(nested, writer) - case episodesCommandName: - return writeEpisodesCommandUsage(nested, writer) - case membersCommandName: - return writeMembersCommandUsage(nested, writer) - } - return false -} - -func writeShowsCommandUsage(command string, writer io.Writer) bool { - switch command { - case createCommandName: - writeShowsCreateUsage(writer) - case listCommandName: - writeShowsListUsage(writer) - case deleteCommandName: - writeShowsDeleteUsage(writer) - default: - return false - } - return true -} - -func writeEpisodesCommandUsage(command string, writer io.Writer) bool { - switch command { - case createCommandName: - writeEpisodesCreateUsage(writer) - case listCommandName: - writeEpisodesListUsage(writer) - default: - return false - } - return true -} - -func writeMembersCommandUsage(command string, writer io.Writer) bool { - switch command { - case listCommandName: - writeMembersListUsage(writer) - case inviteCommandName: - writeMembersInviteUsage(writer) - case roleCommandName: - writeMembersRoleUsage(writer) - case removeCommandName: - writeMembersRemoveUsage(writer) - default: - return false - } - return true -} - -func writeCommandUsage(command string, writer io.Writer) bool { - switch command { - case loginCommandName: - writeLoginUsage(writer) - case authCommandName: - writeAuthUsage(writer) - case importCommandName: - writeImportUsage(writer) - case showsCommandName: - writeShowsUsage(writer) - case episodesCommandName: - writeEpisodesUsage(writer) - case membersCommandName: - writeMembersUsage(writer) - default: - return false - } - return true -} - -func writeAuthCommandUsage(command string, writer io.Writer) bool { - switch command { - case statusCommandName: - writeAuthStatusUsage(writer) - default: - return false - } - return true -} - -func flagWasSet(flags *flag.FlagSet, name string) bool { - found := false - flags.Visit(func(current *flag.Flag) { - if current.Name == name { - found = true - } - }) - return found -} - -func writeRootUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] ") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Commands:") - _, _ = fmt.Fprintln(writer, " login Authorize this CLI with Listenbox") - _, _ = fmt.Fprintln(writer, " import Import an RSS feed or YouTube URL") - _, _ = fmt.Fprintln(writer, " auth Manage CLI authorization") - _, _ = fmt.Fprintln(writer, " shows Manage shows by slug") - _, _ = fmt.Fprintln(writer, " episodes Manage episodes") - _, _ = fmt.Fprintln(writer, " members Manage team members") - _, _ = fmt.Fprintln(writer, " help Show help") -} - -func writeImportUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] import [--config PATH] [--slug SLUG] ", - ) - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --slug SLUG Request a global show slug; omit for a server-generated slug") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Output:") - _, _ = fmt.Fprintln( - writer, - " Progress and the management link are written to stderr; the final show slug is written to stdout, with the "+ - "trace ID when enabled.", - ) -} - -func writeLoginUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] login [--config PATH]") -} - -func writeAuthStatusUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] auth status [--config PATH]") -} - -func writeAuthUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] auth ") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Commands:") - _, _ = fmt.Fprintln(writer, " status Show current CLI authorization") -} - -func writeShowsUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] shows ") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Commands:") - _, _ = fmt.Fprintln(writer, " list List accessible show slugs") - _, _ = fmt.Fprintln(writer, " create Create a show") - _, _ = fmt.Fprintln(writer, " delete Permanently delete a show") -} - -func writeShowsListUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] shows list [--config PATH]") -} - -func writeShowsDeleteUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] shows delete --show SLUG --yes [--config PATH]") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --show SLUG Global show slug (required)") - _, _ = fmt.Fprintln(writer, " --yes Confirm permanent deletion (required)") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") -} - -func writeShowsCreateUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] shows create --title TITLE --slug SLUG "+ - "--type audio|video --language LANGUAGE [--artwork FILE] [--config PATH]", - ) - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --title TITLE Show title (required)") - _, _ = fmt.Fprintln(writer, " --slug SLUG Global show slug (required)") - _, _ = fmt.Fprintln(writer, " --type TYPE Kind of podcast: audio or video (required)") - _, _ = fmt.Fprintln(writer, " --language CODE Podcast language, such as en or en-US (required)") - _, _ = fmt.Fprintln(writer, " --artwork FILE Optional canonical show artwork") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") -} - -func writeEpisodesUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] episodes ") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Commands:") - _, _ = fmt.Fprintln(writer, " list List episode IDs newest first") - _, _ = fmt.Fprintln(writer, " create Create an episode as a draft or publish it after processing") - _, _ = fmt.Fprintln(writer, " delete Permanently delete an episode by ID") -} - -func writeEpisodesDeleteUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] episodes delete --episode ID --yes [--config PATH]") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --episode ID Episode ID (required)") - _, _ = fmt.Fprintln(writer, " --yes Confirm permanent deletion (required)") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") -} - -func writeEpisodesListUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] episodes list --show SLUG [--limit N] [--config PATH]", - ) - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --show SLUG Existing show slug (required)") - _, _ = fmt.Fprintln(writer, " --limit N Episodes requested per page (1-500; default 100)") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") -} - -func writeEpisodesCreateUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] episodes create --show SLUG --title TITLE "+ - "[--description TEXT] --file PATH [--publication draft|publish] [--config PATH]", - ) - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Options:") - _, _ = fmt.Fprintln(writer, " --show SLUG Existing show slug (required)") - _, _ = fmt.Fprintln(writer, " --title TITLE Episode title (required)") - _, _ = fmt.Fprintln(writer, " --description TEXT Optional episode description") - _, _ = fmt.Fprintln(writer, " --file PATH Audio or video source file (required)") - _, _ = fmt.Fprintln(writer, " --publication MODE draft or publish after processing (default draft)") - _, _ = fmt.Fprintln(writer, " --config PATH Path to CLI config file") -} - -func writeMembersUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] members ") - _, _ = fmt.Fprintln(writer) - _, _ = fmt.Fprintln(writer, "Commands:") - _, _ = fmt.Fprintln(writer, " list List members and pending invitations") - _, _ = fmt.Fprintln(writer, " invite Invite an email address") - _, _ = fmt.Fprintln(writer, " role Change a member role") - _, _ = fmt.Fprintln(writer, " remove Remove a member") -} - -func writeMembersListUsage(writer io.Writer) { - _, _ = fmt.Fprintln(writer, "Usage: listenbox [--config PATH] members list [--show SLUG] [--config PATH]") -} - -func writeMembersInviteUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] members invite --email EMAIL --role read|write [--show SLUG] [--config PATH]", - ) -} - -func writeMembersRoleUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] members role --member USER_ID --role read|write [--show SLUG] [--config PATH]", - ) -} - -func writeMembersRemoveUsage(writer io.Writer) { - _, _ = fmt.Fprintln( - writer, - "Usage: listenbox [--config PATH] members remove --member USER_ID --yes [--show SLUG] [--config PATH]", - ) -} diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go deleted file mode 100644 index edcd303..0000000 --- a/internal/cli/command_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "fmt" - "strings" - "testing" - - "gotest.tools/v3/assert" -) - -const ( - titleFlagArgument = "--" + titleFlagName - showFlagArgument = "--" + showFlagName - slugFlagArgument = "--" + slugFlagName - typeFlagArgument = "--" + typeFlagName - languageFlagArgument = "--" + languageFlagName - testAudioPodcastType = "audio" - testVideoPodcastType = "video" - testShowsCreateTitle = "Daily" - testShowsCreateSlug = "daily" - testCLIShowSlug = "cli-show" - testShowIDField = "show_id" - testEpisodeTitle = "Title" - testEpisodeSource = "source.mp3" -) - -func TestHelpOnlyDocumentsSupportedCommands(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(stdout.String(), "login")) - assert.Assert(t, strings.Contains(stdout.String(), "auth")) - assert.Assert(t, !strings.Contains(stdout.String(), "whoami")) - assert.Assert(t, strings.Contains(stdout.String(), "shows")) - assert.Assert(t, bytes.Contains(stdout.Bytes(), []byte("episodes"))) -} - -func TestAuthHelpDocumentsSupportedCommands(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName, authCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, !strings.Contains(stdout.String(), "login")) - assert.Assert(t, strings.Contains(stdout.String(), "status")) - assert.Assert(t, !strings.Contains(stdout.String(), "whoami")) -} - -func TestLoginHelpDocumentsTopLevelCommand(t *testing.T) { - t.Parallel() - - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName, loginCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(stdout.String(), "listenbox [--config PATH] login")) - assert.Assert(t, !strings.Contains(stdout.String(), "auth login")) -} - -func TestUnknownRootWhoamiFailsCleanly(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{"whoami"}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, fmt.Sprintf("unknown command %q", "whoami")) -} - -func TestNestedAuthLoginFailsCleanly(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{authCommandName, loginCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, fmt.Sprintf("unknown auth command %q", loginCommandName)) -} - -func TestShowsCreateRequiresTitleSlugAndTypeFlags(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - args []string - want string - }{ - { - name: "all missing", - args: []string{showsCommandName, createCommandName}, - want: titleFlagArgument, - }, - { - name: "title missing", - args: []string{ - showsCommandName, - createCommandName, - slugFlagArgument, - testShowsCreateSlug, - typeFlagArgument, - testAudioPodcastType, - }, - want: titleFlagArgument, - }, - { - name: "slug missing", - args: []string{ - showsCommandName, - createCommandName, - titleFlagArgument, - testShowsCreateTitle, - typeFlagArgument, - testAudioPodcastType, - }, - want: slugFlagArgument, - }, - { - name: "type missing", - args: []string{ - showsCommandName, - createCommandName, - titleFlagArgument, - testShowsCreateTitle, - slugFlagArgument, - testShowsCreateSlug, - }, - want: "--type is required", - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - assertCLIErrorContains(t, testCase.args, testCase.want) - }) - } -} - -func TestShowsCreateReportsAllMissingRequiredFlags(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{showsCommandName, createCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.Equal(t, err.Error(), "shows create: invalid arguments\n"+ - " --title is required\n"+ - " --slug is required\n"+ - " --type is required\n"+ - " --language is required") -} - -func assertCLIErrorContains(t *testing.T, args []string, want string) { - t.Helper() - err := runCLIWithHome( - context.Background(), - args, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, want) -} - -func TestShowsCreateRejectsInvalidPodcastType(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{ - showsCommandName, - createCommandName, - titleFlagArgument, - testShowsCreateTitle, - slugFlagArgument, - testShowsCreateSlug, - typeFlagArgument, - "livestream", - }, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, "--type must be audio or video") -} - -func TestShowsCreateHelpDocumentsRequiredPodcastType(t *testing.T) { - t.Parallel() - - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName, showsCommandName, createCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(stdout.String(), "--title TITLE")) - assert.Assert(t, strings.Contains(stdout.String(), "--slug SLUG")) - assert.Assert(t, strings.Contains(stdout.String(), "--type TYPE")) - assert.Assert(t, strings.Contains(stdout.String(), "Kind of podcast: audio or video (required)")) - assert.Assert(t, !strings.Contains(strings.ToLower(stdout.String()), "source type")) - assert.Assert(t, !strings.Contains(strings.ToLower(stdout.String()), "source kind")) -} - -func TestShowsListHelpDocumentsCommand(t *testing.T) { - t.Parallel() - - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName, showsCommandName, listCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(stdout.String(), "shows list")) -} - -func TestUnknownCommandFailsCleanly(t *testing.T) { - t.Parallel() - err := runCLIWithHome( - context.Background(), - []string{"upload"}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, `unknown command "upload"`) -} diff --git a/internal/cli/config.go b/internal/cli/config.go deleted file mode 100644 index 6628b1d..0000000 --- a/internal/cli/config.go +++ /dev/null @@ -1,132 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "net" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/knadh/koanf/parsers/yaml" - "github.com/knadh/koanf/providers/file" - "github.com/knadh/koanf/v2" - cliconfig "github.com/listenbox/listenbox-cli/internal/cliconfig" -) - -const ( - httpScheme = "http" - httpsScheme = "https" -) - -type loadedCLIConfig struct { - apiOrigin string - dashboardOrigin string - printTraceIDs bool -} - -func loadCLIConfig( - path string, - explicit bool, - home string, - defaultConfig loadedCLIConfig, -) (loadedCLIConfig, error) { - if explicit && path == "" { - return loadedCLIConfig{}, fmt.Errorf("explicit config path %q is empty", path) - } - if !explicit { - path = filepath.Join(home, ".config", "listenbox", "config.yaml") - _, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return defaultConfig, nil - } - return loadedCLIConfig{}, fmt.Errorf("inspect user config %q: %w", path, err) - } - } - - k := koanf.New(".") - if err := k.Load(file.Provider(path), yaml.Parser()); err != nil { - return loadedCLIConfig{}, fmt.Errorf("load CLI config %q: %w", path, err) - } - return decodeCLIConfig(k, fmt.Sprintf("CLI config %q", path)) -} - -func decodeCLIConfig(k *koanf.Koanf, source string) (loadedCLIConfig, error) { - raw, err := json.Marshal(k.Raw()) - if err != nil { - return loadedCLIConfig{}, fmt.Errorf("marshal %s: %w", source, err) - } - var generated cliconfig.CLIConfig - if err := json.Unmarshal(raw, &generated); err != nil { - return loadedCLIConfig{}, fmt.Errorf("decode %s: %w", source, err) - } - apiOrigin, err := normalizeOrigin(generated.ApiOrigin, "API") - if err != nil { - return loadedCLIConfig{}, fmt.Errorf("validate api_origin in %s: %w", source, err) - } - dashboardOrigin, err := normalizeOrigin(generated.DashboardOrigin, "Dashboard") - if err != nil { - return loadedCLIConfig{}, fmt.Errorf("validate dashboard_origin in %s: %w", source, err) - } - return loadedCLIConfig{ - apiOrigin: apiOrigin, - dashboardOrigin: dashboardOrigin, - printTraceIDs: generated.PrintTraceIds, - }, nil -} - -func normalizeAPIOrigin(value string) (string, error) { - return normalizeOrigin(value, "API") -} - -func normalizeOrigin(value string, name string) (string, error) { - trimmed := strings.TrimSpace(value) - parsed, err := url.Parse(trimmed) - if err != nil { - return "", fmt.Errorf("parse %s origin %q: %w", name, value, err) - } - scheme := strings.ToLower(parsed.Scheme) - if err := validateOriginComponents(parsed, scheme, value, name); err != nil { - return "", err - } - - host := canonicalOriginHost(parsed, scheme) - return scheme + "://" + host, nil -} - -func validateOriginComponents(parsed *url.URL, scheme string, original string, name string) error { - switch { - case scheme != httpScheme && scheme != httpsScheme: - return fmt.Errorf("%s origin %q must use http or https", name, original) - case parsed.Host == "" || parsed.Hostname() == "": - return fmt.Errorf("%s origin %q must include a host", name, original) - case parsed.User != nil: - return fmt.Errorf("%s origin %q must not include user information", name, original) - case parsed.Path != "" && parsed.Path != "/": - return fmt.Errorf("%s origin %q must not include a path", name, original) - case parsed.RawQuery != "" || parsed.ForceQuery: - return fmt.Errorf("%s origin %q must not include a query", name, original) - case parsed.Fragment != "": - return fmt.Errorf("%s origin %q must not include a fragment", name, original) - default: - return nil - } -} - -func canonicalOriginHost(parsed *url.URL, scheme string) string { - hostname := strings.ToLower(parsed.Hostname()) - port := parsed.Port() - if (scheme == httpScheme && port == "80") || (scheme == httpsScheme && port == "443") { - port = "" - } - host := hostname - if strings.Contains(hostname, ":") { - host = "[" + hostname + "]" - } - if port != "" { - host = net.JoinHostPort(hostname, port) - } - return host -} diff --git a/internal/cli/config_dev.go b/internal/cli/config_dev.go deleted file mode 100644 index 6a0c33a..0000000 --- a/internal/cli/config_dev.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build dev - -package cli - -import _ "embed" - -//go:embed cli.dev.yaml -var embeddedCLIConfig []byte diff --git a/internal/cli/config_dev_test.go b/internal/cli/config_dev_test.go deleted file mode 100644 index 01386ab..0000000 --- a/internal/cli/config_dev_test.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build dev - -package cli - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -func TestEmbeddedCLIConfigDefaultsToDevelopment(t *testing.T) { - t.Parallel() - config, err := loadEmbeddedCLIConfig() - assert.NilError(t, err) - assert.Equal(t, config.apiOrigin, "http://localhost:8080") - assert.Equal(t, config.dashboardOrigin, "http://localhost:5174") - assert.Equal(t, config.printTraceIDs, true) -} diff --git a/internal/cli/config_prod.go b/internal/cli/config_prod.go deleted file mode 100644 index e134fab..0000000 --- a/internal/cli/config_prod.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !dev - -package cli - -import _ "embed" - -//go:embed cli.prod.yaml -var embeddedCLIConfig []byte diff --git a/internal/cli/config_prod_test.go b/internal/cli/config_prod_test.go deleted file mode 100644 index 9ba2689..0000000 --- a/internal/cli/config_prod_test.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !dev - -package cli - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -func TestEmbeddedCLIConfigDefaultsToProduction(t *testing.T) { - t.Parallel() - config, err := loadEmbeddedCLIConfig() - assert.NilError(t, err) - assert.Equal(t, config.apiOrigin, "https://v1.listenbox.app") - assert.Equal(t, config.dashboardOrigin, "https://web.listenbox.app") - assert.Equal(t, config.printTraceIDs, false) -} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go deleted file mode 100644 index 4c2f42b..0000000 --- a/internal/cli/config_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "gotest.tools/v3/assert" -) - -func TestLoadCLIConfigUsesEmbeddedDefaultWhenUserConfigIsMissing(t *testing.T) { - t.Parallel() - defaultConfig, err := loadEmbeddedCLIConfig() - assert.NilError(t, err) - config, err := loadCLIConfig("", false, t.TempDir(), defaultConfig) - assert.NilError(t, err) - assert.Equal(t, config.apiOrigin, defaultConfig.apiOrigin) - assert.Equal(t, config.dashboardOrigin, defaultConfig.dashboardOrigin) -} - -func TestLoadCLIConfigReadsImplicitUserConfig(t *testing.T) { - t.Parallel() - home := t.TempDir() - path := filepath.Join(home, ".config", "listenbox", "config.yaml") - assert.NilError(t, os.MkdirAll(filepath.Dir(path), 0o700)) - assert.NilError(t, os.WriteFile(path, []byte( - "api_origin: http://EXAMPLE.com:80/\n"+ - "dashboard_origin: https://WEB.example.com:443/\n"+ - "print_trace_ids: true\n", - ), 0o600)) - config, err := loadCLIConfig("", false, home, loadedCLIConfig{}) - assert.NilError(t, err) - assert.Equal(t, config.apiOrigin, "http://example.com") - assert.Equal(t, config.dashboardOrigin, "https://web.example.com") - assert.Equal(t, config.printTraceIDs, true) -} - -func TestLoadCLIConfigRejectsExplicitFailures(t *testing.T) { - t.Parallel() - home := t.TempDir() - invalidPath := filepath.Join(home, "invalid.yaml") - assert.NilError(t, os.WriteFile(invalidPath, []byte( - "api_origin: https://example.com/path\n"+ - "dashboard_origin: https://example.com\n"+ - "print_trace_ids: false\n", - ), 0o600)) - - tests := []struct { - name string - path string - want string - }{ - {name: "empty", path: "", want: "explicit config path"}, - {name: "missing", path: filepath.Join(home, "missing.yaml"), want: "load CLI config"}, - {name: "unreadable shape", path: home, want: "load CLI config"}, - {name: "invalid origin", path: invalidPath, want: "must not include a path"}, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - _, err := loadCLIConfig(testCase.path, true, home, loadedCLIConfig{}) - assert.ErrorContains(t, err, testCase.want) - }) - } -} - -func TestNormalizeAPIOriginRejectsNonOrigins(t *testing.T) { - t.Parallel() - values := []string{ - "", - "ftp://example.com", - "https://user@example.com", - "https://example.com?query=yes", - "https://example.com#fragment", - } - for _, value := range values { - _, err := normalizeAPIOrigin(value) - assert.Assert(t, err != nil, value) - } -} diff --git a/internal/cli/doc.go b/internal/cli/doc.go deleted file mode 100644 index 78f5aa7..0000000 --- a/internal/cli/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package cli implements the Listenbox command-line client. -package cli diff --git a/internal/cli/episode_delete.go b/internal/cli/episode_delete.go deleted file mode 100644 index b1af4f6..0000000 --- a/internal/cli/episode_delete.go +++ /dev/null @@ -1,149 +0,0 @@ -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "net/http" - "strings" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -func runEpisodesDeleteCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox episodes delete", flag.ContinueOnError) - flags.SetOutput(stderr) - episodeID := "" - yes := false - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&episodeID, episodeFlagName, "", "episode ID (required)") - flags.BoolVar(&yes, yesFlagName, false, "confirm permanent deletion (required)") - flags.Usage = func() { writeEpisodesDeleteUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse episodes delete flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("episodes delete received unexpected argument %q", flags.Arg(0)) - } - if !validCompletionID(episodeID, "ep_") { - return fmt.Errorf("episodes delete: invalid --episode %q", episodeID) - } - if !flagWasSet(flags, yesFlagName) || !yes { - return errors.New("episodes delete: --yes is required") - } - return deleteEpisode(ctx, stdout, stderr, home, configPath, configExplicit, defaultConfig, episodeID) -} - -//nolint:cyclop,funlen,gocognit,gocyclo // The CLI validates each HTTP and SSE variant before reporting deletion. -func deleteEpisode( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - episodeID string, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("delete episode: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - response, err := client.CreateEpisodeDeletion(ctx, publicapi.CreateEpisodeDeletionParams{EpisodeId: episodeID}) - if err != nil { - return fmt.Errorf("delete episode %q: %w", episodeID, err) - } - if response == nil || response.Status202 == nil { - if response != nil { - switch response.StatusCode { - case http.StatusUnauthorized: - return fmt.Errorf("delete episode %q: authentication failed; run listenbox login", episodeID) - case http.StatusForbidden: - return fmt.Errorf("delete episode %q: only the current team owner may delete it", episodeID) - case http.StatusNotFound: - return fmt.Errorf("delete episode %q: episode not found", episodeID) - } - return fmt.Errorf("delete episode %q returned HTTP status %d", episodeID, response.StatusCode) - } - return fmt.Errorf("delete episode %q returned no response", episodeID) - } - eventsResponse, err := client.EpisodeDeletionEvents(ctx, publicapi.EpisodeDeletionEventsParams{ - EpisodeDeletionRunId: response.Status202.EpisodeDeletionRunId, - }) - if err != nil { - return fmt.Errorf("read episode deletion event stream for %q: %w", episodeID, err) - } - if eventsResponse == nil || eventsResponse.Status200 == nil { - status := 0 - if eventsResponse != nil { - status = eventsResponse.StatusCode - } - return fmt.Errorf("episode deletion event stream for %q returned HTTP status %d", episodeID, status) - } - stream := eventsResponse.Status200 - defer func() { _ = stream.Close() }() - progress := newImportProgress(stderr, writerIsTerminal(stderr)) - defer progress.cleanFailureLine() - for { - event, ok, err := stream.Next(ctx) - if err != nil { - return fmt.Errorf("read episode deletion stream for %q: %w", episodeID, err) - } - if !ok { - return fmt.Errorf("episode deletion stream for %q ended before a terminal event", episodeID) - } - if current := event.EpisodeDeletionProgressEvent; current != nil { - if current.Type != showDeletionProgressEventType || current.Percent < 0 || - current.Percent > maxShowDeletionPercent || strings.TrimSpace(current.CurrentStep) == "" { - return fmt.Errorf("episode deletion stream for %q emitted malformed progress event", episodeID) - } - if err := progress.update(uint32(current.Percent)); err != nil { - return err - } - continue - } - terminal := event.EpisodeDeletionTerminalEvent - if terminal == nil { - return fmt.Errorf("episode deletion stream for %q emitted an empty event variant", episodeID) - } - if completed := terminal.EpisodeDeletionCompletedEvent; completed != nil { - if completed.Type != showDeletionTerminalEventType || completed.Status != showDeletionCompletedStatus || - completed.EpisodeId != episodeID { - return fmt.Errorf("episode deletion stream for %q emitted malformed completed event", episodeID) - } - if err := progress.complete(); err != nil { - return err - } - _, err := fmt.Fprintf(stdout, "Deleted episode %q\n", episodeID) - return err - } - failed := terminal.EpisodeDeletionFailedEvent - if failed == nil || strings.TrimSpace(failed.ErrorCode) == "" || strings.TrimSpace(failed.ErrorMessage) == "" { - return fmt.Errorf("episode deletion stream for %q emitted malformed failed event", episodeID) - } - return fmt.Errorf("delete episode %q failed (%s): %s", episodeID, failed.ErrorCode, failed.ErrorMessage) - } -} diff --git a/internal/cli/episodes.go b/internal/cli/episodes.go deleted file mode 100644 index 6267c08..0000000 --- a/internal/cli/episodes.go +++ /dev/null @@ -1,906 +0,0 @@ -package cli - -import ( - "bufio" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "slices" - "strings" - "time" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - episodeResumeVersion = 4 - episodeResumeDirectoryMode os.FileMode = 0o700 - episodeResumeFileMode os.FileMode = 0o600 - episodeProgressComplete = int64(100) - episodeErrorResponseLimit = 64 << 10 - episodeJSONResponseLimit = 1 << 20 - episodeAudioMIME = "audio/mpeg" - episodeShowSlugJSONField = "show_slug" - episodeMultipartMaxParts = 10000 - episodeListMaxPageLimit = 500 - episodeFinalizingPhase = "finalizing" -) - -type episodeUploadInterruptedError struct{} - -func (*episodeUploadInterruptedError) Error() string { - return "episode source upload interrupted; resumable state saved" -} - -type episodeSourceIdentity struct { - AbsolutePath string - ByteLength int64 - ContentType string - SHA256 string -} - -type episodeResumePart struct { - PartNumber int32 `json:"part_number"` - ETag string `json:"etag"` - Size int64 `json:"size"` -} - -type episodeResumeRecord struct { - Version int `json:"version"` - EpisodeID string `json:"episode_id,omitempty"` - UploadSessionID string `json:"upload_session_id,omitempty"` - TeamID string `json:"team_id,omitempty"` - ShowID string `json:"show_id,omitempty"` - ManagementURL string `json:"management_url,omitempty"` - FilePath string `json:"file_path"` - FileSHA256 string `json:"file_sha256"` - FileByteLength int64 `json:"file_byte_length"` - CompletedParts []episodeResumePart `json:"completed_parts"` - ExpiresAt int64 `json:"expires_at"` - Phase string `json:"phase"` -} - -type episodeUploadSession struct { - TeamID string `json:"team_id"` - ShowID string `json:"show_id"` - EpisodeID string `json:"episode_id,omitempty"` - UploadSessionID string `json:"upload_session_id"` - ObjectKey string `json:"object_key"` - ContentType string `json:"content_type"` - ByteLength int64 `json:"byte_length"` - PartSize int64 `json:"part_size"` - MaxParallelism int32 `json:"max_parallelism"` - ExpiresAt int64 `json:"expires_at"` - Phase string `json:"phase"` - CompletedParts []episodeResumePart `json:"completed_parts"` -} - -type completedEpisode struct { - ID string `json:"id"` - ShowID string `json:"show_id"` -} - -type episodeUploadPartPresign struct { - PartNumber int32 `json:"part_number"` - Method string `json:"method"` - UploadURL string `json:"upload_url"` - ExpiresAt int64 `json:"expires_at"` -} - -type episodeUploadPartPresigns struct { - UploadSessionID string `json:"upload_session_id"` - Parts []episodeUploadPartPresign `json:"parts"` -} - -type episodeCreateEvent struct { - Type string `json:"type"` - Status string `json:"status"` - CurrentStep string `json:"current_step"` - Percent int64 `json:"percent"` - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - EpisodeID string `json:"episode_id"` - RunID string `json:"run_id"` - YouTubeVideoID string `json:"youtube_video_id"` -} - -type episodeUploadClient struct { - apiOrigin string - credential string - httpClient *http.Client - traceWriter io.Writer -} - -func runEpisodesCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - if len(args) == 0 { - writeEpisodesUsage(stderr) - return errors.New("episodes command is required") - } - switch args[0] { - case helpCommandName, shortHelpFlag, longHelpFlag: - if len(args) == 1 { - writeEpisodesUsage(stdout) - return nil - } - if len(args) == nestedCommandArgs { - switch args[1] { - case createCommandName: - writeEpisodesCreateUsage(stdout) - return nil - case listCommandName: - writeEpisodesListUsage(stdout) - return nil - case deleteCommandName: - writeEpisodesDeleteUsage(stdout) - return nil - } - } - return fmt.Errorf("episodes help received unknown command %q", args[1]) - case createCommandName: - return runEpisodesCreateCommand( - ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig, - ) - case listCommandName: - return runEpisodesListCommand( - ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig, - ) - case deleteCommandName: - return runEpisodesDeleteCommand( - ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig, - ) - default: - writeEpisodesUsage(stderr) - return fmt.Errorf("unknown episodes command %q", args[0]) - } -} - -func runEpisodesListCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox episodes list", flag.ContinueOnError) - flags.SetOutput(stderr) - show := "" - limit := 0 - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&show, showFlagName, "", "existing show slug (required)") - flags.IntVar(&limit, limitFlagName, 0, "episodes requested per page (1-500)") - flags.Usage = func() { writeEpisodesListUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse episodes list flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("episodes list received unexpected argument %q", flags.Arg(0)) - } - show = strings.TrimSpace(show) - if show == "" { - return errors.New("episodes list: --show is required") - } - if !showSlugPattern.MatchString(show) { - return errors.New("episodes list: --show must be a valid show slug") - } - if flagWasSet(flags, limitFlagName) && (limit < 1 || limit > episodeListMaxPageLimit) { - return errors.New("episodes list: --limit must be from 1 through 500") - } - return listEpisodes(ctx, stdout, home, configPath, configExplicit, defaultConfig, show, limit) -} - -func listEpisodes( - ctx context.Context, - stdout io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - show string, - limit int, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("list episodes: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - - params := publicapi.ListEpisodesParams{ShowSlug: show} - if limit != 0 { - //nolint:gosec // Flag validation bounds the value to 1 through 500 before conversion. - pageLimit := publicapi.EpisodePageLimit(limit) - params.Limit = &pageLimit - } - seenCursors := map[string]struct{}{} - for { - response, listErr := client.ListEpisodes(ctx, params) - if listErr != nil { - return fmt.Errorf("list episodes for show %q: %w", show, listErr) - } - next, err := streamEpisodeListPage(stdout, response, show, config.apiOrigin) - if err != nil { - return err - } - if next == nil { - return nil - } - if *next == "" { - return fmt.Errorf("list episodes for show %q returned an empty next cursor", show) - } - if _, repeated := seenCursors[*next]; repeated { - return fmt.Errorf("list episodes for show %q returned repeated cursor %q", show, *next) - } - seenCursors[*next] = struct{}{} - params.Cursor = next - } -} - -func streamEpisodeListPage( - stdout io.Writer, - response *publicapi.ListEpisodesResponse, - show string, - apiOrigin string, -) (*string, error) { - if response == nil { - return nil, fmt.Errorf("list episodes for show %q against %q returned no response", show, apiOrigin) - } - if response.Status200 == nil { - return nil, fmt.Errorf("list episodes for show %q returned HTTP status %d", show, response.StatusCode) - } - for _, episode := range response.Status200.Episodes { - if _, err := fmt.Fprintln(stdout, episode.Id); err != nil { - return nil, fmt.Errorf("print episode ID %q: %w", episode.Id, err) - } - } - return response.Status200.NextCursor, nil -} - -func runEpisodesCreateCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox episodes create", flag.ContinueOnError) - flags.SetOutput(stderr) - arguments := episodesCreateArguments{Publication: publicapi.EpisodePublicationDraft} - description := "" - publication := string(arguments.Publication) - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&arguments.Show, showFlagName, "", "existing show slug (required)") - flags.StringVar(&arguments.Title, titleFlagName, "", "episode title (required)") - flags.StringVar(&description, descriptionFlagName, "", "optional episode description") - flags.StringVar(&arguments.File, fileFlagName, "", "audio or video source path (required)") - flags.StringVar(&publication, publicationFlagName, publication, "draft or publish after processing") - flags.Usage = func() { writeEpisodesCreateUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse episodes create flags: %w", err) - } - if flagWasSet(flags, descriptionFlagName) { - arguments.Description = &description - } - arguments.Publication = publicapi.EpisodePublication(publication) - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("episodes create received unexpected argument %q", flags.Arg(0)) - } - if err := validateEpisodesCreateArguments(&arguments); err != nil { - return err - } - return createEpisode(ctx, stdout, stderr, home, configPath, configExplicit, defaultConfig, arguments) -} - -//nolint:cyclop,funlen // Coordinates explicit durable boundaries and output failures. -func createEpisode( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - args episodesCreateArguments, -) error { - source, err := inspectEpisodeSource(args.File) - if err != nil { - return err - } - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("create episode: not logged in; run listenbox login") - } - resumePath, record, err := loadOrCreateEpisodeResume(home, args, source) - if err != nil { - return err - } - transport, ok := http.DefaultTransport.(*http.Transport) - if !ok { - return errors.New("create episode: default HTTP transport is unavailable") - } - client := &episodeUploadClient{ - apiOrigin: config.apiOrigin, - credential: auth.APIKey, - httpClient: &http.Client{Transport: transport.Clone()}, - } - if config.printTraceIDs { - client.traceWriter = stderr - } - wasFinalizing := record.Phase == episodeFinalizingPhase - var session episodeUploadSession - if !wasFinalizing { - session, err = client.createOrResume(ctx, args, source) - if err != nil { - return err - } - record.EpisodeID = session.EpisodeID - record.UploadSessionID = session.UploadSessionID - record.TeamID = session.TeamID - record.ShowID = session.ShowID - record.CompletedParts = session.CompletedParts - record.ExpiresAt = session.ExpiresAt - record.Phase = session.Phase - } - if err := writeEpisodeResume(resumePath, record); err != nil { - return err - } - if record.Phase != episodeFinalizingPhase { - if err := uploadMissingEpisodeParts(ctx, stderr, source, session, record, resumePath, client); err != nil { - return err - } - record.Phase = episodeFinalizingPhase - if err := writeEpisodeResume(resumePath, record); err != nil { - return err - } - } - episode, err := client.complete(ctx, record.UploadSessionID, args.Publication) - if err != nil { - return err - } - record.EpisodeID = episode.ID - record.ShowID = episode.ShowID - record.Phase = processingStatus - managementURL, err := episodeManagementURL( - config.dashboardOrigin, record.TeamID, record.ShowID, record.EpisodeID, - ) - if err != nil { - return err - } - record.ManagementURL = managementURL - if err := writeEpisodeResume(resumePath, record); err != nil { - return err - } - event, err := client.waitForSafeHandoff(ctx, stderr, record.UploadSessionID) - if err != nil { - if errors.Is(ctx.Err(), context.Canceled) { - return nil - } - return err - } - record.Phase = importCompletedStatus - if err := writeEpisodeResume(resumePath, record); err != nil { - return err - } - result := "Created draft episode" - if args.Publication == publicapi.EpisodePublicationPublish { - result = "Published episode" - } - if _, err := fmt.Fprintf( - stdout, "%s %q\nOpen in Listenbox: %s\n", result, args.Title, record.ManagementURL, - ); err != nil { - return fmt.Errorf("print created episode: %w", err) - } - if event.YouTubeVideoID != "" { - if _, err := fmt.Fprintf( - stdout, - "YouTube (processing): https://www.youtube.com/watch?v=%s\n", - event.YouTubeVideoID, - ); err != nil { - return fmt.Errorf("print YouTube processing URL: %w", err) - } - } - return nil -} - -func inspectEpisodeSource(path string) (episodeSourceIdentity, error) { - absolutePath, err := filepath.Abs(path) - if err != nil { - return episodeSourceIdentity{}, fmt.Errorf("resolve --file %q: %w", path, err) - } - file, err := os.Open(absolutePath) - if err != nil { - return episodeSourceIdentity{}, fmt.Errorf("open --file %q: %w", path, err) - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return episodeSourceIdentity{}, fmt.Errorf("inspect --file %q: %w", path, err) - } - if !info.Mode().IsRegular() || info.Size() <= 0 { - return episodeSourceIdentity{}, fmt.Errorf("--file %q must be a non-empty regular file", path) - } - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return episodeSourceIdentity{}, fmt.Errorf("hash --file %q: %w", path, err) - } - contentType, err := episodeContentTypeForFileName(absolutePath) - if err != nil { - return episodeSourceIdentity{}, fmt.Errorf("inspect --file %q: %w", path, err) - } - return episodeSourceIdentity{ - AbsolutePath: absolutePath, ByteLength: info.Size(), ContentType: contentType, - SHA256: hex.EncodeToString(hash.Sum(nil)), - }, nil -} - -func episodeContentTypeForFileName(fileName string) (string, error) { - switch strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))) { - case ".mp3": - return string(publicapi.EpisodeUploadContentTypeAudioMpeg), nil - case ".m4a": - return string(publicapi.EpisodeUploadContentTypeAudioMp4), nil - case ".wav": - return string(publicapi.EpisodeUploadContentTypeAudioWav), nil - case ".flac": - return string(publicapi.EpisodeUploadContentTypeAudioFlac), nil - case ".mp4", ".m4v": - return string(publicapi.EpisodeUploadContentTypeVideoMp4), nil - case ".mov": - return string(publicapi.EpisodeUploadContentTypeVideoQuicktime), nil - default: - return "", fmt.Errorf( - "unsupported media upload file %q; accepted extensions: .mp3, .m4a, .wav, .flac, .mp4, .m4v, .mov", - fileName, - ) - } -} - -//nolint:cyclop // Validates every persisted resume invariant before mutation. -func loadOrCreateEpisodeResume( - home string, - args episodesCreateArguments, - source episodeSourceIdentity, -) (string, episodeResumeRecord, error) { - resumeIdentity := sha256.Sum256([]byte(args.Show + "\x00" + source.SHA256)) - directory := filepath.Join(home, ".config", "listenbox", "episode-resume") - path := filepath.Join(directory, hex.EncodeToString(resumeIdentity[:])+".json") - raw, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return "", episodeResumeRecord{}, fmt.Errorf("read episode resume record %q: %w", path, err) - } - if os.IsNotExist(err) { - record := episodeResumeRecord{ - Version: episodeResumeVersion, - FilePath: source.AbsolutePath, FileSHA256: source.SHA256, FileByteLength: source.ByteLength, - CompletedParts: []episodeResumePart{}, Phase: "prepared", - } - if err := writeEpisodeResume(path, record); err != nil { - return "", record, err - } - return path, record, nil - } - var record episodeResumeRecord - if decodeErr := json.Unmarshal(raw, &record); decodeErr != nil { - return "", record, fmt.Errorf("decode episode resume record %q: %w", path, decodeErr) - } - if record.Version != episodeResumeVersion { - return "", record, fmt.Errorf( - "episode resume record %q has an unsupported version; remove it to restart explicitly", - path, - ) - } - if record.FileSHA256 != source.SHA256 || record.FileByteLength != source.ByteLength { - return "", record, fmt.Errorf( - "--file identity changed since resumable upload %s; remove %q to restart explicitly", - record.UploadSessionID, - path, - ) - } - if record.ExpiresAt > 0 && record.ExpiresAt <= time.Now().UnixMilli() && - record.Phase != "finalizing" && record.Phase != processingStatus && record.Phase != "completed" { - return "", record, fmt.Errorf( - "resumable upload %s expired; remove %q to restart explicitly", - record.UploadSessionID, - path, - ) - } - record.FilePath = source.AbsolutePath - return path, record, nil -} - -func writeEpisodeResume(path string, record episodeResumeRecord) error { - directory := filepath.Dir(path) - if err := os.MkdirAll(directory, episodeResumeDirectoryMode); err != nil { - return fmt.Errorf("create episode resume directory %q: %w", directory, err) - } - if err := os.Chmod(directory, episodeResumeDirectoryMode); err != nil { - return fmt.Errorf("secure episode resume directory %q: %w", directory, err) - } - temporary, err := os.CreateTemp(directory, ".episode-resume-*") - if err != nil { - return fmt.Errorf("create temporary episode resume record: %w", err) - } - temporaryPath := temporary.Name() - defer func() { _ = os.Remove(temporaryPath) }() - if err := temporary.Chmod(episodeResumeFileMode); err != nil { - _ = temporary.Close() - return err - } - encoder := json.NewEncoder(temporary) - encoder.SetIndent("", " ") - if err := encoder.Encode(record); err != nil { - _ = temporary.Close() - return fmt.Errorf("encode episode resume record: %w", err) - } - if err := temporary.Sync(); err != nil { - _ = temporary.Close() - return fmt.Errorf("sync episode resume record: %w", err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("close episode resume record: %w", err) - } - if err := os.Rename(temporaryPath, path); err != nil { - return fmt.Errorf("replace episode resume record %q: %w", path, err) - } - return syncAuthDirectory(directory) -} - -//nolint:cyclop // Each accepted part has its own durable save boundary. -func uploadMissingEpisodeParts( - ctx context.Context, - stderr io.Writer, - source episodeSourceIdentity, - session episodeUploadSession, - record episodeResumeRecord, - resumePath string, - client *episodeUploadClient, -) error { - accepted := make(map[int32]episodeResumePart, len(session.CompletedParts)) - for _, part := range session.CompletedParts { - accepted[part.PartNumber] = part - } - partCount64 := (source.ByteLength + session.PartSize - 1) / session.PartSize - if partCount64 > episodeMultipartMaxParts { - return errors.New("episode source requires too many multipart parts") - } - partCount := int32(partCount64) // #nosec G115 -- bounded by the multipart maximum above. - missing := make([]int32, 0, partCount) - for partNumber := int32(1); partNumber <= partCount; partNumber++ { - if _, ok := accepted[partNumber]; !ok { - missing = append(missing, partNumber) - } - } - if err := printSavedEpisodeProgress(stderr, accepted, source.ByteLength); err != nil { - return err - } - if len(missing) == 0 { - return nil - } - presigns, err := client.presign(ctx, session.UploadSessionID, missing) - if err != nil { - return err - } - file, err := os.Open(source.AbsolutePath) - if err != nil { - return fmt.Errorf("reopen --file %q: %w", source.AbsolutePath, err) - } - defer file.Close() - for _, presign := range presigns.Parts { - start := int64(presign.PartNumber-1) * session.PartSize - length := min(session.PartSize, source.ByteLength-start) - etag, err := client.uploadPart(ctx, presign, io.NewSectionReader(file, start, length), length) - if err != nil { - if errors.Is(ctx.Err(), context.Canceled) { - return &episodeUploadInterruptedError{} - } - return err - } - accepted[presign.PartNumber] = episodeResumePart{ - PartNumber: presign.PartNumber, ETag: etag, Size: length, - } - record.CompletedParts = sortedEpisodeResumeParts(accepted) - record.Phase = "uploading" - if err := writeEpisodeResume(resumePath, record); err != nil { - return err - } - if err := printSavedEpisodeProgress(stderr, accepted, source.ByteLength); err != nil { - return err - } - } - return nil -} - -func sortedEpisodeResumeParts(parts map[int32]episodeResumePart) []episodeResumePart { - result := make([]episodeResumePart, 0, len(parts)) - for _, part := range parts { - result = append(result, part) - } - slices.SortFunc(result, func(a, b episodeResumePart) int { return int(a.PartNumber - b.PartNumber) }) - return result -} - -func printSavedEpisodeProgress( - writer io.Writer, - parts map[int32]episodeResumePart, - total int64, -) error { - var saved int64 - for _, part := range parts { - saved += part.Size - } - percent := min(episodeProgressComplete, saved*episodeProgressComplete/total) - if _, err := fmt.Fprintf(writer, "Source upload: %d%% saved\n", percent); err != nil { - return fmt.Errorf("print source upload progress: %w", err) - } - return nil -} - -func (client *episodeUploadClient) createOrResume( - ctx context.Context, - args episodesCreateArguments, - source episodeSourceIdentity, -) (episodeUploadSession, error) { - body := map[string]any{ - episodeShowSlugJSONField: args.Show, "title": args.Title, - "file_name": filepath.Base(source.AbsolutePath), "content_type": source.ContentType, - "byte_length": source.ByteLength, "source_sha256": source.SHA256, - } - if args.Description != nil { - body[descriptionFlagName] = *args.Description - } - var session episodeUploadSession - err := client.doJSON( - ctx, http.MethodPost, "/s/episode-upload-sessions", body, &session, http.StatusOK, http.StatusCreated, - ) - return session, err -} - -func (client *episodeUploadClient) presign( - ctx context.Context, - uploadSessionID string, - partNumbers []int32, -) (episodeUploadPartPresigns, error) { - var result episodeUploadPartPresigns - err := client.doJSON( - ctx, http.MethodPost, - "/s/episode-upload-sessions/"+uploadSessionID+"/parts/presign", - map[string]any{"part_numbers": partNumbers}, &result, http.StatusCreated, - ) - return result, err -} - -func (client *episodeUploadClient) complete( - ctx context.Context, - uploadSessionID string, - publication publicapi.EpisodePublication, -) (completedEpisode, error) { - var result completedEpisode - err := client.doJSON( - ctx, http.MethodPost, "/s/episode-upload-sessions/"+uploadSessionID+"/complete", - map[string]any{publicationFlagName: publication}, &result, http.StatusOK, http.StatusCreated, - ) - return result, err -} - -func (client *episodeUploadClient) uploadPart( - ctx context.Context, - presign episodeUploadPartPresign, - body io.Reader, - length int64, -) (string, error) { - request, err := http.NewRequestWithContext(ctx, presign.Method, presign.UploadURL, body) - if err != nil { - return "", fmt.Errorf("create upload request for part %d: %w", presign.PartNumber, err) - } - request.ContentLength = length - response, err := client.httpClient.Do(request) - if err != nil { - return "", fmt.Errorf("upload source part %d: %w", presign.PartNumber, err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - message, _ := io.ReadAll(io.LimitReader(response.Body, episodeErrorResponseLimit)) - return "", fmt.Errorf( - "upload source part %d returned HTTP %d: %s", - presign.PartNumber, - response.StatusCode, - strings.TrimSpace(string(message)), - ) - } - etag := response.Header.Get("ETag") - if etag == "" { - return "", fmt.Errorf("upload source part %d returned no ETag", presign.PartNumber) - } - return etag, nil -} - -func (client *episodeUploadClient) doJSON( - ctx context.Context, - method string, - path string, - body any, - target any, - wantStatus ...int, -) error { - var requestBody io.Reader - if body != nil { - encoded, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("encode episode upload request: %w", err) - } - requestBody = bytes.NewReader(encoded) - } - request, err := http.NewRequestWithContext(ctx, method, client.apiOrigin+path, requestBody) - if err != nil { - return fmt.Errorf("create episode upload request: %w", err) - } - request.Header.Set("Authorization", "Bearer "+client.credential) - request.Header.Set("Accept", "application/json") - if body != nil { - request.Header.Set("Content-Type", "application/json") - } - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("send episode upload request: %w", err) - } - defer response.Body.Close() - if err := client.printResponseTrace(response); err != nil { - return err - } - if !slices.Contains(wantStatus, response.StatusCode) { - message, _ := io.ReadAll(io.LimitReader(response.Body, episodeErrorResponseLimit)) - return fmt.Errorf("episode upload returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(message))) - } - decoder := json.NewDecoder(io.LimitReader(response.Body, episodeJSONResponseLimit)) - if err := decoder.Decode(target); err != nil { - return fmt.Errorf("decode episode upload response: %w", err) - } - return nil -} - -//nolint:cyclop,funlen // SSE parsing keeps each malformed and terminal condition explicit. -func (client *episodeUploadClient) waitForSafeHandoff( - ctx context.Context, - stderr io.Writer, - uploadSessionID string, -) (episodeCreateEvent, error) { - request, err := http.NewRequestWithContext( - ctx, http.MethodGet, client.apiOrigin+"/s/episode-upload-sessions/"+uploadSessionID+"/events", nil, - ) - if err != nil { - return episodeCreateEvent{}, err - } - request.Header.Set("Authorization", "Bearer "+client.credential) - request.Header.Set("Accept", "text/event-stream") - response, err := client.httpClient.Do(request) - if err != nil { - return episodeCreateEvent{}, fmt.Errorf("attach to episode processing: %w", err) - } - defer response.Body.Close() - if err := client.printResponseTrace(response); err != nil { - return episodeCreateEvent{}, err - } - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, episodeErrorResponseLimit)) - return episodeCreateEvent{}, fmt.Errorf( - "episode processing stream returned HTTP %d: %s", - response.StatusCode, - strings.TrimSpace(string(message)), - ) - } - scanner := bufio.NewScanner(response.Body) - var data string - for scanner.Scan() { - line := scanner.Text() - if value, ok := strings.CutPrefix(line, "data:"); ok { - data = strings.TrimSpace(value) - continue - } - if line != "" || data == "" { - continue - } - var event episodeCreateEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - return event, fmt.Errorf("decode episode processing event: %w", err) - } - data = "" - switch event.Type { - case "episode.processing.progress": - if _, err := fmt.Fprintf(stderr, "%s: %d%%\n", episodeProgressLabel(event.CurrentStep), event.Percent); err != nil { - return event, err - } - case "episode.processing.safe_handoff": - return event, nil - case "episode.processing.failed": - return event, fmt.Errorf("episode processing failed (%s): %s", event.ErrorCode, event.ErrorMessage) - default: - return event, fmt.Errorf("episode processing emitted unknown event type %q", event.Type) - } - } - if err := scanner.Err(); err != nil { - return episodeCreateEvent{}, fmt.Errorf("read episode processing stream: %w", err) - } - return episodeCreateEvent{}, errors.New("episode processing stream ended before safe handoff") -} - -func episodeProgressLabel(step string) string { - switch step { - case "queued", "processing_audio", "listenbox_processed": - return "Listenbox processing" - case "video_preparation", "video_prepared": - return "Video preparation" - case "youtube_upload": - return "YouTube upload" - default: - return "Episode processing" - } -} - -func episodeManagementURL(origin string, teamID string, showID string, episodeID string) (string, error) { - if !validCompletionID(teamID, "team_") || !validCompletionID(showID, "shw_") || - !validCompletionID(episodeID, "ep_") { - return "", errors.New("episode create returned invalid management identifiers") - } - result, err := url.JoinPath(origin, teamID, "shows", showID, "episodes", episodeID, "edit") - if err != nil { - return "", fmt.Errorf("build episode management URL: %w", err) - } - return result, nil -} - -func (client *episodeUploadClient) printResponseTrace(response *http.Response) error { - if client.traceWriter == nil { - return nil - } - traceID := response.Header.Get("X-Trace-Id") - decoded, err := hex.DecodeString(traceID) - if err != nil || len(decoded) != 16 || traceID != strings.ToLower(traceID) || strings.Trim(traceID, "0") == "" { - return fmt.Errorf("print episode request trace ID: invalid X-Trace-Id %q", traceID) - } - if _, err := fmt.Fprintf(client.traceWriter, "Trace ID: %s\n", traceID); err != nil { - return fmt.Errorf("print episode request trace ID %q: %w", traceID, err) - } - return nil -} diff --git a/internal/cli/episodes_list_test.go b/internal/cli/episodes_list_test.go deleted file mode 100644 index 5ba262e..0000000 --- a/internal/cli/episodes_list_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "gotest.tools/v3/assert" -) - -func TestEpisodesListAutoDrainsPagesWithLimit(t *testing.T) { - t.Parallel() - - requestNumber := 0 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requestNumber++ - assert.Equal(t, request.Method, http.MethodGet) - assert.Equal(t, request.URL.Path, "/s/shows/cli-show/episodes") - assert.Equal(t, request.Header.Get("Authorization"), "Bearer "+testCLIAPIKey) - assert.Equal(t, request.URL.Query().Get("limit"), "2") - writer.Header().Set("Content-Type", "application/json") - switch requestNumber { - case 1: - assert.Equal(t, request.URL.Query().Get("cursor"), "") - writeEpisodeListTestPage(t, writer, []string{"newest", "middle"}, "cursor-1") - case 2: - assert.Equal(t, request.URL.Query().Get("cursor"), "cursor-1") - writeEpisodeListTestPage(t, writer, []string{"oldest"}, "") - default: - http.Error(writer, "unexpected request", http.StatusInternalServerError) - } - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{ - configFlagArgument, configPath, episodesCommandName, listCommandName, - showFlagArgument, testCLIShowSlug, "--limit", "2", - }, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal(t, requestNumber, 2) - assert.Equal(t, stdout.String(), "ep_newest\nep_middle\nep_oldest\n") -} - -func TestEpisodesListOmitsOptionalLimitAndHandlesEmptyShow(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - _, limitPresent := request.URL.Query()["limit"] - assert.Assert(t, !limitPresent, "limit omitted") - writer.Header().Set("Content-Type", "application/json") - writeEpisodeListTestPage(t, writer, []string{}, "") - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, episodesCommandName, listCommandName, showFlagArgument, testCLIShowSlug}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal(t, stdout.String(), "") -} - -func TestEpisodesListRejectsRepeatedCursorAfterStreamingPages(t *testing.T) { - t.Parallel() - - requestNumber := 0 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requestNumber++ - writer.Header().Set("Content-Type", "application/json") - writeEpisodeListTestPage(t, writer, []string{fmt.Sprintf("episode-%d", requestNumber)}, "repeat") - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, episodesCommandName, listCommandName, showFlagArgument, testCLIShowSlug}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "returned repeated cursor") - assert.Equal(t, stdout.String(), "ep_episode-1\nep_episode-2\n") -} - -func TestEpisodesListPreservesStreamedOutputWhenLaterPageFails(t *testing.T) { - t.Parallel() - - requestNumber := 0 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requestNumber++ - if requestNumber == 1 { - writer.Header().Set("Content-Type", "application/json") - writeEpisodeListTestPage(t, writer, []string{"already-streamed"}, "next") - return - } - http.Error(writer, "later failure", http.StatusInternalServerError) - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, episodesCommandName, listCommandName, showFlagArgument, testCLIShowSlug}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "later failure") - assert.Equal(t, stdout.String(), "ep_already-streamed\n") -} - -func writeEpisodeListTestPage(t *testing.T, writer http.ResponseWriter, slugs []string, nextCursor string) { - t.Helper() - episodes := make([]map[string]any, 0, len(slugs)) - for _, slug := range slugs { - episodes = append(episodes, map[string]any{ - "id": "ep_" + slug, testShowIDField: "shw_test", "title": slug, - "slug": slug, statusCommandName: "draft", "enclosures": []any{}, - }) - } - page := map[string]any{"episodes": episodes} - if nextCursor != "" { - page["next_cursor"] = nextCursor - } - assert.NilError(t, json.NewEncoder(writer).Encode(page)) -} diff --git a/internal/cli/episodes_resume_test.go b/internal/cli/episodes_resume_test.go deleted file mode 100644 index 1d4119a..0000000 --- a/internal/cli/episodes_resume_test.go +++ /dev/null @@ -1,98 +0,0 @@ -//nolint:lll // Resume fixtures keep the complete strong file identity visible. -package cli - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - "time" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" - "gotest.tools/v3/assert" -) - -func TestEpisodeResumeLookupIsScopedByShowAndFileSHA(t *testing.T) { - t.Parallel() - home := t.TempDir() - path := filepath.Join(t.TempDir(), "source.mp3") - args := episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, File: path, - Publication: publicapi.EpisodePublicationDraft, - } - source := episodeSourceIdentity{AbsolutePath: path, ByteLength: 10, ContentType: episodeAudioMIME, SHA256: strings.Repeat("a", 64)} - resumePath, first, err := loadOrCreateEpisodeResume(home, args, source) - assert.NilError(t, err) - secondPath := filepath.Join(t.TempDir(), "renamed.mp3") - resumed := source - resumed.AbsolutePath = secondPath - changedArgs := args - changedArgs.Title = "Changed local command metadata" - changedArgs.Publication = publicapi.EpisodePublicationPublish - resolvedPath, second, err := loadOrCreateEpisodeResume(home, changedArgs, resumed) - assert.NilError(t, err) - assert.Equal(t, resolvedPath, resumePath) - assert.Equal(t, second.FileSHA256, first.FileSHA256) - assert.Equal(t, second.FilePath, secondPath) - _, statErr := os.Stat(resumePath) - assert.NilError(t, statErr) - changedHash := source - changedHash.SHA256 = strings.Repeat("b", 64) - newPath, _, err := loadOrCreateEpisodeResume(home, args, changedHash) - assert.NilError(t, err) - assert.Assert(t, newPath != resumePath) -} - -func TestEpisodeResumeRejectsExpiredAttemptAndPreservesDraftRecord(t *testing.T) { - t.Parallel() - home := t.TempDir() - path := filepath.Join(t.TempDir(), "source.mp3") - args := episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, File: path, - Publication: publicapi.EpisodePublicationDraft, - } - source := episodeSourceIdentity{AbsolutePath: path, ByteLength: 10, ContentType: episodeAudioMIME, SHA256: strings.Repeat("a", 64)} - resumePath, record, err := loadOrCreateEpisodeResume(home, args, source) - assert.NilError(t, err) - record.ExpiresAt = time.Now().Add(-time.Minute).UnixMilli() - record.Phase = "uploading" - assert.NilError(t, writeEpisodeResume(resumePath, record)) - _, preserved, err := loadOrCreateEpisodeResume(home, args, source) - assert.ErrorContains(t, err, "expired") - assert.Equal(t, preserved.UploadSessionID, record.UploadSessionID) -} - -func TestSavedEpisodeProgressCountsAcceptedPartsOnly(t *testing.T) { - t.Parallel() - parts := map[int32]episodeResumePart{ - 1: {PartNumber: 1, Size: 5}, - } - var output bytes.Buffer - assert.NilError(t, printSavedEpisodeProgress(&output, parts, 10)) - assert.Equal(t, output.String(), "Source upload: 50% saved\n") - parts[2] = episodeResumePart{PartNumber: 2, Size: 5} - assert.NilError(t, printSavedEpisodeProgress(&output, parts, 10)) - assert.Equal(t, output.String(), "Source upload: 50% saved\nSource upload: 100% saved\n") -} - -func TestEpisodeResumeRecordContainsUploadFactsOnly(t *testing.T) { - t.Parallel() - home := t.TempDir() - path := filepath.Join(t.TempDir(), "source.mp3") - args := episodesCreateArguments{ - Show: testShowsCreateSlug, Title: testEpisodeTitle, File: path, - Publication: publicapi.EpisodePublicationDraft, - } - source := episodeSourceIdentity{AbsolutePath: path, ByteLength: 10, ContentType: episodeAudioMIME, SHA256: strings.Repeat("a", 64)} - resumePath, _, err := loadOrCreateEpisodeResume(home, args, source) - assert.NilError(t, err) - raw, err := os.ReadFile(resumePath) - assert.NilError(t, err) - assert.Assert(t, !bytes.Contains(raw, []byte("upload_url"))) - assert.Assert(t, !bytes.Contains(raw, []byte("api_key"))) - assert.Assert(t, !bytes.Contains(raw, []byte("publication"))) - info, err := os.Stat(resumePath) - assert.NilError(t, err) - assert.Equal(t, info.Mode().Perm(), os.FileMode(0o600)) -} diff --git a/internal/cli/episodes_source_test.go b/internal/cli/episodes_source_test.go deleted file mode 100644 index 8b210a2..0000000 --- a/internal/cli/episodes_source_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "testing" - - "gotest.tools/v3/assert" -) - -func TestInspectEpisodeSourceLeavesMediaValidationToServer(t *testing.T) { - t.Parallel() - - sourceBytes := []byte("opaque upload bytes that only the server may probe") - sourcePath := filepath.Join(t.TempDir(), "episode.mp4") - assert.NilError(t, os.WriteFile(sourcePath, sourceBytes, 0o600)) - - source, err := inspectEpisodeSource(sourcePath) - assert.NilError(t, err) - assert.Equal(t, source.ContentType, "video/mp4") - assert.Assert(t, source.ByteLength > 0) - assert.Assert(t, source.ByteLength < 1<<10) - expectedHash := sha256.Sum256(sourceBytes) - assert.Equal(t, source.SHA256, hex.EncodeToString(expectedHash[:])) -} diff --git a/internal/cli/execute.go b/internal/cli/execute.go deleted file mode 100644 index 15fda0f..0000000 --- a/internal/cli/execute.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/signal" - "syscall" - - "github.com/knadh/koanf/parsers/yaml" - "github.com/knadh/koanf/providers/rawbytes" - "github.com/knadh/koanf/v2" -) - -const interruptedExitCode = 130 - -// Execute runs the CLI and terminates the process with its exit status. -func Execute() { - os.Exit(runMain()) -} - -func runMain() int { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - if err := runCLI(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { - if _, ok := errors.AsType[*episodeUploadInterruptedError](err); ok { - writeCLIError(os.Stderr, err) - return interruptedExitCode - } - writeCLIError(os.Stderr, err) - return 1 - } - return 0 -} - -func writeCLIError(stderr io.Writer, err error) { - _, _ = fmt.Fprintln(stderr, err) -} - -func loadEmbeddedCLIConfig() (loadedCLIConfig, error) { - k := koanf.New(".") - if err := k.Load(rawbytes.Provider(embeddedCLIConfig), yaml.Parser()); err != nil { - return loadedCLIConfig{}, fmt.Errorf("load embedded CLI config: %w", err) - } - return decodeCLIConfig(k, "embedded CLI config") -} diff --git a/internal/cli/import.go b/internal/cli/import.go deleted file mode 100644 index 411cbe8..0000000 --- a/internal/cli/import.go +++ /dev/null @@ -1,403 +0,0 @@ -package cli - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" - "github.com/mattn/go-isatty" -) - -const ( - importProgressBarWidth = 24 - importProgressEventType = "progress" - importTerminalEventType = "terminal" - importCompletedStatus = "completed" - importCancelledStatus = "cancelled" - importFailedStatus = "failed" - maxImportPercent = 100 -) - -func importPodcast( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - sourceURL string, - slug string, - slugExplicit bool, -) error { - if isYouTubeSource(sourceURL) { - return importYouTube( - ctx, stdout, stderr, home, configPath, configExplicit, defaultConfig, sourceURL, slug, slugExplicit, - ) - } - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("import RSS feed: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - body := importRSSRequest(sourceURL, slug, slugExplicit) - response, err := client.ImportRSS(ctx, publicapi.ImportRSSParams{Body: body}) - if err != nil { - return fmt.Errorf("import RSS feed %q: %w", sourceURL, err) - } - if response == nil { - return fmt.Errorf("import RSS feed %q against %q returned no response", sourceURL, config.apiOrigin) - } - if response.Status202 != nil { - if config.printTraceIDs { - if err := printImportTraceID(stdout, response.Raw.Header.Get("X-Trace-Id")); err != nil { - return err - } - } - return consumeImportRSSRun(ctx, stdout, stderr, sourceURL, config, client, response) - } - return handleImportRSSResponse( - sourceURL, - slug, - slugExplicit, - response, - ) -} - -func importRSSRequest(sourceURL string, slug string, slugExplicit bool) publicapi.ImportRSSRequest { - body := publicapi.ImportRSSRequest{SourceUrl: sourceURL} - if slugExplicit { - body.Slug = &slug - } - return body -} - -func consumeImportRSSRun( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - sourceURL string, - config loadedCLIConfig, - client *publicapi.Client, - response *publicapi.ImportRSSResponse, -) error { - eventsResponse, err := client.ImportRSSRunEvents(ctx, publicapi.ImportRSSRunEventsParams{ - ImportRunId: response.Status202.ImportRunId, - }) - if err != nil { - return fmt.Errorf("read RSS import event stream for %q: %w", sourceURL, err) - } - if eventsResponse == nil || eventsResponse.Status200 == nil { - status := 0 - if eventsResponse != nil { - status = eventsResponse.StatusCode - } - return fmt.Errorf("RSS import event stream for %q returned HTTP status %d", sourceURL, status) - } - return consumeImportRSSStream( - ctx, - stdout, - stderr, - sourceURL, - config.dashboardOrigin, - eventsResponse.Status200, - ) -} - -func handleImportRSSResponse( - sourceURL string, - requestedSlug string, - slugExplicit bool, - response *publicapi.ImportRSSResponse, -) error { - switch { - case response.Status202 != nil: - return fmt.Errorf("import RSS feed %q returned a run without an event stream", sourceURL) - case response.Status400 != nil: - return fmt.Errorf("import RSS feed %q: %s", sourceURL, response.Status400.Message) - case response.Status402 != nil: - return fmt.Errorf( - "import RSS feed %q: %s import requires the %s entitlement (current: %s); upgrade at %s and retry", - sourceURL, - response.Status402.MediaKind, - response.Status402.RequiredEntitlement, - response.Status402.CurrentEntitlement, - response.Status402.PricingUrl, - ) - case response.StatusCode == http.StatusUnauthorized: - return fmt.Errorf("import RSS feed %q: authentication failed; run listenbox login", sourceURL) - case response.StatusCode == http.StatusForbidden: - return fmt.Errorf("import RSS feed %q: API key lacks show:create scope", sourceURL) - case response.StatusCode == http.StatusConflict && slugExplicit: - return fmt.Errorf("import RSS feed %q: requested slug %q conflicts", sourceURL, requestedSlug) - case response.StatusCode == http.StatusConflict: - return fmt.Errorf("import RSS feed %q: import choice conflicts", sourceURL) - default: - return fmt.Errorf("import RSS feed %q returned HTTP status %d", sourceURL, response.StatusCode) - } -} - -func printImportTraceID(stdout io.Writer, traceID string) error { - decoded, err := hex.DecodeString(traceID) - if err != nil || len(decoded) != 16 || traceID != strings.ToLower(traceID) || strings.Trim(traceID, "0") == "" { - return fmt.Errorf("print RSS import trace ID: invalid trace_id %q", traceID) - } - if _, err := fmt.Fprintf(stdout, "Trace ID: %s\n", traceID); err != nil { - return fmt.Errorf("print RSS import trace ID %q: %w", traceID, err) - } - return nil -} - -func consumeImportRSSStream( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - sourceURL string, - dashboardOrigin string, - stream *publicapi.SSEStream[publicapi.PublicRSSImportEvent], -) error { - defer func() { _ = stream.Close() }() - progress := newImportProgress(stderr, writerIsTerminal(stderr)) - defer progress.cleanFailureLine() - for { - event, ok, err := stream.Next(ctx) - if err != nil { - return fmt.Errorf("read RSS import stream for %q: %w", sourceURL, err) - } - if !ok { - return fmt.Errorf("RSS import stream for %q ended before a terminal event", sourceURL) - } - done, err := handleImportRSSEvent(stdout, stderr, sourceURL, dashboardOrigin, progress, event) - if err != nil { - return err - } - if done { - return nil - } - } -} - -func handleImportRSSEvent( - stdout io.Writer, - stderr io.Writer, - sourceURL string, - dashboardOrigin string, - progress *importProgress, - event publicapi.PublicRSSImportEvent, -) (bool, error) { - if event.RSSImportProgressEvent != nil { - current := event.RSSImportProgressEvent - if current.Type != importProgressEventType || current.Percent < 0 || current.Percent > maxImportPercent { - return false, fmt.Errorf("RSS import stream for %q emitted malformed progress event", sourceURL) - } - return false, progress.update(uint32(current.Percent)) - } - if event.PublicRSSImportTerminalEvent == nil { - return false, fmt.Errorf("RSS import stream for %q emitted an empty event variant", sourceURL) - } - return handleImportRSSTerminal( - stdout, - stderr, - sourceURL, - dashboardOrigin, - progress, - event.PublicRSSImportTerminalEvent, - ) -} - -func handleImportRSSTerminal( - stdout io.Writer, - stderr io.Writer, - sourceURL string, - dashboardOrigin string, - progress *importProgress, - terminal *publicapi.PublicRSSImportTerminalEvent, -) (bool, error) { - if terminal.PublicRSSImportCompletedTerminalEvent != nil { - return finishCompletedRSSImport( - stdout, - stderr, - sourceURL, - dashboardOrigin, - progress, - terminal.PublicRSSImportCompletedTerminalEvent, - ) - } - if terminal.RSSImportCancelledTerminalEvent != nil { - cancelled := terminal.RSSImportCancelledTerminalEvent - if cancelled.Type != importTerminalEventType || cancelled.Status != importCancelledStatus { - return false, fmt.Errorf("RSS import stream for %q emitted malformed cancelled terminal event", sourceURL) - } - return false, fmt.Errorf("RSS import feed %q was cancelled", sourceURL) - } - if terminal.RSSImportFailedTerminalEvent == nil { - return false, fmt.Errorf("RSS import stream for %q emitted an empty terminal event variant", sourceURL) - } - return false, failedRSSImportError(sourceURL, terminal.RSSImportFailedTerminalEvent) -} - -func finishCompletedRSSImport( - stdout io.Writer, - stderr io.Writer, - sourceURL string, - dashboardOrigin string, - progress *importProgress, - completed *publicapi.PublicRSSImportCompletedTerminalEvent, -) (bool, error) { - if completed.Type != importTerminalEventType || completed.Status != importCompletedStatus || - !validCompletionID(completed.FeedId, "lb_") || !validShowSlug(completed.ShowSlug) { - return false, fmt.Errorf("RSS import stream for %q emitted malformed completed terminal event", sourceURL) - } - showURL, err := showManagementURL(dashboardOrigin, completed.TeamId, completed.ShowId) - if err != nil { - return false, fmt.Errorf( - "RSS import stream for %q emitted malformed completed terminal event: %w", - sourceURL, - err, - ) - } - if err := progress.complete(); err != nil { - return false, err - } - if _, err := fmt.Fprintln(stdout, completed.ShowSlug); err != nil { - return false, fmt.Errorf("print imported show slug %q: %w", completed.ShowSlug, err) - } - if _, err := fmt.Fprintf(stderr, "Open in Listenbox: %s\n", showURL); err != nil { - return false, fmt.Errorf("print imported show URL %q: %w", showURL, err) - } - return true, nil -} - -func showManagementURL(dashboardOrigin string, teamID string, showID string) (string, error) { - normalizedOrigin, err := normalizeOrigin(dashboardOrigin, "Dashboard") - if err != nil { - return "", err - } - if !validCompletionID(teamID, "team_") { - return "", fmt.Errorf("invalid team_id %q", teamID) - } - if !validCompletionID(showID, "shw_") { - return "", fmt.Errorf("invalid show_id %q", showID) - } - showURL, err := url.JoinPath(normalizedOrigin, teamID, "shows", showID) - if err != nil { - return "", fmt.Errorf( - "build show URL from Dashboard origin %q, team ID %q, and show ID %q: %w", - normalizedOrigin, - teamID, - showID, - err, - ) - } - return showURL, nil -} - -func validCompletionID(value string, prefix string) bool { - return strings.HasPrefix(value, prefix) && len(value) > len(prefix) && - strings.TrimSpace(value) == value && url.PathEscape(value) == value -} - -func validShowSlug(value string) bool { - if len(value) == 0 || len(value) > 63 { - return false - } - previousHyphen := true - for _, current := range value { - switch { - case current >= 'a' && current <= 'z', current >= '0' && current <= '9': - previousHyphen = false - case current == '-' && !previousHyphen: - previousHyphen = true - default: - return false - } - } - return !previousHyphen -} - -func failedRSSImportError(sourceURL string, failed *publicapi.RSSImportFailedTerminalEvent) error { - if failed.Type != importTerminalEventType || failed.Status != importFailedStatus || - strings.TrimSpace(failed.ErrorCode) == "" || strings.TrimSpace(failed.ErrorMessage) == "" { - return fmt.Errorf("RSS import stream for %q emitted malformed failed terminal event", sourceURL) - } - return fmt.Errorf( - "RSS import feed %q failed (%s): %s", - sourceURL, - failed.ErrorCode, - failed.ErrorMessage, - ) -} - -type importProgress struct { - writer io.Writer - tty bool - hasValue bool - last uint32 - lineOpen bool -} - -func newImportProgress(writer io.Writer, tty bool) *importProgress { - return &importProgress{writer: writer, tty: tty} -} - -func (progress *importProgress) update(percent uint32) error { - if progress.hasValue && progress.last == percent { - return nil - } - progress.hasValue = true - progress.last = percent - if !progress.tty { - if _, err := fmt.Fprintf(progress.writer, "Import progress: %d%%\n", percent); err != nil { - return fmt.Errorf("print RSS import progress %d%%: %w", percent, err) - } - return nil - } - filled := int(percent) * importProgressBarWidth / maxImportPercent - bar := strings.Repeat("#", filled) + strings.Repeat("-", importProgressBarWidth-filled) - if _, err := fmt.Fprintf(progress.writer, "\rImporting [%s] %3d%%", bar, percent); err != nil { - return fmt.Errorf("print RSS import progress %d%%: %w", percent, err) - } - progress.lineOpen = true - return nil -} - -func (progress *importProgress) complete() error { - if err := progress.update(maxImportPercent); err != nil { - return err - } - if progress.tty && progress.lineOpen { - if _, err := fmt.Fprintln(progress.writer); err != nil { - return fmt.Errorf("finish RSS import progress line: %w", err) - } - progress.lineOpen = false - } - return nil -} - -func (progress *importProgress) cleanFailureLine() { - if progress.tty && progress.lineOpen { - _, _ = fmt.Fprintln(progress.writer) - progress.lineOpen = false - } -} - -func writerIsTerminal(writer io.Writer) bool { - descriptor, ok := writer.(interface{ Fd() uintptr }) - if !ok { - return false - } - return isatty.IsTerminal(descriptor.Fd()) || isatty.IsCygwinTerminal(descriptor.Fd()) -} diff --git a/internal/cli/import_test.go b/internal/cli/import_test.go deleted file mode 100644 index 8c116dc..0000000 --- a/internal/cli/import_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" - "gotest.tools/v3/assert" -) - -const ( - //nolint:gosec // Synthetic credential used only by local test servers. - testCLIAPIKey = "apk_cli.AAAAAAAAAAAAAAAAAAAAAA" - testRSSURL = "https://feeds.test/rss" - testMalformedImportCompletion = "malformed completed terminal event" -) - -//nolint:funlen // Covers all supported help entry points and positional errors together. -func TestImportHelpAndArgumentParsing(t *testing.T) { - t.Parallel() - - t.Run("root lists import", func(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{helpCommandName}, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(stdout.String(), "import Import an RSS feed")) - }) - - for _, args := range [][]string{ - {helpCommandName, importCommandName}, - {importCommandName, longHelpFlag}, - {importCommandName, shortHelpFlag}, - } { - t.Run(strings.Join(args, " "), func(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - var stderr bytes.Buffer - err := runCLIWithHome(context.Background(), args, &stdout, &stderr, t.TempDir()) - assert.NilError(t, err) - output := stdout.String() + stderr.String() - assert.Assert(t, strings.Contains(output, - "Usage: listenbox [--config PATH] import [--config PATH] [--slug SLUG] ")) - assert.Assert(t, strings.Contains(output, "--slug SLUG")) - assert.Assert(t, strings.Contains(output, "--config PATH")) - assert.Assert(t, strings.Contains( - output, - "Progress and the management link are written to stderr", - )) - assert.Assert(t, strings.Contains(output, "final show slug is written to stdout")) - }) - } - - for _, testCase := range []struct { - name string - args []string - want string - }{ - {name: "missing URL", args: []string{importCommandName}, want: "requires exactly one source URL"}, - { - name: "extra URL", - args: []string{importCommandName, "https://one.test/rss", "https://two.test/rss"}, - want: `unexpected argument "https://two.test/rss"`, - }, - } { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - testCase.args, - &stdout, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, testCase.want) - assert.Equal(t, stdout.String(), "") - }) - } -} - -//nolint:funlen // Covers exact request and split stdout/stderr contracts for optional slug cases. -func TestImportRequestAuthOptionalSlugAndExactOutput(t *testing.T) { - t.Parallel() - - for _, testCase := range []struct { - name string - args []string - wantSlug string - wantSlugInBody bool - }{ - { - name: "omitted slug", - args: []string{"https://feeds.test/podcast.xml"}, - }, - { - name: "explicit slug", - args: []string{slugFlagArgument, "requested-show", "https://feeds.test/podcast.xml"}, - wantSlug: "requested-show", - wantSlugInBody: true, - }, - } { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - requests := make(chan importTestRequest, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - var body map[string]any - decodeErr := json.NewDecoder(request.Body).Decode(&body) - requests <- importTestRequest{ - authorization: request.Header.Get("Authorization"), - body: body, - decodeErr: decodeErr, - } - writeTestImportCreation(writer) - return - } - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, map[string]any{testEventTypeField: importProgressEventType, "percent": 10}) - writeTestSSEEvent(writer, map[string]any{testEventTypeField: importProgressEventType, "percent": 10}) - writeTestSSEEvent(writer, map[string]any{ - testEventTypeField: importTerminalEventType, - statusCommandName: importCompletedStatus, - "feed_id": "lb_server_feed", - testTeamIDField: "team_0123456789abcdef", - testShowIDField: "shw_0123456789abcdef", - "show_slug": "actual-server-show", - }) - })) - defer server.Close() - - stdout, stderr, err := runImportAgainstServer( - context.Background(), - t, - server.URL, - testCase.args, - "https://dashboard.test:9443", - ) - assert.NilError(t, err) - assert.Equal(t, stdout, "actual-server-show\n") - assert.Equal( - t, - stderr, - "Import progress: 10%\nImport progress: 100%\n"+ - "Open in Listenbox: https://dashboard.test:9443/team_0123456789abcdef/"+ - "shows/shw_0123456789abcdef\n", - ) - request := <-requests - assert.NilError(t, request.decodeErr) - assert.Equal(t, request.authorization, "Bearer "+testCLIAPIKey) - assert.Equal(t, request.body["source_url"], "https://feeds.test/podcast.xml") - slug, hasSlug := request.body["slug"] - assert.Equal(t, hasSlug, testCase.wantSlugInBody) - if hasSlug { - assert.Equal(t, slug, testCase.wantSlug) - } - }) - } -} - -func TestImportAcceptsCommandLocalConfigFlag(t *testing.T) { - t.Parallel() - - server := importCompletedTestServer("local-config-show") - defer server.Close() - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{importCommandName, "--config", configPath, testRSSURL}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal(t, stdout.String(), "local-config-show\n") -} - -func TestImportHTTPStatusMappingsLeaveStdoutEmpty(t *testing.T) { - t.Parallel() - - for _, testCase := range []struct { - name string - status int - body string - want string - }{ - { - name: "bad request", - status: http.StatusBadRequest, - body: `{"message":"invalid RSS URL","errors":[]}`, - want: "invalid RSS URL", - }, - { - name: "entitlement required", - status: http.StatusPaymentRequired, - body: `{"code":"rss_import_entitlement_required","media_kind":"video",` + - `"current_entitlement":"audio","required_entitlement":"video_hd",` + - `"pricing_url":"https://listenbox.app/pricing"}`, - want: "video import requires the video_hd entitlement (current: audio); upgrade at " + - "https://listenbox.app/pricing and retry", - }, - {name: "unauthorized", status: http.StatusUnauthorized, want: "authentication failed"}, - {name: "forbidden", status: http.StatusForbidden, want: "lacks show:create scope"}, - {name: "conflict", status: http.StatusConflict, want: "requested slug \"taken\" conflicts"}, - {name: "unexpected", status: http.StatusTeapot, body: "unexpected", want: "HTTP status 418"}, - } { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - if testCase.body != "" { - writer.Header().Set("Content-Type", "application/json") - } - writer.WriteHeader(testCase.status) - _, _ = io.WriteString(writer, testCase.body) - })) - defer server.Close() - stdout, _, err := runImportAgainstServer( - context.Background(), - t, - server.URL, - []string{"--slug", "taken", testRSSURL}, - ) - assert.ErrorContains(t, err, testCase.want) - assert.Equal(t, stdout, "") - }) - } -} - -//nolint:funlen // Table enumerates malformed and failed stream terminal contracts. -func TestImportStreamFailuresLeaveStdoutEmpty(t *testing.T) { - t.Parallel() - - for _, testCase := range []struct { - name string - events []string - want string - }{ - { - name: "failed terminal", - events: []string{ - `{"type":"terminal","status":"failed","error_code":"feed_fetch_failed","error_message":"boom"}`, - }, - want: "failed (feed_fetch_failed): boom", - }, - { - name: "premature EOF", - events: []string{`{"type":"progress","percent":30}`}, - want: "ended before a terminal event", - }, - { - name: "empty variant", - events: []string{`{}`}, - want: "unsupported PublicRSSImportEvent discriminator", - }, - { - name: "missing feed id", - events: []string{ - `{"type":"terminal","status":"completed","team_id":"team_valid",` + - `"show_id":"shw_valid","show_slug":"show"}`, - }, - want: testMalformedImportCompletion, - }, - { - name: "missing show slug", - events: []string{ - `{"type":"terminal","status":"completed","feed_id":"lb_feed",` + - `"team_id":"team_valid","show_id":"shw_valid"}`, - }, - want: testMalformedImportCompletion, - }, - { - name: "missing team id", - events: []string{ - `{"type":"terminal","status":"completed","feed_id":"lb_feed",` + - `"show_id":"shw_valid","show_slug":"show"}`, - }, - want: "invalid team_id", - }, - { - name: "missing show id", - events: []string{ - `{"type":"terminal","status":"completed","feed_id":"lb_feed",` + - `"team_id":"team_valid","show_slug":"show"}`, - }, - want: "invalid show_id", - }, - { - name: "unsafe team id", - events: []string{ - `{"type":"terminal","status":"completed","feed_id":"lb_feed",` + - `"team_id":"team_bad/segment","show_id":"shw_valid","show_slug":"show"}`, - }, - want: "invalid team_id", - }, - { - name: "unsafe show slug", - events: []string{ - `{"type":"terminal","status":"completed","feed_id":"lb_feed",` + - `"team_id":"team_valid","show_id":"shw_valid","show_slug":"show\nextra"}`, - }, - want: testMalformedImportCompletion, - }, - } { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - var streamServed atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - writeTestImportCreation(writer) - return - } - if streamServed.Swap(true) { - writer.WriteHeader(http.StatusNoContent) - return - } - writer.Header().Set("Content-Type", "text/event-stream") - for _, event := range testCase.events { - _, _ = io.WriteString(writer, "data: "+event+"\n\n") - } - })) - defer server.Close() - stdout, _, err := runImportAgainstServer( - context.Background(), - t, - server.URL, - []string{testRSSURL}, - ) - assert.ErrorContains(t, err, testCase.want) - assert.Equal(t, stdout, "") - }) - } -} - -func TestImportStreamCancellationFails(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - stream := publicapi.NewSSEStream[publicapi.PublicRSSImportEvent]( - io.NopCloser(strings.NewReader("")), - ) - var stdout bytes.Buffer - err := consumeImportRSSStream( - ctx, - &stdout, - &bytes.Buffer{}, - testRSSURL, - "https://web.listenbox.app", - stream, - ) - assert.ErrorContains(t, err, "context canceled") - assert.Equal(t, stdout.String(), "") -} - -func TestImportProgressTTYAndNonTTY(t *testing.T) { - t.Parallel() - - var plain bytes.Buffer - plainProgress := newImportProgress(&plain, false) - assert.NilError(t, plainProgress.update(25)) - assert.NilError(t, plainProgress.update(25)) - assert.NilError(t, plainProgress.complete()) - assert.Equal(t, plain.String(), "Import progress: 25%\nImport progress: 100%\n") - - var tty bytes.Buffer - ttyProgress := newImportProgress(&tty, true) - assert.NilError(t, ttyProgress.update(25)) - assert.NilError(t, ttyProgress.update(25)) - assert.NilError(t, ttyProgress.complete()) - assert.Equal(t, strings.Count(tty.String(), " 25%"), 1) - assert.Equal(t, strings.Count(tty.String(), "100%"), 1) - assert.Assert(t, strings.Contains(tty.String(), "\rImporting [")) - assert.Assert(t, strings.HasSuffix(tty.String(), "\n")) -} - -type importTestRequest struct { - authorization string - body map[string]any - decodeErr error -} - -func runImportAgainstServer( - ctx context.Context, - t *testing.T, - apiOrigin string, - importArgs []string, - dashboardOrigins ...string, -) (string, string, error) { - t.Helper() - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, apiOrigin, dashboardOrigins...) - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - args := make([]string, 0, 3+len(importArgs)) - args = append(args, configFlagArgument, configPath, importCommandName) - args = append(args, importArgs...) - var stdout bytes.Buffer - var stderr bytes.Buffer - err := runCLIWithHome(ctx, args, &stdout, &stderr, home) - return stdout.String(), stderr.String(), err -} - -func importCompletedTestServer(showSlug string) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - writeTestImportCreation(writer) - return - } - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, map[string]any{ - testEventTypeField: importTerminalEventType, - "status": "completed", - "feed_id": "lb_feed", - testTeamIDField: "team_0123456789abcdef", - "show_id": "shw_0123456789abcdef", - "show_slug": showSlug, - }) - })) -} - -func writeTestImportCreation(writer http.ResponseWriter) { - writer.Header().Set("Content-Type", "application/json") - writer.Header().Set("X-Trace-Id", "0123456789abcdef0123456789abcdef") - writer.WriteHeader(http.StatusAccepted) - if err := json.NewEncoder(writer).Encode(map[string]string{ - "import_run_id": "imp_0123456789abcdef0123456789abcdef", - }); err != nil { - panic(err) - } -} diff --git a/internal/cli/login.go b/internal/cli/login.go deleted file mode 100644 index 5e6388b..0000000 --- a/internal/cli/login.go +++ /dev/null @@ -1,310 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "slices" - - "github.com/listenbox/listenbox-cli/publicapi" -) - -func login( - ctx context.Context, - stdout io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - authPath := defaultAuthPath(home) - previous, hasPrevious := readUsableStoredAuth(authPath) - - client, response, err := beginCLIAuthorization(ctx, config, previous, hasPrevious) - if err != nil { - return err - } - created := response.Status201 - credential, err := acceptPendingAuthorization( - stdout, - "", - &publicapi.CLIAuthorizationPendingEvent{ - Type: "cli.authorization.pending", - VerificationUrl: created.VerificationUrl, - Credential: created.Credential, - }, - config.apiOrigin, - config.dashboardOrigin, - ) - if err != nil { - return err - } - stream, err := openCLIAuthorizationStream(ctx, client, created.Code) - if err != nil { - return err - } - defer func() { _ = stream.Close() }() - return consumeAuthorizationStreamWithCredential( - ctx, - stdout, - stream, - credential, - config.apiOrigin, - config.dashboardOrigin, - authPath, - ) -} - -func beginCLIAuthorization( - ctx context.Context, - config loadedCLIConfig, - previous storedAuth, - hasPrevious bool, -) (*publicapi.Client, *publicapi.CreateCLIAuthorizationResponse, error) { - client, err := newPublicClient(config.apiOrigin, previous.APIKey, hasPrevious) - if err != nil { - return nil, nil, err - } - params := publicapi.CreateCLIAuthorizationParams{ - Body: publicapi.CreateCLIAuthorization{Scopes: requestedCLIScopes()}, - } - response, err := client.CreateCLIAuthorization(ctx, params) - if hasPrevious && (isUnauthorized(err) || responseUnauthorized(response)) { - client, err = newPublicClient(config.apiOrigin, "", false) - if err != nil { - return nil, nil, err - } - response, err = client.CreateCLIAuthorization(ctx, params) - } - if err != nil { - return nil, nil, fmt.Errorf("start CLI authorization: %w", err) - } - if response == nil { - return nil, nil, fmt.Errorf("start CLI authorization against %q returned no response", config.apiOrigin) - } - if response.Status201 == nil { - return nil, nil, fmt.Errorf("start CLI authorization returned HTTP status %d", response.StatusCode) - } - return client, response, nil -} - -func openCLIAuthorizationStream( - ctx context.Context, - client *publicapi.Client, - code publicapi.CLIAuthorizationCode, -) (*publicapi.SSEStream[publicapi.CLIAuthorizationEvent], error) { - response, err := client.CliAuthorizationEvents(ctx, publicapi.CliAuthorizationEventsParams{Code: code}) - if err != nil { - return nil, fmt.Errorf("start CLI authorization event stream: %w", err) - } - if response == nil || response.Status200 == nil { - status := 0 - if response != nil { - status = response.StatusCode - } - return nil, fmt.Errorf("CLI authorization event stream returned HTTP status %d", status) - } - return response.Status200, nil -} - -func consumeAuthorizationStreamWithCredential( - ctx context.Context, - stdout io.Writer, - stream *publicapi.SSEStream[publicapi.CLIAuthorizationEvent], - credential string, - apiOrigin string, - dashboardOrigin string, - authPath string, -) error { - for { - event, ok, nextErr := stream.Next(ctx) - if nextErr != nil { - return fmt.Errorf("read CLI authorization stream: %w", nextErr) - } - if !ok { - return fmt.Errorf("CLI authorization stream from %q ended before a terminal event", apiOrigin) - } - if event.CLIAuthorizationPendingEvent == nil { - return finishAuthorization(ctx, event, credential, apiOrigin, authPath) - } - var err error - credential, err = acceptPendingAuthorization( - stdout, - credential, - event.CLIAuthorizationPendingEvent, - apiOrigin, - dashboardOrigin, - ) - if err != nil { - return err - } - } -} - -func acceptPendingAuthorization( - stdout io.Writer, - currentCredential string, - pending *publicapi.CLIAuthorizationPendingEvent, - apiOrigin string, - dashboardOrigin string, -) (string, error) { - if currentCredential != "" { - return "", fmt.Errorf("CLI authorization stream from %q emitted more than one pending event", apiOrigin) - } - if !validHeaderCredential(pending.Credential) { - return "", fmt.Errorf("CLI authorization stream from %q emitted an invalid pending credential", apiOrigin) - } - verificationURL, err := configuredVerificationURL(pending.VerificationUrl, dashboardOrigin) - if err != nil { - return "", err - } - if _, err := fmt.Fprintf(stdout, "Verification URL: %s\n", verificationURL); err != nil { - return "", fmt.Errorf("print verification URL %q: %w", verificationURL, err) - } - return pending.Credential, nil -} - -func finishAuthorization( - ctx context.Context, - event publicapi.CLIAuthorizationEvent, - credential string, - apiOrigin string, - authPath string, -) error { - switch { - case event.CLIAuthorizationApprovedEvent != nil: - if credential == "" { - return fmt.Errorf("CLI authorization stream from %q approved before its pending event", apiOrigin) - } - approved := event.CLIAuthorizationApprovedEvent - if err := verifyApprovedCredential(ctx, apiOrigin, credential, approved.TeamId); err != nil { - return err - } - return writeStoredAuth(authPath, storedAuth{APIKey: credential}) - case event.CLIAuthorizationDeniedEvent != nil: - if credential == "" { - return fmt.Errorf("CLI authorization stream from %q denied before its pending event", apiOrigin) - } - return fmt.Errorf("CLI authorization was denied by %q", apiOrigin) - case event.CLIAuthorizationExpiredEvent != nil: - if credential == "" { - return fmt.Errorf("CLI authorization stream from %q expired before its pending event", apiOrigin) - } - return fmt.Errorf("CLI authorization expired against %q", apiOrigin) - default: - return fmt.Errorf("CLI authorization stream from %q emitted an empty event variant", apiOrigin) - } -} - -func newPublicClient(apiOrigin string, credential string, authenticated bool) (*publicapi.Client, error) { - options := make([]publicapi.Option, 0, 1) - if authenticated { - options = append(options, publicapi.WithRequestEditorFn(bearerTokenRequestEditor(credential))) - } - client, err := publicapi.NewClient(publicapi.ClientOptions{BaseURL: apiOrigin}, options...) - if err != nil { - return nil, fmt.Errorf("create public API client for %q: %w", apiOrigin, err) - } - return client, nil -} - -func bearerTokenRequestEditor(token string) publicapi.RequestEditorFn { - return func(_ context.Context, request *http.Request) error { - request.Header.Set("Authorization", "Bearer "+token) - return nil - } -} - -func isUnauthorized(err error) bool { - var statusErr *publicapi.UnexpectedStatusError - return errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized -} - -func responseUnauthorized(response *publicapi.CreateCLIAuthorizationResponse) bool { - return response != nil && response.StatusCode == http.StatusUnauthorized -} - -func validateVerificationURL(value string) error { - parsed, err := url.Parse(value) - if err != nil { - return fmt.Errorf("parse verification URL %q: %w", value, err) - } - if (parsed.Scheme != httpScheme && parsed.Scheme != httpsScheme) || parsed.Host == "" { - return fmt.Errorf("verification URL %q must be an absolute HTTP(S) URL", value) - } - if parsed.User != nil || parsed.Fragment != "" { - return fmt.Errorf("verification URL %q contains forbidden URL components", value) - } - return nil -} - -func configuredVerificationURL(value string, dashboardOrigin string) (string, error) { - if err := validateVerificationURL(value); err != nil { - return "", err - } - verificationURL, err := url.Parse(value) - if err != nil { - return "", fmt.Errorf("parse verification URL %q after validation: %w", value, err) - } - dashboardURL, err := url.Parse(dashboardOrigin) - if err != nil { - return "", fmt.Errorf("parse configured Dashboard origin %q: %w", dashboardOrigin, err) - } - verificationURL.Scheme = dashboardURL.Scheme - verificationURL.Host = dashboardURL.Host - return verificationURL.String(), nil -} - -func verifyApprovedCredential( - ctx context.Context, - apiOrigin string, - credential string, - approvedTeamID publicapi.TeamID, -) error { - client, err := newPublicClient(apiOrigin, credential, true) - if err != nil { - return err - } - response, err := client.Whoami(ctx) - if err != nil { - return fmt.Errorf("verify approved credential: %w", err) - } - if response.Status200 == nil { - return fmt.Errorf("verify approved credential returned HTTP status %d", response.StatusCode) - } - whoami := response.Status200 - if whoami.TeamId == "" || whoami.TeamId != approvedTeamID { - return fmt.Errorf( - "verify approved credential team %q does not match approved team %q", - whoami.TeamId, - approvedTeamID, - ) - } - for _, scope := range requestedCLIScopes() { - if !slices.Contains(whoami.Scopes, scope) { - return fmt.Errorf("verify approved credential is missing scope %q", scope) - } - } - return nil -} - -func requestedCLIScopes() []publicapi.ApiKeyScope { - return []publicapi.ApiKeyScope{ - publicapi.ApiKeyScopeTeamRead, - publicapi.ApiKeyScopeTeamManage, - publicapi.ApiKeyScopeShowRead, - publicapi.ApiKeyScopeShowCreate, - publicapi.ApiKeyScopeShowUpdate, - publicapi.ApiKeyScopeEpisodeCreate, - publicapi.ApiKeyScopeEpisodeDelete, - publicapi.ApiKeyScopeEpisodeUpdate, - publicapi.ApiKeyScopeEpisodePublish, - } -} diff --git a/internal/cli/login_test.go b/internal/cli/login_test.go deleted file mode 100644 index 92a73aa..0000000 --- a/internal/cli/login_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "gotest.tools/v3/assert" -) - -const ( - configFlagArgument = "--" + configFlagName - testEventTypeField = "type" - testTeamIDField = "team_id" -) - -//nolint:funlen // This test intentionally verifies the complete login transaction. -func TestLoginUsesConfiguredDashboardOriginAndAtomicallyReplacesAuth(t *testing.T) { - t.Parallel() - - const ( - previousCredential = "apk_previous.AAAAAAAAAAAAAAAAAAAAAA" - pendingCredential = "apk_pending.BBBBBBBBBBBBBBBBBBBBBB" - teamID = "team_cli_test" - ) - requestedAuthorization := make(chan string, 1) - requestedScopes := make(chan []string, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch { - case request.Method == http.MethodPost && request.URL.Path == "/cli/authorizations": - requestedAuthorization <- request.Header.Get("Authorization") - var body struct { - Scopes []string `json:"scopes"` - } - if err := json.NewDecoder(request.Body).Decode(&body); err != nil { - http.Error(writer, "invalid request", http.StatusBadRequest) - return - } - requestedScopes <- body.Scopes - writeTestAuthorizationCreation(writer, request, "cla_test", pendingCredential) - case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/cli/authorizations/"): - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, approvedTestEvent(teamID)) - case request.URL.Path == "/s/whoami": - if request.Header.Get("Authorization") != "Bearer "+pendingCredential { - http.Error(writer, "wrong credential", http.StatusUnauthorized) - return - } - writer.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(writer).Encode(map[string]any{ - "id": "apk_pending", - testTeamIDField: teamID, - "scopes": requestedScopeStrings(), - }) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - dashboardServer := httptest.NewServer(http.NotFoundHandler()) - defer dashboardServer.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL, dashboardServer.URL) - authPath := defaultAuthPath(home) - assert.NilError(t, writeStoredAuth(authPath, storedAuth{APIKey: previousCredential})) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, loginCommandName}, - &stdout, - &stderr, - home, - ) - assert.NilError(t, err) - assert.Equal(t, stderr.String(), "") - assert.Equal( - t, - stdout.String(), - "Verification URL: "+dashboardServer.URL+"/cli/authorize?code=cla_test\n", - ) - assert.Equal(t, <-requestedAuthorization, "Bearer "+previousCredential) - assert.DeepEqual(t, <-requestedScopes, requestedScopeStrings()) - - raw, err := os.ReadFile(authPath) - assert.NilError(t, err) - var auth storedAuth - assert.NilError(t, json.Unmarshal(raw, &auth)) - assert.DeepEqual(t, auth, storedAuth{APIKey: pendingCredential}) - var fields map[string]any - assert.NilError(t, json.Unmarshal(raw, &fields)) - _, hasAPIOrigin := fields["api_origin"] - assert.Assert(t, !hasAPIOrigin) - authInfo, err := os.Stat(authPath) - assert.NilError(t, err) - assert.Equal(t, authInfo.Mode().Perm(), os.FileMode(0o600)) - directoryInfo, err := os.Stat(filepath.Dir(authPath)) - assert.NilError(t, err) - assert.Equal(t, directoryInfo.Mode().Perm(), os.FileMode(0o700)) -} - -func TestLoginDenialPreservesPreviousAuth(t *testing.T) { - t.Parallel() - - const previousCredential = "apk_previous.AAAAAAAAAAAAAAAAAAAAAA" - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - writeTestAuthorizationCreation(writer, request, "cla_denied", "apk_pending.BBBBBBBBBBBBBBBBBBBBBB") - return - } - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, deniedTestEvent()) - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - authPath := defaultAuthPath(home) - previous := storedAuth{APIKey: previousCredential} - assert.NilError(t, writeStoredAuth(authPath, previous)) - - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{loginCommandName, configFlagArgument, configPath}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "authorization was denied") - assert.Equal( - t, - stdout.String(), - "Verification URL: "+server.URL+"/cli/authorize?code=cla_denied\n", - ) - auth, ok := readUsableStoredAuth(authPath) - assert.Assert(t, ok) - assert.DeepEqual(t, auth, previous) -} - -func TestLoginDoesNotWriteAuthWithoutApproval(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - terminal map[string]string - want string - }{ - { - name: "expired", - terminal: map[string]string{testEventTypeField: "cli.authorization.expired"}, - want: "authorization expired", - }, - { - name: "stream interruption", - want: "ended before a terminal event", - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - writeTestAuthorizationCreation(writer, request, "cla_terminal", "apk_pending.BBBBBBBBBBBBBBBBBBBBBB") - return - } - writer.Header().Set("Content-Type", "text/event-stream") - if testCase.terminal != nil { - writeTestSSEEvent(writer, testCase.terminal) - } - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, loginCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, testCase.want) - _, statErr := os.Stat(defaultAuthPath(home)) - assert.Assert(t, os.IsNotExist(statErr)) - }) - } -} - -func TestLoginRetriesAnInvalidPreviousCredentialAnonymously(t *testing.T) { - t.Parallel() - - //nolint:gosec // Synthetic credential used only by the local test server. - const previousCredential = "apk_stale.AAAAAAAAAAAAAAAAAAAAAA" - requestedAuthorization := make(chan string, 2) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPost { - authorization := request.Header.Get("Authorization") - requestedAuthorization <- authorization - if authorization != "" { - writer.WriteHeader(http.StatusUnauthorized) - return - } - writeTestAuthorizationCreation(writer, request, "cla_retry", "apk_pending.BBBBBBBBBBBBBBBBBBBBBB") - return - } - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, deniedTestEvent()) - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - assert.NilError(t, writeStoredAuth( - defaultAuthPath(home), - storedAuth{APIKey: previousCredential}, - )) - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, loginCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "authorization was denied") - assert.Equal(t, <-requestedAuthorization, "Bearer "+previousCredential) - assert.Equal(t, <-requestedAuthorization, "") -} - -func TestLoginDoesNotWriteAuthWhenFinalWhoamiRejectsCredential(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch { - case request.Method == http.MethodPost && request.URL.Path == "/cli/authorizations": - writeTestAuthorizationCreation(writer, request, "cla_rejected", "apk_rejected.BBBBBBBBBBBBBBBBBBBBBB") - case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/cli/authorizations/"): - writer.Header().Set("Content-Type", "text/event-stream") - writeTestSSEEvent(writer, approvedTestEvent("team_rejected")) - case request.URL.Path == "/s/whoami": - writer.WriteHeader(http.StatusUnauthorized) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL) - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, loginCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "verify approved credential returned HTTP status 401") - _, statErr := os.Stat(defaultAuthPath(home)) - assert.Assert(t, os.IsNotExist(statErr)) -} - -func writeTestCLIConfig( - t *testing.T, - directory string, - apiOrigin string, - dashboardOrigins ...string, -) string { - t.Helper() - dashboardOrigin := apiOrigin - if len(dashboardOrigins) == 1 { - dashboardOrigin = dashboardOrigins[0] - } - path := filepath.Join(directory, "cli.yaml") - assert.NilError(t, os.WriteFile(path, []byte( - "api_origin: "+apiOrigin+"\n"+ - "dashboard_origin: "+dashboardOrigin+"\n"+ - "print_trace_ids: false\n", - ), 0o600)) - return path -} - -func serverURL(request *http.Request) string { - return httpScheme + "://" + request.Host -} - -func writeTestAuthorizationCreation(writer http.ResponseWriter, request *http.Request, code string, credential string) { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(writer).Encode(map[string]string{ - "code": code, - "verification_url": serverURL(request) + "/cli/authorize?code=" + code, - "credential": credential, - }); err != nil { - panic(err) - } -} - -func approvedTestEvent(teamID string) map[string]string { - return map[string]string{ - testEventTypeField: "cli.authorization.approved", - testTeamIDField: teamID, - } -} - -func deniedTestEvent() map[string]string { - return map[string]string{testEventTypeField: "cli.authorization.denied"} -} - -func writeTestSSEEvent(writer io.Writer, event any) { - _, _ = io.WriteString(writer, "data: ") - if err := json.NewEncoder(writer).Encode(event); err != nil { - return - } - _, _ = io.WriteString(writer, "\n") -} - -func requestedScopeStrings() []string { - return []string{ - "team:read", - "team:manage", - "show:read", - "show:create", - "show:update", - "episode:create", - "episode:delete", - "episode:update", - "episode:publish", - } -} diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go deleted file mode 100644 index d4bc9b4..0000000 --- a/internal/cli/main_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "testing" - - "go.uber.org/goleak" - "gotest.tools/v3/assert" -) - -func TestWriteCLIErrorPrintsOnlyValidationMessages(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{showsCommandName, createCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, "--type is required") - - var stderr bytes.Buffer - writeCLIError(&stderr, err) - - assert.Equal(t, stderr.String(), "shows create: invalid arguments\n"+ - " --title is required\n"+ - " --slug is required\n"+ - " --type is required\n"+ - " --language is required\n") -} - -func TestMain(m *testing.M) { - goleak.VerifyTestMain(m) -} diff --git a/internal/cli/media.go b/internal/cli/media.go deleted file mode 100644 index dd24124..0000000 --- a/internal/cli/media.go +++ /dev/null @@ -1,279 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "time" - - "github.com/asticode/go-astiav" -) - -const ( - mediaFastStart = "+faststart" - mediaMovFlags = "movflags" -) - -// A mediaInput owns its demuxer and a single lookahead packet. Only downloaded -// local files enter FFmpeg; HTTP and cancellation remain owned by the importer. -type mediaInput struct { - format *astiav.FormatContext - stream *astiav.Stream - packet *astiav.Packet - ready bool -} - -func openMediaInput(path string, kind astiav.MediaType) (*mediaInput, error) { - input := &mediaInput{format: astiav.AllocFormatContext(), packet: astiav.AllocPacket()} - if input.format == nil || input.packet == nil { - input.close() - return nil, errors.New("allocate native media input") - } - if err := input.format.OpenInput(path, nil, nil); err != nil { - input.close() - return nil, fmt.Errorf("open media %q: %w", path, err) - } - if err := input.format.FindStreamInfo(nil); err != nil { - input.close() - return nil, fmt.Errorf("inspect media %q: %w", path, err) - } - for _, stream := range input.format.Streams() { - if stream.CodecParameters().MediaType() == kind { - input.stream = stream - return input, nil - } - } - input.close() - return nil, fmt.Errorf("media %q has no %s stream", path, kind) -} - -func (input *mediaInput) close() { - if input.packet != nil { - input.packet.Free() - } - if input.format != nil { - input.format.CloseInput() - input.format.Free() - } -} - -func (input *mediaInput) next(ctx context.Context) error { - input.ready = false - input.packet.Unref() - for { - if err := ctx.Err(); err != nil { - return err - } - if err := input.format.ReadFrame(input.packet); err != nil { - if errors.Is(err, astiav.ErrEof) { - return nil - } - return fmt.Errorf("read media packet: %w", err) - } - if input.packet.StreamIndex() == input.stream.Index() { - input.ready = true - return nil - } - input.packet.Unref() - } -} - -func (input *mediaInput) timestamp() int64 { - timestamp := input.packet.Dts() - if timestamp == astiav.NoPtsValue { - timestamp = input.packet.Pts() - } - if timestamp == astiav.NoPtsValue { - timestamp = 0 - } - return astiav.RescaleQ(timestamp, input.stream.TimeBase(), astiav.NewRational(1, int(time.Second/time.Microsecond))) -} - -type mediaOutput struct { - format *astiav.FormatContext - io *astiav.IOContext -} - -func openMediaOutput(path string) (*mediaOutput, error) { - format, err := astiav.AllocOutputFormatContext(nil, "", path) - if err != nil { - return nil, fmt.Errorf("allocate output %q: %w", path, err) - } - output := &mediaOutput{format: format} - if !format.OutputFormat().Flags().Has(astiav.IOFormatFlagNofile) { - output.io, err = astiav.OpenIOContext(path, astiav.NewIOContextFlags(astiav.IOContextFlagWrite), nil, nil) - if err != nil { - format.Free() - return nil, fmt.Errorf("open output %q: %w", path, err) - } - format.SetPb(output.io) - } - return output, nil -} - -func (output *mediaOutput) close() error { - var err error - if output.io != nil { - err = output.io.Close() - } - output.format.Free() - return err -} - -func (output *mediaOutput) copyStream(input *mediaInput) (*astiav.Stream, error) { - stream := output.format.NewStream(nil) - if stream == nil { - return nil, errors.New("allocate output stream") - } - if err := input.stream.CodecParameters().Copy(stream.CodecParameters()); err != nil { - return nil, err - } - stream.CodecParameters().SetCodecTag(0) - stream.SetTimeBase(input.stream.TimeBase()) - return stream, nil -} - -func (output *mediaOutput) header(options map[string]string) error { - dictionary := astiav.NewDictionary() - defer dictionary.Free() - for key, value := range options { - if err := dictionary.Set(key, value, 0); err != nil { - return err - } - } - if err := output.format.WriteHeader(dictionary); err != nil { - return fmt.Errorf("write media header: %w", err) - } - return nil -} - -func (output *mediaOutput) write(input *mediaInput, stream *astiav.Stream) error { - input.packet.RescaleTs(input.stream.TimeBase(), stream.TimeBase()) - input.packet.SetStreamIndex(stream.Index()) - input.packet.SetPos(-1) - if err := output.format.WriteInterleavedFrame(input.packet); err != nil { - return fmt.Errorf("write media packet: %w", err) - } - return nil -} - -func muxMedia(ctx context.Context, videoPath, audioPath, destination string) (result error) { - video, err := openMediaInput(videoPath, astiav.MediaTypeVideo) - if err != nil { - return err - } - defer video.close() - audio, err := openMediaInput(audioPath, astiav.MediaTypeAudio) - if err != nil { - return err - } - defer audio.close() - output, err := openMediaOutput(destination) - if err != nil { - return err - } - defer func() { result = errors.Join(result, output.close()) }() - videoStream, err := output.copyStream(video) - if err != nil { - return err - } - audioStream, err := output.copyStream(audio) - if err != nil { - return err - } - if err := output.header(map[string]string{mediaMovFlags: mediaFastStart}); err != nil { - return err - } - if err := video.next(ctx); err != nil { - return err - } - if err := audio.next(ctx); err != nil { - return err - } - if !video.ready || !audio.ready { - return errors.New("downloaded media stream is empty") - } - return interleaveMedia(ctx, output, video, audio, videoStream, audioStream) -} - -func interleaveMedia( - ctx context.Context, output *mediaOutput, video, audio *mediaInput, - videoStream, audioStream *astiav.Stream, -) error { - // Reading both inputs in timestamp order keeps muxer buffering bounded even - // for long recordings. Never enqueue the entire video before the audio. - for video.ready || audio.ready { - input, stream := audio, audioStream - if video.ready && (!audio.ready || video.timestamp() <= audio.timestamp()) { - input, stream = video, videoStream - } - if err := output.write(input, stream); err != nil { - return err - } - if err := input.next(ctx); err != nil { - return err - } - } - return output.format.WriteTrailer() -} - -func remuxMedia( - ctx context.Context, source, destination string, kind astiav.MediaType, options map[string]string, -) (result error) { - input, err := openMediaInput(source, kind) - if err != nil { - return err - } - defer input.close() - output, err := openMediaOutput(destination) - if err != nil { - return err - } - defer func() { result = errors.Join(result, output.close()) }() - stream, err := output.copyStream(input) - if err != nil { - return err - } - if err := output.header(options); err != nil { - return err - } - count := 0 - for { - if err := input.next(ctx); err != nil { - return err - } - if !input.ready { - break - } - if err := output.write(input, stream); err != nil { - return err - } - count++ - } - if count == 0 { - return errors.New("downloaded media stream is empty") - } - return output.format.WriteTrailer() -} - -func mediaDimensions(source string) (int, int, error) { - input, err := openMediaInput(source, astiav.MediaTypeVideo) - if err != nil { - return 0, 0, err - } - defer input.close() - parameters := input.stream.CodecParameters() - width, height := parameters.Width(), parameters.Height() - if width <= 0 || height <= 0 { - return 0, 0, fmt.Errorf("invalid video dimensions %dx%d", width, height) - } - return width, height, nil -} - -func packageMediaTrack(ctx context.Context, source, root string, kind astiav.MediaType) error { - return remuxMedia(ctx, source, filepath.Join(root, "index.m3u8"), kind, map[string]string{ - "hls_time": "6", "hls_playlist_type": "vod", "hls_segment_type": "fmp4", - "hls_fmp4_init_filename": "init.mp4", "hls_segment_filename": filepath.Join(root, "segment-%05d.m4s"), - }) -} diff --git a/internal/cli/media_audio.go b/internal/cli/media_audio.go deleted file mode 100644 index 6028f36..0000000 --- a/internal/cli/media_audio.go +++ /dev/null @@ -1,291 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - - "github.com/asticode/go-astiav" -) - -const ( - mediaAudioRate = 48000 - mediaStereoBitrate = 128000 -) - -func prepareMediaAudio(ctx context.Context, source, destination string) error { - if err := ctx.Err(); err != nil { - return err - } - input, err := openMediaInput(source, astiav.MediaTypeAudio) - if err != nil { - return err - } - defer input.close() - if input.stream.CodecParameters().CodecID() == astiav.CodecIDAac { - return remuxMedia(ctx, source, destination, astiav.MediaTypeAudio, map[string]string{mediaMovFlags: mediaFastStart}) - } - return transcodeMediaAudio(ctx, input, destination) -} - -type mediaAudioTranscoder struct { - decoder, encoder *astiav.CodecContext - resampler *astiav.SoftwareResampleContext - fifo *astiav.AudioFifo - output *mediaOutput - stream *astiav.Stream - timestamp int64 -} - -func (transcoder *mediaAudioTranscoder) close() { - if transcoder.decoder != nil { - transcoder.decoder.Free() - } - if transcoder.encoder != nil { - transcoder.encoder.Free() - } - if transcoder.resampler != nil { - transcoder.resampler.Free() - } - if transcoder.fifo != nil { - transcoder.fifo.Free() - } -} - -func (transcoder *mediaAudioTranscoder) openCodecs(input *mediaInput) error { - decoder := astiav.FindDecoder(input.stream.CodecParameters().CodecID()) - encoder := astiav.FindEncoder(astiav.CodecIDAac) - if decoder == nil || encoder == nil { - return errors.New("bundled audio codec is unavailable") - } - transcoder.decoder = astiav.AllocCodecContext(decoder) - transcoder.encoder = astiav.AllocCodecContext(encoder) - if transcoder.decoder == nil || transcoder.encoder == nil { - return errors.New("allocate audio codec") - } - if err := input.stream.CodecParameters().ToCodecContext(transcoder.decoder); err != nil { - return err - } - if err := transcoder.decoder.Open(decoder, nil); err != nil { - return err - } - if transcoder.decoder.SampleRate() <= 0 || transcoder.decoder.ChannelLayout().Channels() <= 0 { - return errors.New("invalid input audio rate or channel layout") - } - layout := astiav.ChannelLayoutStereo - bitrate := int64(mediaStereoBitrate) - if transcoder.decoder.ChannelLayout().Channels() == 1 { - layout = astiav.ChannelLayoutMono - bitrate = 64000 - } - transcoder.encoder.SetChannelLayout(layout) - transcoder.encoder.SetSampleRate(mediaAudioRate) - transcoder.encoder.SetSampleFormat(astiav.SampleFormatFltp) - transcoder.encoder.SetBitRate(bitrate) - transcoder.encoder.SetTimeBase(astiav.NewRational(1, mediaAudioRate)) - transcoder.encoder.SetFlags(astiav.NewCodecContextFlags(astiav.CodecContextFlagGlobalHeader)) - return transcoder.encoder.Open(encoder, nil) -} - -func (transcoder *mediaAudioTranscoder) openOutput() error { - transcoder.stream = transcoder.output.format.NewStream(nil) - if transcoder.stream == nil { - return errors.New("allocate AAC output stream") - } - transcoder.stream.SetTimeBase(transcoder.encoder.TimeBase()) - if err := transcoder.encoder.ToCodecParameters(transcoder.stream.CodecParameters()); err != nil { - return err - } - if err := transcoder.output.header(map[string]string{mediaMovFlags: mediaFastStart}); err != nil { - return err - } - transcoder.resampler = astiav.AllocSoftwareResampleContext() - transcoder.fifo = astiav.AllocAudioFifo( - transcoder.encoder.SampleFormat(), transcoder.encoder.ChannelLayout().Channels(), 1, - ) - if transcoder.resampler == nil || transcoder.fifo == nil { - return errors.New("allocate audio resampler") - } - return nil -} - -func transcodeMediaAudio(ctx context.Context, input *mediaInput, destination string) (result error) { - transcoder := &mediaAudioTranscoder{} - defer transcoder.close() - if err := transcoder.openCodecs(input); err != nil { - return fmt.Errorf("open audio codecs: %w", err) - } - output, err := openMediaOutput(destination) - if err != nil { - return err - } - transcoder.output = output - defer func() { result = errors.Join(result, output.close()) }() - if err := transcoder.openOutput(); err != nil { - return err - } - return transcoder.run(ctx, input) -} - -func (transcoder *mediaAudioTranscoder) run(ctx context.Context, input *mediaInput) error { - for { - if err := input.next(ctx); err != nil { - return err - } - if !input.ready { - break - } - if err := transcoder.decode(ctx, input.packet); err != nil { - return err - } - } - if err := transcoder.decode(ctx, nil); err != nil { - return err - } - return transcoder.finish(ctx) -} - -func (transcoder *mediaAudioTranscoder) finish(ctx context.Context) error { - for { - if err := ctx.Err(); err != nil { - return err - } - samples, err := transcoder.resample(nil) - if err != nil { - return err - } - if samples == 0 { - break - } - if err := transcoder.encodeReady(ctx, false); err != nil { - return err - } - } - if err := transcoder.encodeReady(ctx, true); err != nil { - return err - } - if transcoder.timestamp == 0 { - return errors.New("downloaded audio stream is empty") - } - if err := transcoder.encoder.SendFrame(nil); err != nil { - return err - } - if err := transcoder.drainEncoder(); err != nil { - return err - } - return transcoder.output.format.WriteTrailer() -} - -func mediaCodecWaiting(err error) bool { - return errors.Is(err, astiav.ErrEagain) || errors.Is(err, astiav.ErrEof) -} - -func (transcoder *mediaAudioTranscoder) decode(ctx context.Context, packet *astiav.Packet) error { - if err := transcoder.decoder.SendPacket(packet); err != nil { - return err - } - frame := astiav.AllocFrame() - if frame == nil { - return errors.New("allocate decoded audio frame") - } - defer frame.Free() - for { - if err := ctx.Err(); err != nil { - return err - } - if err := transcoder.decoder.ReceiveFrame(frame); err != nil { - if mediaCodecWaiting(err) { - return nil - } - return fmt.Errorf("decode audio frame: %w", err) - } - if _, err := transcoder.resample(frame); err != nil { - return err - } - frame.Unref() - if err := transcoder.encodeReady(ctx, false); err != nil { - return err - } - } -} - -func (transcoder *mediaAudioTranscoder) resample(input *astiav.Frame) (int, error) { - frame := astiav.AllocFrame() - if frame == nil { - return 0, errors.New("allocate resampled audio frame") - } - defer frame.Free() - frame.SetChannelLayout(transcoder.encoder.ChannelLayout()) - frame.SetSampleFormat(transcoder.encoder.SampleFormat()) - frame.SetSampleRate(mediaAudioRate) - // Let swresample allocate enough space for the input and retained delay. - if err := transcoder.resampler.ConvertFrame(input, frame); err != nil { - return 0, fmt.Errorf("resample audio: %w", err) - } - if frame.NbSamples() > 0 { - if _, err := transcoder.fifo.Write(frame); err != nil { - return 0, err - } - } - return frame.NbSamples(), nil -} - -func (transcoder *mediaAudioTranscoder) encodeReady(ctx context.Context, finish bool) error { - size := transcoder.encoder.FrameSize() - for transcoder.fifo.Size() >= size || (finish && transcoder.fifo.Size() > 0) { - if err := ctx.Err(); err != nil { - return err - } - if err := transcoder.encodeFrame(min(size, transcoder.fifo.Size())); err != nil { - return err - } - } - return nil -} - -func (transcoder *mediaAudioTranscoder) encodeFrame(samples int) error { - frame := astiav.AllocFrame() - if frame == nil { - return errors.New("allocate encoder audio frame") - } - defer frame.Free() - frame.SetChannelLayout(transcoder.encoder.ChannelLayout()) - frame.SetSampleFormat(transcoder.encoder.SampleFormat()) - frame.SetSampleRate(mediaAudioRate) - frame.SetNbSamples(samples) - if err := frame.AllocBuffer(0); err != nil { - return err - } - if _, err := transcoder.fifo.Read(frame); err != nil { - return err - } - frame.SetPts(transcoder.timestamp) - transcoder.timestamp += int64(samples) - if err := transcoder.encoder.SendFrame(frame); err != nil { - return err - } - return transcoder.drainEncoder() -} - -func (transcoder *mediaAudioTranscoder) drainEncoder() error { - packet := astiav.AllocPacket() - if packet == nil { - return errors.New("allocate encoded audio packet") - } - defer packet.Free() - for { - if err := transcoder.encoder.ReceivePacket(packet); err != nil { - if mediaCodecWaiting(err) { - return nil - } - return fmt.Errorf("encode AAC packet: %w", err) - } - packet.RescaleTs(transcoder.encoder.TimeBase(), transcoder.stream.TimeBase()) - packet.SetStreamIndex(transcoder.stream.Index()) - packet.SetPos(-1) - if err := transcoder.output.format.WriteInterleavedFrame(packet); err != nil { - return err - } - packet.Unref() - } -} diff --git a/internal/cli/media_test.go b/internal/cli/media_test.go deleted file mode 100644 index 767fd66..0000000 --- a/internal/cli/media_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/asticode/go-astiav" - "gotest.tools/v3/assert" -) - -func TestNativeMediaProducesAVCAACAndHLS(t *testing.T) { - t.Parallel() - for _, audio := range []string{"aac.m4a", "opus.webm"} { - t.Run(audio, func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - directory := t.TempDir() - prepared := filepath.Join(directory, "audio.m4a") - assert.NilError(t, prepareMediaAudio(ctx, filepath.Join("testdata", "media", audio), prepared)) - destination := filepath.Join(directory, "video.mp4") - assert.NilError(t, muxMedia(ctx, filepath.Join("testdata", "media", "video.mp4"), prepared, destination)) - duration, err := packageYouTubeVideo(ctx, directory) - assert.NilError(t, err) - assert.Assert(t, duration >= 1 && duration <= 4, "unexpected package duration %d", duration) - assertNativePackage(t, directory) - }) - } -} - -func assertNativePackage(t *testing.T, directory string) { - t.Helper() - destination := filepath.Join(directory, "video.mp4") - audio, err := openMediaInput(destination, astiav.MediaTypeAudio) - assert.NilError(t, err) - defer audio.close() - assert.Equal(t, audio.stream.CodecParameters().CodecID(), astiav.CodecIDAac) - video, err := openMediaInput(destination, astiav.MediaTypeVideo) - assert.NilError(t, err) - defer video.close() - assert.Equal(t, video.stream.CodecParameters().CodecID(), astiav.CodecIDH264) - assert.Equal(t, video.stream.CodecParameters().Width(), 1280) - assert.Equal(t, video.stream.CodecParameters().Height(), 720) - data, err := os.ReadFile(destination) - assert.NilError(t, err) - assert.Assert(t, len(data) > 100_000 && len(data) < 2_000_000) - moov, mdat := bytes.Index(data, []byte("moov")), bytes.Index(data, []byte("mdat")) - assert.Assert(t, moov > 0 && mdat > moov, "MP4 must be fast-start") - for _, kind := range []string{youtubeVideoKind, youtubeAudioKind} { - root := filepath.Join(directory, "hls", kind) - playlist, err := os.ReadFile(filepath.Join(root, "index.m3u8")) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(string(playlist), "#EXT-X-ENDLIST")) - for _, name := range []string{"init.mp4", "segment-00000.m4s"} { - info, err := os.Stat(filepath.Join(root, name)) - assert.NilError(t, err) - assert.Assert(t, info.Size() > 100 && info.Size() < 2_000_000) - } - } -} - -func TestNativeMediaCancellationAndInvalidInput(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(t.Context()) - cancel() - output := filepath.Join(t.TempDir(), "audio.m4a") - assert.ErrorIs(t, prepareMediaAudio(ctx, "testdata/media/opus.webm", output), context.Canceled) - _, err := os.Stat(output) - assert.Assert(t, os.IsNotExist(err), "cancelled preparation must not create output") - invalid := filepath.Join(t.TempDir(), "invalid.mp4") - assert.NilError(t, os.WriteFile(invalid, []byte("not media"), 0o600)) - assert.ErrorContains(t, prepareMediaAudio(t.Context(), invalid, output), "open media") -} diff --git a/internal/cli/members.go b/internal/cli/members.go deleted file mode 100644 index 298d422..0000000 --- a/internal/cli/members.go +++ /dev/null @@ -1,576 +0,0 @@ -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "net/http" - "strings" - "time" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - emailFlagName = "email" - memberFlagName = "member" - roleFlagName = "role" -) - -func runMembersCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - if len(args) == 0 { - writeMembersUsage(stderr) - return errors.New("members command is required") - } - switch args[0] { - case helpCommandName, shortHelpFlag, longHelpFlag: - return runMembersHelp(args, stdout) - case listCommandName: - return runMembersList(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - case inviteCommandName: - return runMembersInvite(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - case roleCommandName: - return runMembersRole(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - case removeCommandName: - return runMembersRemove(ctx, args[1:], stdout, stderr, home, configPath, configExplicit, defaultConfig) - default: - writeMembersUsage(stderr) - return fmt.Errorf("unknown members command %q", args[0]) - } -} - -func runMembersHelp(args []string, stdout io.Writer) error { - if len(args) == 1 { - writeMembersUsage(stdout) - return nil - } - if len(args) != nestedCommandArgs || !writeNestedCommandUsage(membersCommandName, args[1], stdout) { - return fmt.Errorf("members help received unknown command %q", args[1]) - } - return nil -} - -func runMembersList( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox members list", flag.ContinueOnError) - flags.SetOutput(stderr) - showSlug := "" - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&showSlug, showFlagName, "", "show slug; omit to list team members") - flags.Usage = func() { writeMembersListUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse members list flags: %w", err) - } - if flags.NArg() != 0 { - return fmt.Errorf("members list received unexpected argument %q", flags.Arg(0)) - } - client, config, err := teamManagementClient( - home, - configPath, - configExplicit || flagWasSet(flags, configFlagName), - defaultConfig, - ) - if err != nil { - return err - } - showSlug = strings.TrimSpace(showSlug) - if showSlug != "" { - return listShowMembers(ctx, client, config.apiOrigin, showSlug, stdout) - } - return listTeamMembers(ctx, client, config.apiOrigin, stdout) -} - -func listShowMembers( - ctx context.Context, - client *publicapi.Client, - apiOrigin string, - showSlug string, - stdout io.Writer, -) error { - response, err := client.ListShowMembers(ctx, publicapi.ListShowMembersParams{ShowSlug: showSlug}) - if err != nil { - return fmt.Errorf("list show members: %w", err) - } - if response == nil { - return fmt.Errorf("list show members against %q returned no response", apiOrigin) - } - if response.Status200 == nil { - return teamManagementResponseError( - "list show members", response.StatusCode, response.Status400, - ) - } - if err := writeShowMembers(stdout, response.Status200.Members); err != nil { - return err - } - return writePendingShowInvitations(stdout, response.Status200.Invitations) -} - -func listTeamMembers( - ctx context.Context, - client *publicapi.Client, - apiOrigin string, - stdout io.Writer, -) error { - response, err := client.ListTeamMembers(ctx) - if err != nil { - return fmt.Errorf("list members: %w", err) - } - if response == nil { - return fmt.Errorf("list members against %q returned no response", apiOrigin) - } - if response.Status200 == nil { - return teamManagementResponseError( - "list members", response.StatusCode, response.Status400, - ) - } - if err := writeTeamMembers(stdout, response.Status200.Members); err != nil { - return err - } - return writePendingTeamInvitations(stdout, response.Status200.Invitations) -} - -func writeShowMembers(stdout io.Writer, members []publicapi.ShowMember) error { - for _, member := range members { - if _, err := fmt.Fprintf( - stdout, - "%s\t%s\t%s\t%s\n", - member.AccessSource, - member.Role, - member.User.Email, - member.User.Id, - ); err != nil { - return fmt.Errorf("print show member %q: %w", member.User.Id, err) - } - } - return nil -} - -func writePendingShowInvitations(stdout io.Writer, invitations []publicapi.PendingShowInvitation) error { - for _, invitation := range invitations { - expires := time.UnixMilli(invitation.ExpiresAt).UTC().Format(time.RFC3339) - if _, err := fmt.Fprintf( - stdout, - "pending\t%s\t%s\t%s\t%s\n", - invitation.Role, - invitation.Email, - invitation.Id, - expires, - ); err != nil { - return fmt.Errorf("print show invitation %q: %w", invitation.Id, err) - } - } - return nil -} - -func writeTeamMembers(stdout io.Writer, members []publicapi.TeamMember) error { - for _, member := range members { - if _, err := fmt.Fprintf(stdout, "%s\t%s\t%s\n", member.Role, member.User.Email, member.User.Id); err != nil { - return fmt.Errorf("print member %q: %w", member.User.Id, err) - } - } - return nil -} - -func writePendingTeamInvitations(stdout io.Writer, invitations []publicapi.PendingTeamInvitation) error { - for _, invitation := range invitations { - expires := time.UnixMilli(invitation.ExpiresAt).UTC().Format(time.RFC3339) - if _, err := fmt.Fprintf( - stdout, - "pending\t%s\t%s\t%s\t%s\n", - invitation.Role, - invitation.Email, - invitation.Id, - expires, - ); err != nil { - return fmt.Errorf("print invitation %q: %w", invitation.Id, err) - } - } - return nil -} - -func runMembersInvite( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox members invite", flag.ContinueOnError) - flags.SetOutput(stderr) - email, role, showSlug := "", "", "" - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&email, emailFlagName, "", "email address to invite (required)") - flags.StringVar(&role, roleFlagName, "", "member role: read or write (required)") - flags.StringVar(&showSlug, showFlagName, "", "show slug; omit to invite a team member") - flags.Usage = func() { writeMembersInviteUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse members invite flags: %w", err) - } - if flags.NArg() != 0 { - return fmt.Errorf("members invite received unexpected argument %q", flags.Arg(0)) - } - email = strings.ToLower(strings.TrimSpace(email)) - if email == "" { - return errors.New("members invite: --email is required") - } - if err := validateMemberRole(role, "members invite"); err != nil { - return err - } - client, _, err := teamManagementClient( - home, - configPath, - configExplicit || flagWasSet(flags, configFlagName), - defaultConfig, - ) - if err != nil { - return err - } - showSlug = strings.TrimSpace(showSlug) - if showSlug != "" { - invitation, inviteErr := inviteShowMember(ctx, client, showSlug, email, role) - if inviteErr != nil { - return inviteErr - } - _, err = fmt.Fprintf(stdout, "Invited %s to %s as %s.\n", invitation.Email, showSlug, invitation.Role) - return err - } - invitation, err := inviteTeamMember(ctx, client, email, role) - if err != nil { - return err - } - _, err = fmt.Fprintf(stdout, "Invited %s as %s.\n", invitation.Email, invitation.Role) - return err -} - -func inviteShowMember( - ctx context.Context, - client *publicapi.Client, - showSlug string, - email string, - role string, -) (publicapi.PendingShowInvitation, error) { - response, err := client.CreateShowInvitation(ctx, publicapi.CreateShowInvitationParams{ - ShowSlug: showSlug, - Body: publicapi.CreateShowInvitation{ - Email: email, - Role: publicapi.AssignableTeamRole(role), - }, - }) - if err != nil { - return publicapi.PendingShowInvitation{}, fmt.Errorf("invite show member %q: %w", email, err) - } - if response == nil { - return publicapi.PendingShowInvitation{}, errors.New("invite show member returned no response") - } - if response.Status201 == nil { - return publicapi.PendingShowInvitation{}, teamManagementResponseError( - "invite show member", response.StatusCode, response.Status400, - ) - } - return *response.Status201, nil -} - -func inviteTeamMember( - ctx context.Context, - client *publicapi.Client, - email string, - role string, -) (publicapi.PendingTeamInvitation, error) { - response, err := client.CreateTeamInvitation(ctx, publicapi.CreateTeamInvitationParams{ - Body: publicapi.CreateTeamInvitation{ - Email: email, - Role: publicapi.AssignableTeamRole(role), - }, - }) - if err != nil { - return publicapi.PendingTeamInvitation{}, fmt.Errorf("invite member %q: %w", email, err) - } - if response == nil { - return publicapi.PendingTeamInvitation{}, errors.New("invite member returned no response") - } - if response.Status201 == nil { - return publicapi.PendingTeamInvitation{}, teamManagementResponseError( - "invite member", response.StatusCode, response.Status400, - ) - } - return *response.Status201, nil -} - -func runMembersRole( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox members role", flag.ContinueOnError) - flags.SetOutput(stderr) - memberID, role, showSlug := "", "", "" - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&memberID, memberFlagName, "", "member user ID (required)") - flags.StringVar(&role, roleFlagName, "", "member role: read or write (required)") - flags.StringVar(&showSlug, showFlagName, "", "show slug; omit to change a team member") - flags.Usage = func() { writeMembersRoleUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse members role flags: %w", err) - } - if flags.NArg() != 0 { - return fmt.Errorf("members role received unexpected argument %q", flags.Arg(0)) - } - if !strings.HasPrefix(memberID, "usr_") { - return errors.New("members role: --member must be a user ID") - } - if err := validateMemberRole(role, "members role"); err != nil { - return err - } - client, _, err := teamManagementClient( - home, - configPath, - configExplicit || flagWasSet(flags, configFlagName), - defaultConfig, - ) - if err != nil { - return err - } - showSlug = strings.TrimSpace(showSlug) - if showSlug != "" { - return updateShowMemberRole(ctx, client, showSlug, memberID, role, stdout) - } - return updateTeamMemberRole(ctx, client, memberID, role, stdout) -} - -func updateShowMemberRole( - ctx context.Context, - client *publicapi.Client, - showSlug string, - memberID string, - role string, - stdout io.Writer, -) error { - response, err := client.UpdateShowMemberRole(ctx, publicapi.UpdateShowMemberRoleParams{ - ShowSlug: showSlug, - UserId: memberID, - Body: publicapi.UpdateShowMemberRole{ - Role: publicapi.AssignableTeamRole(role), - }, - }) - if err != nil { - return fmt.Errorf("change show member %q role: %w", memberID, err) - } - if response == nil { - return errors.New("change show member role returned no response") - } - if response.StatusCode != http.StatusNoContent { - return teamManagementResponseError( - "change show member role", response.StatusCode, response.Status400, - ) - } - _, err = fmt.Fprintf(stdout, "Changed show member %s to %s.\n", memberID, role) - return err -} - -func updateTeamMemberRole( - ctx context.Context, - client *publicapi.Client, - memberID string, - role string, - stdout io.Writer, -) error { - response, err := client.UpdateTeamMemberRole(ctx, publicapi.UpdateTeamMemberRoleParams{ - UserId: memberID, - Body: publicapi.UpdateTeamMemberRole{ - Role: publicapi.AssignableTeamRole(role), - }, - }) - if err != nil { - return fmt.Errorf("change member %q role: %w", memberID, err) - } - if response == nil { - return errors.New("change member role returned no response") - } - if response.StatusCode != http.StatusNoContent { - return teamManagementResponseError( - "change member role", response.StatusCode, response.Status400, - ) - } - _, err = fmt.Fprintf(stdout, "Changed member %s to %s.\n", memberID, role) - return err -} - -func runMembersRemove( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox members remove", flag.ContinueOnError) - flags.SetOutput(stderr) - memberID, showSlug, confirmed := "", "", false - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&memberID, memberFlagName, "", "member user ID (required)") - flags.BoolVar(&confirmed, yesFlagName, false, "confirm removal (required)") - flags.StringVar(&showSlug, showFlagName, "", "show slug; omit to remove a team member") - flags.Usage = func() { writeMembersRemoveUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse members remove flags: %w", err) - } - if flags.NArg() != 0 { - return fmt.Errorf("members remove received unexpected argument %q", flags.Arg(0)) - } - if !strings.HasPrefix(memberID, "usr_") { - return errors.New("members remove: --member must be a user ID") - } - if !confirmed { - return errors.New("members remove: --yes is required") - } - client, _, err := teamManagementClient( - home, - configPath, - configExplicit || flagWasSet(flags, configFlagName), - defaultConfig, - ) - if err != nil { - return err - } - showSlug = strings.TrimSpace(showSlug) - if showSlug != "" { - return removeShowMember(ctx, client, showSlug, memberID, stdout) - } - return removeTeamMember(ctx, client, memberID, stdout) -} - -func removeShowMember( - ctx context.Context, - client *publicapi.Client, - showSlug string, - memberID string, - stdout io.Writer, -) error { - response, err := client.RemoveShowMember(ctx, publicapi.RemoveShowMemberParams{ - ShowSlug: showSlug, - UserId: memberID, - }) - if err != nil { - return fmt.Errorf("remove show member %q: %w", memberID, err) - } - if response == nil { - return errors.New("remove show member returned no response") - } - if response.StatusCode != http.StatusNoContent { - return teamManagementResponseError( - "remove show member", response.StatusCode, response.Status400, - ) - } - _, err = fmt.Fprintf(stdout, "Removed show member %s.\n", memberID) - return err -} - -func removeTeamMember( - ctx context.Context, - client *publicapi.Client, - memberID string, - stdout io.Writer, -) error { - response, err := client.RemoveTeamMember(ctx, publicapi.RemoveTeamMemberParams{UserId: memberID}) - if err != nil { - return fmt.Errorf("remove member %q: %w", memberID, err) - } - if response == nil { - return errors.New("remove member returned no response") - } - if response.StatusCode != http.StatusNoContent { - return teamManagementResponseError( - "remove member", response.StatusCode, response.Status400, - ) - } - _, err = fmt.Fprintf(stdout, "Removed member %s.\n", memberID) - return err -} - -func teamManagementClient( - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) (*publicapi.Client, loadedCLIConfig, error) { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return nil, loadedCLIConfig{}, err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return nil, loadedCLIConfig{}, errors.New("team members: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return nil, loadedCLIConfig{}, err - } - return client, config, nil -} - -func validateMemberRole(role string, command string) error { - if role != "read" && role != "write" { - return fmt.Errorf("%s: --role must be read or write", command) - } - return nil -} - -func teamManagementResponseError( - action string, - status int, - validation *publicapi.ValidationErr, -) error { - switch { - case validation != nil: - return fmt.Errorf("%s: %s", action, validation.Message) - case status == http.StatusUnauthorized: - return fmt.Errorf("%s: authentication failed; run listenbox login", action) - case status == http.StatusForbidden: - return fmt.Errorf("%s: team owner permission is required", action) - case status == http.StatusConflict || status == http.StatusNotFound: - return fmt.Errorf("%s: target is unavailable", action) - default: - return fmt.Errorf("%s returned HTTP status %d", action, status) - } -} diff --git a/internal/cli/show_delete.go b/internal/cli/show_delete.go deleted file mode 100644 index 32fce2f..0000000 --- a/internal/cli/show_delete.go +++ /dev/null @@ -1,184 +0,0 @@ -//nolint:lll -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - showDeletionProgressEventType = "progress" - showDeletionTerminalEventType = "terminal" - showDeletionCompletedStatus = "completed" - showDeletionFailedStatus = "failed" - processingStatus = "processing" - maxShowDeletionPercent = 100 -) - -func deleteShow( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - slug string, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("delete show: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - response, err := client.CreateShowDeletion(ctx, publicapi.CreateShowDeletionParams{ShowSlug: slug}) - if err != nil { - return fmt.Errorf("delete show %q: %w", slug, err) - } - created, err := validateShowDeletionResponse(slug, response) - if err != nil { - return err - } - eventsResponse, err := client.ShowDeletionEvents(ctx, publicapi.ShowDeletionEventsParams{ - ShowDeletionRunId: created.ShowDeletionRunId, - }) - if err != nil { - return fmt.Errorf("read show deletion event stream for %q: %w", slug, err) - } - stream, err := validateShowDeletionEventsResponse(slug, eventsResponse) - if err != nil { - return err - } - return consumeShowDeletionStream(ctx, stdout, stderr, slug, stream) -} - -func validateShowDeletionResponse(slug string, response *publicapi.CreateShowDeletionResponse) (*publicapi.CreatedPublicShowDeletion, error) { - if response == nil { - return nil, fmt.Errorf("delete show %q returned no response", slug) - } - if response.Status202 != nil { - return response.Status202, nil - } - switch { - case response.Status400 != nil: - return nil, fmt.Errorf("delete show %q: %s", slug, response.Status400.Message) - case response.StatusCode == http.StatusUnauthorized: - return nil, fmt.Errorf("delete show %q: authentication failed; run listenbox login", slug) - case response.StatusCode == http.StatusForbidden: - return nil, fmt.Errorf("delete show %q: only the current show owner may delete it", slug) - case response.StatusCode == http.StatusNotFound: - return nil, fmt.Errorf("delete show %q: show not found", slug) - default: - return nil, fmt.Errorf("delete show %q returned HTTP status %d", slug, response.StatusCode) - } -} - -func validateShowDeletionEventsResponse(slug string, response *publicapi.ShowDeletionEventsResponse) (*publicapi.SSEStream[publicapi.ShowDeletionEvent], error) { - if response == nil || response.Status200 == nil { - status := 0 - if response != nil { - status = response.StatusCode - } - return nil, fmt.Errorf("show deletion event stream for %q returned HTTP status %d", slug, status) - } - return response.Status200, nil -} - -func consumeShowDeletionStream( - ctx context.Context, - stdout io.Writer, - stderr io.Writer, - slug string, - stream *publicapi.SSEStream[publicapi.ShowDeletionEvent], -) error { - defer func() { _ = stream.Close() }() - progress := newImportProgress(stderr, writerIsTerminal(stderr)) - defer progress.cleanFailureLine() - for { - event, ok, err := stream.Next(ctx) - if err != nil { - return fmt.Errorf("read show deletion stream for %q: %w", slug, err) - } - if !ok { - return fmt.Errorf("show deletion stream for %q ended before a terminal event", slug) - } - done, err := handleShowDeletionEvent(stdout, slug, progress, event) - if err != nil { - return err - } - if done { - return nil - } - } -} - -func handleShowDeletionEvent( - stdout io.Writer, - slug string, - progress *importProgress, - event publicapi.ShowDeletionEvent, -) (bool, error) { - if current := event.ShowDeletionProgressEvent; current != nil { - if !validShowDeletionProgressEvent(*current) { - return false, fmt.Errorf("show deletion stream for %q emitted malformed progress event", slug) - } - percent := uint32(current.Percent) //nolint:gosec // validated non-negative and bounded above - if err := progress.update(percent); err != nil { - return false, err - } - return false, nil - } - if event.ShowDeletionTerminalEvent == nil { - return false, fmt.Errorf("show deletion stream for %q emitted an empty event variant", slug) - } - return handleShowDeletionTerminalEvent(stdout, slug, progress, *event.ShowDeletionTerminalEvent) -} - -func validShowDeletionProgressEvent(event publicapi.ShowDeletionProgressEvent) bool { - if event.Type != showDeletionProgressEventType || event.Percent < 0 || event.Percent > maxShowDeletionPercent { - return false - } - if event.Status != "queued" && event.Status != processingStatus { - return false - } - return strings.TrimSpace(event.CurrentStep) != "" -} - -func handleShowDeletionTerminalEvent( - stdout io.Writer, - slug string, - progress *importProgress, - event publicapi.ShowDeletionTerminalEvent, -) (bool, error) { - if completed := event.ShowDeletionCompletedEvent; completed != nil { - if completed.Type != showDeletionTerminalEventType || - completed.Status != showDeletionCompletedStatus || completed.ShowSlug != slug { - return false, fmt.Errorf("show deletion stream for %q emitted malformed completed terminal event", slug) - } - if err := progress.complete(); err != nil { - return false, err - } - if _, err := fmt.Fprintf(stdout, "Deleted show %q\n", slug); err != nil { - return false, fmt.Errorf("print deleted show %q: %w", slug, err) - } - return true, nil - } - failed := event.ShowDeletionFailedEvent - if failed == nil || failed.Type != showDeletionTerminalEventType || failed.Status != showDeletionFailedStatus || - strings.TrimSpace(failed.ErrorCode) == "" || strings.TrimSpace(failed.ErrorMessage) == "" { - return false, fmt.Errorf("show deletion stream for %q emitted malformed failed terminal event", slug) - } - return false, fmt.Errorf("delete show %q failed (%s): %s", slug, failed.ErrorCode, failed.ErrorMessage) -} diff --git a/internal/cli/show_delete_command.go b/internal/cli/show_delete_command.go deleted file mode 100644 index 5c003b5..0000000 --- a/internal/cli/show_delete_command.go +++ /dev/null @@ -1,58 +0,0 @@ -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "io" -) - -func runShowsDeleteCommand( - ctx context.Context, - args []string, - stdout io.Writer, - stderr io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - flags := flag.NewFlagSet("listenbox shows delete", flag.ContinueOnError) - flags.SetOutput(stderr) - slug := "" - yes := false - flags.StringVar(&configPath, configFlagName, configPath, "path to CLI config file") - flags.StringVar(&slug, showFlagName, "", "global show slug (required)") - flags.BoolVar(&yes, yesFlagName, false, "confirm permanent deletion (required)") - flags.Usage = func() { writeShowsDeleteUsage(stderr) } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return fmt.Errorf("parse shows delete flags: %w", err) - } - configExplicit = configExplicit || flagWasSet(flags, configFlagName) - if flags.NArg() != 0 { - return fmt.Errorf("shows delete received unexpected argument %q", flags.Arg(0)) - } - if slug == "" { - return errors.New("shows delete: --show is required") - } - if !validShowSlug(slug) { - return fmt.Errorf("shows delete: invalid --show %q", slug) - } - if !flagWasSet(flags, yesFlagName) || !yes { - return errors.New("shows delete: --yes is required") - } - return deleteShow( - ctx, - stdout, - stderr, - home, - configPath, - configExplicit, - defaultConfig, - slug, - ) -} diff --git a/internal/cli/shows.go b/internal/cli/shows.go deleted file mode 100644 index 145da5a..0000000 --- a/internal/cli/shows.go +++ /dev/null @@ -1,305 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "image/jpeg" - "image/png" - "io" - "net/http" - "os" - "path/filepath" - "time" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - showArtworkUploadTimeout = 2 * time.Minute - showIDRandomByteLength = 8 -) - -func listShows( - ctx context.Context, - stdout io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("list shows: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - response, err := client.ListShows(ctx) - if err != nil { - return fmt.Errorf("list shows: %w", err) - } - if response == nil { - return fmt.Errorf("list shows against %q returned no response", config.apiOrigin) - } - return handleListShowsResponse(stdout, response) -} - -func handleListShowsResponse(stdout io.Writer, response *publicapi.ListShowsResponse) error { - switch { - case response.Status200 != nil: - for _, show := range *response.Status200 { - if _, err := fmt.Fprintln(stdout, show.Slug); err != nil { - return fmt.Errorf("print show slug %q: %w", show.Slug, err) - } - } - return nil - case response.Status400 != nil: - return fmt.Errorf("list shows: %s", response.Status400.Message) - case response.StatusCode == http.StatusUnauthorized: - return errors.New("list shows: authentication failed; run listenbox login") - default: - return fmt.Errorf("list shows returned HTTP status %d", response.StatusCode) - } -} - -func createShow( - ctx context.Context, - stdout io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, - title string, - slug string, - sourceKind publicapi.ShowSourceKind, - artworkPath string, - language string, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("create show: not logged in; run listenbox login") - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - showID, err := mintShowID() - if err != nil { - return fmt.Errorf("create show %q: %w", slug, err) - } - var imageAssetID *publicapi.ImageAssetID - if artworkPath != "" { - uploadClient := &http.Client{Timeout: showArtworkUploadTimeout} - uploadedID, err := uploadShowArtwork( - ctx, - client, - uploadClient, - showID, - artworkPath, - ) - if err != nil { - return fmt.Errorf("create show %q artwork: %w", slug, err) - } - imageAssetID = &uploadedID - } - response, err := client.CreateShow(ctx, publicapi.CreateShowParams{ - Body: publicapi.CreateShow{ - Id: showID, - ImageAssetId: imageAssetID, - Title: title, - Slug: slug, - SourceKind: sourceKind, - Language: language, - }, - }) - if err != nil { - return fmt.Errorf("create show %q: %w", slug, err) - } - if response == nil { - return fmt.Errorf("create show %q against %q returned no response", slug, config.apiOrigin) - } - return handleCreateShowResponse(stdout, config.dashboardOrigin, slug, response) -} - -func uploadShowArtwork( - ctx context.Context, - client *publicapi.Client, - uploadClient *http.Client, - showID publicapi.ShowID, - path string, -) (publicapi.ImageAssetID, error) { - raw, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read %q: %w", path, err) - } - contentType, ok := showArtworkContentType(raw) - if !ok { - return "", errors.New("artwork must be a JPEG or PNG image") - } - if err := validateShowArtworkDimensions(raw); err != nil { - return "", err - } - fileName := filepath.Base(path) - presign, err := client.CreateImageUploadPresign( - ctx, - publicapi.CreateImageUploadPresignParams{ - Body: publicapi.CreateImageUploadPresign{ - ByteLength: int64(len(raw)), - ContentType: contentType, - FileName: &fileName, - ShowId: showID, - }, - }, - ) - if err != nil { - return "", fmt.Errorf("prepare upload: %w", err) - } - if presign.Status201 == nil { - return "", fmt.Errorf("prepare upload returned HTTP %d", presign.StatusCode) - } - if err := putShowArtwork(ctx, uploadClient, presign.Status201, raw, contentType); err != nil { - return "", err - } - - completed, err := client.CompleteImageUpload( - ctx, - publicapi.CompleteImageUploadParams{ - ImageAssetId: presign.Status201.ImageAssetId, - Body: publicapi.CompleteImageUpload{ - ObjectKey: presign.Status201.ObjectKey, - }, - }, - ) - if err != nil { - return "", fmt.Errorf("verify upload: %w", err) - } - if completed.Status201 == nil { - return "", fmt.Errorf("verify upload returned HTTP %d", completed.StatusCode) - } - return completed.Status201.Id, nil -} - -func mintShowID() (publicapi.ShowID, error) { - randomBytes := make([]byte, showIDRandomByteLength) - if _, err := rand.Read(randomBytes); err != nil { - return "", fmt.Errorf("mint show ID: %w", err) - } - return "shw_" + hex.EncodeToString(randomBytes), nil -} - -func validateShowArtworkDimensions(raw []byte) error { - width, height, err := showArtworkDimensions(raw) - if err != nil { - return err - } - if width != height || width < 1400 || width > 3000 { - return fmt.Errorf( - "upload a square image between 1400 and 3000 pixels. Attempted resolution: %d × %d pixels", - width, - height, - ) - } - return nil -} - -func showArtworkDimensions(raw []byte) (int, int, error) { - if len(raw) >= 3 && raw[0] == 0xff && raw[1] == 0xd8 && raw[2] == 0xff { - config, err := jpeg.DecodeConfig(bytes.NewReader(raw)) - if err != nil { - return 0, 0, fmt.Errorf("read artwork dimensions: %w", err) - } - return config.Width, config.Height, nil - } - config, err := png.DecodeConfig(bytes.NewReader(raw)) - if err != nil { - return 0, 0, fmt.Errorf("read artwork dimensions: %w", err) - } - return config.Width, config.Height, nil -} - -func putShowArtwork( - ctx context.Context, - client *http.Client, - presign *publicapi.CreatedImageUploadPresign, - raw []byte, - contentType string, -) error { - request, err := http.NewRequestWithContext( - ctx, - presign.Method, - presign.UploadUrl, - bytes.NewReader(raw), - ) - if err != nil { - return fmt.Errorf("create upload request: %w", err) - } - request.ContentLength = int64(len(raw)) - request.Header.Set("Content-Type", contentType) - response, err := client.Do(request) - if err != nil { - return fmt.Errorf("upload artwork: %w", err) - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("upload artwork returned HTTP %d", response.StatusCode) - } - return nil -} - -func showArtworkContentType(raw []byte) (string, bool) { - if len(raw) >= 3 && raw[0] == 0xff && raw[1] == 0xd8 && raw[2] == 0xff { - return "image/jpeg", true - } - pngSignature := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} - if len(raw) >= len(pngSignature) && bytes.Equal(raw[:len(pngSignature)], pngSignature) { - return "image/png", true - } - return "", false -} - -func handleCreateShowResponse( - stdout io.Writer, - dashboardOrigin string, - slug string, - response *publicapi.CreateShowResponse, -) error { - switch { - case response.Status201 != nil: - showURL, err := showManagementURL(dashboardOrigin, response.Status201.TeamId, response.Status201.Id) - if err != nil { - return fmt.Errorf("create show %q returned invalid show location: %w", slug, err) - } - if _, err := fmt.Fprintf( - stdout, - "Created show %q\nOpen in Listenbox: %s\n", - response.Status201.Slug, - showURL, - ); err != nil { - return fmt.Errorf("print created show slug %q: %w", response.Status201.Slug, err) - } - return nil - case response.Status400 != nil: - return fmt.Errorf("create show %q: %s", slug, response.Status400.Message) - case response.StatusCode == http.StatusUnauthorized: - return fmt.Errorf("create show %q: authentication failed; run listenbox login", slug) - case response.StatusCode == http.StatusForbidden: - return fmt.Errorf("create show %q: API key lacks show:create scope", slug) - case response.StatusCode == http.StatusConflict: - return fmt.Errorf("create show: slug %q already exists", slug) - default: - return fmt.Errorf("create show %q returned HTTP status %d", slug, response.StatusCode) - } -} diff --git a/internal/cli/shows_test.go b/internal/cli/shows_test.go deleted file mode 100644 index e7da900..0000000 --- a/internal/cli/shows_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "regexp" - "testing" - - "gotest.tools/v3/assert" -) - -//nolint:funlen // Complete CLI request and response contract stays visible in one test. -func TestShowsCreateSendsRequiredPodcastType(t *testing.T) { - t.Parallel() - - for _, podcastType := range []string{testAudioPodcastType, testVideoPodcastType} { - t.Run(podcastType, func(t *testing.T) { - t.Parallel() - - requests := make(chan map[string]any, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - assert.Equal(t, request.URL.Path, "/s/shows") - assert.Equal(t, request.Header.Get("Authorization"), "Bearer "+testCLIAPIKey) - var body map[string]any - assert.NilError(t, json.NewDecoder(request.Body).Decode(&body)) - requests <- body - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusCreated) - _, err := fmt.Fprintf( - writer, - `{"id":"shw_0123456789abcdef","team_id":"team_0123456789abcdef",`+ - `"title":"CLI Show","slug":"`+testCLIShowSlug+`","source_kind":%q,"language":"en"}`, - podcastType, - ) - assert.NilError(t, err) - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL, "https://dashboard.test:9443") - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{ - configFlagArgument, - configPath, - showsCommandName, - createCommandName, - titleFlagArgument, - "CLI Show", - slugFlagArgument, - testCLIShowSlug, - typeFlagArgument, - podcastType, - languageFlagArgument, - "en", - }, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - wantOutput := "Created show \"cli-show\"\n" + - "Open in Listenbox: https://dashboard.test:9443/" + - "team_0123456789abcdef/shows/shw_0123456789abcdef\n" - assert.Equal(t, stdout.String(), wantOutput) - body := <-requests - showID, ok := body["id"].(string) - assert.Assert(t, ok) - assert.Assert(t, regexp.MustCompile(`^shw_[a-f0-9]{16}$`).MatchString(showID)) - assert.Equal(t, body["title"], "CLI Show") - assert.Equal(t, body["slug"], testCLIShowSlug) - assert.Equal(t, body["source_kind"], podcastType) - assert.Equal(t, body["language"], "en") - }) - } -} - -func TestShowsListPrintsAccessibleSlugs(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - assert.Equal(t, request.Method, http.MethodGet) - assert.Equal(t, request.URL.Path, "/s/shows") - assert.Equal(t, request.Header.Get("Authorization"), "Bearer "+testCLIAPIKey) - writer.Header().Set("Content-Type", "application/json") - _, err := fmt.Fprint( - writer, - `[{"id":"shw_second","team_id":"team_test","title":"Second","slug":"second","source_kind":"video","language":"en"},`+ - `{"id":"shw_first","team_id":"team_test","title":"First","slug":"first","source_kind":"audio","language":"en"}]`, - ) - assert.NilError(t, err) - })) - defer server.Close() - - home := t.TempDir() - configPath := writeTestCLIConfig(t, home, server.URL, "https://dashboard.test") - assert.NilError(t, writeStoredAuth(defaultAuthPath(home), storedAuth{APIKey: testCLIAPIKey})) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, showsCommandName, listCommandName}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal(t, stdout.String(), "second\nfirst\n") -} diff --git a/internal/cli/testdata/media/README.md b/internal/cli/testdata/media/README.md deleted file mode 100644 index a0b3cf8..0000000 --- a/internal/cli/testdata/media/README.md +++ /dev/null @@ -1,10 +0,0 @@ -Synthetic, two-second media fixtures copied from Listenbox's deterministic -YouTube E2E catalogue (listenbox/listenbox2, apps/fixtures/e2e/youtube). -No network or external executable is needed by the native media tests. - -- video.mp4: dash-range/720/manifest-stream0.mp4, AVC 1280x720. -- aac.m4a: dash-range/720/manifest-stream1.mp4, AAC audio. -- opus.webm: audio/opus.webm, Opus audio. - -Tests exercise AAC remux, Opus decode/resample/AAC encode, bounded two-input -muxing, fast-start MP4, HLS output, cancellation, and malformed input. diff --git a/internal/cli/testdata/media/aac.m4a b/internal/cli/testdata/media/aac.m4a deleted file mode 100644 index 6c8b609..0000000 Binary files a/internal/cli/testdata/media/aac.m4a and /dev/null differ diff --git a/internal/cli/testdata/media/opus.webm b/internal/cli/testdata/media/opus.webm deleted file mode 100644 index 2e8d43b..0000000 Binary files a/internal/cli/testdata/media/opus.webm and /dev/null differ diff --git a/internal/cli/testdata/media/video.mp4 b/internal/cli/testdata/media/video.mp4 deleted file mode 100644 index 893f2cf..0000000 Binary files a/internal/cli/testdata/media/video.mp4 and /dev/null differ diff --git a/internal/cli/whoami.go b/internal/cli/whoami.go deleted file mode 100644 index 435e614..0000000 --- a/internal/cli/whoami.go +++ /dev/null @@ -1,72 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -func authStatus( - ctx context.Context, - stdout io.Writer, - home string, - configPath string, - configExplicit bool, - defaultConfig loadedCLIConfig, -) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return fmt.Errorf("auth status using home %q: not logged in; run listenbox login", home) - } - client, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - response, err := client.Whoami(ctx) - if err != nil { - return fmt.Errorf("auth status: %w", err) - } - if response == nil { - return fmt.Errorf("auth status against %q returned no response", config.apiOrigin) - } - switch { - case response.Status200 != nil: - return printAuthStatus(stdout, *response.Status200) - case response.StatusCode == http.StatusUnauthorized: - return fmt.Errorf( - "auth status against %q: authentication failed; run listenbox login", - config.apiOrigin, - ) - default: - return fmt.Errorf("auth status returned HTTP status %d", response.StatusCode) - } -} - -func printAuthStatus(stdout io.Writer, identity publicapi.APIKeyWhoami) error { - name := "" - if identity.Name != nil { - name = strings.Join(strings.Fields(*identity.Name), " ") - } - email := strings.TrimSpace(identity.Email) - if email == "" { - return fmt.Errorf("print auth status: user email %q is empty", identity.Email) - } - var err error - if name == "" { - _, err = fmt.Fprintf(stdout, "Logged in as %s\n", email) - } else { - _, err = fmt.Fprintf(stdout, "Logged in as %s <%s>\n", name, email) - } - if err != nil { - return fmt.Errorf("print auth status for user %q: %w", email, err) - } - return nil -} diff --git a/internal/cli/whoami_test.go b/internal/cli/whoami_test.go deleted file mode 100644 index d0dfbda..0000000 --- a/internal/cli/whoami_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - publicapi "github.com/listenbox/listenbox-cli/publicapi" - "gotest.tools/v3/assert" -) - -func TestAuthStatusPrintsStoredAuthorizationIdentity(t *testing.T) { - t.Parallel() - - credential := "apk_test." + t.Name() - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - assert.Equal(t, request.URL.Path, "/s/whoami") - assert.Equal(t, request.Header.Get("Authorization"), "Bearer "+credential) - writer.Header().Set("Content-Type", "application/json") - _, err := fmt.Fprint( - writer, - `{"email":"john@example.com","id":"apk_test","name":"John",`+ - `"team_id":"lb_team_test","scopes":["show:create","show:update"]}`, - ) - assert.NilError(t, err) - })) - defer server.Close() - - home := t.TempDir() - writeAuthStatusTestAuth(t, home, credential) - configPath := writeTestCLIConfig(t, home, server.URL) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, authCommandName, statusCommandName}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal( - t, - stdout.String(), - "Logged in as John \n", - ) -} - -func TestAuthStatusPrintsEmailWhenNameIsAbsent(t *testing.T) { - t.Parallel() - - credential := "apk_test." + t.Name() - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/json") - _, err := fmt.Fprint( - writer, - `{"email":"john@example.com","id":"apk_test","team_id":"lb_team_test","scopes":["show:create"]}`, - ) - assert.NilError(t, err) - })) - defer server.Close() - - home := t.TempDir() - writeAuthStatusTestAuth(t, home, credential) - configPath := writeTestCLIConfig(t, home, server.URL) - var stdout bytes.Buffer - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, authCommandName, statusCommandName}, - &stdout, - &bytes.Buffer{}, - home, - ) - assert.NilError(t, err) - assert.Equal(t, stdout.String(), "Logged in as john@example.com\n") -} - -func TestPrintAuthStatusRejectsMissingEmail(t *testing.T) { - t.Parallel() - - var stdout bytes.Buffer - err := printAuthStatus(&stdout, publicapi.APIKeyWhoami{}) - assert.ErrorContains(t, err, "user email") - assert.Equal(t, stdout.String(), "") -} - -func TestAuthStatusRequiresStoredAuthorization(t *testing.T) { - t.Parallel() - - err := runCLIWithHome( - context.Background(), - []string{authCommandName, statusCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - t.TempDir(), - ) - assert.ErrorContains(t, err, "not logged in; run listenbox login") -} - -func TestAuthStatusReportsRejectedStoredAuthorization(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - home := t.TempDir() - writeAuthStatusTestAuth(t, home, "apk_test.expired-secret") - configPath := writeTestCLIConfig(t, home, server.URL) - err := runCLIWithHome( - context.Background(), - []string{configFlagArgument, configPath, authCommandName, statusCommandName}, - &bytes.Buffer{}, - &bytes.Buffer{}, - home, - ) - assert.ErrorContains(t, err, "authentication failed; run listenbox login") -} - -func writeAuthStatusTestAuth(t *testing.T, home string, credential string) { - t.Helper() - directory := filepath.Dir(defaultAuthPath(home)) - assert.NilError(t, os.MkdirAll(directory, authDirectoryMode)) - assert.NilError( - t, - os.WriteFile(defaultAuthPath(home), []byte(fmt.Sprintf(`{"api_key":%q}`, credential)), authFileMode), - ) -} diff --git a/internal/cli/youtube.go b/internal/cli/youtube.go deleted file mode 100644 index 09e267b..0000000 --- a/internal/cli/youtube.go +++ /dev/null @@ -1,459 +0,0 @@ -//nolint:lll // Media manifests and generated API mappings are kept together for review. -package cli - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "io/fs" - "math" - "net/http" - "net/url" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "time" - - "github.com/asticode/go-astiav" - youtube "github.com/kkdai/youtube/v2" - publicapi "github.com/listenbox/listenbox-cli/publicapi" -) - -const ( - youtubeTransferTimeout = 30 * time.Minute - youtubeCleanupTimeout = 10 * time.Second - youtubeFileMode = 0o600 - youtubeDirectoryMode = 0o700 - youtubeResponseLimit = 64 << 10 - youtubeVideoKind = "video" - youtubeAudioKind = "audio" -) - -func isYouTubeSource(source string) bool { - parsed, err := url.Parse(source) - if err != nil { - return false - } - switch strings.ToLower(parsed.Hostname()) { - case "youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be": - return true - default: - return false - } -} - -//nolint:cyclop // Local download, media processing and upload failures retain explicit recovery boundaries. -func importYouTube(ctx context.Context, stdout, stderr io.Writer, home, configPath string, configExplicit bool, defaultConfig loadedCLIConfig, sourceURL, slug string, slugExplicit bool) error { - config, err := loadCLIConfig(configPath, configExplicit, home, defaultConfig) - if err != nil { - return err - } - auth, ok := readUsableStoredAuth(defaultAuthPath(home)) - if !ok { - return errors.New("import YouTube: not logged in; run listenbox login") - } - api, err := newPublicClient(config.apiOrigin, auth.APIKey, true) - if err != nil { - return err - } - client := &http.Client{Timeout: youtubeTransferTimeout} - extractor := &youtube.Client{HTTPClient: client} - title, videos, err := discoverYouTube(ctx, extractor, sourceURL) - if err != nil { - return err - } - if !slugExplicit { - digest := sha256.Sum256([]byte(sourceURL)) - slug = "youtube-" + hex.EncodeToString(digest[:8]) - } - show, err := ensureYouTubeShow(ctx, api, title, slug) - if err != nil { - return err - } - progress := newImportProgress(stderr, writerIsTerminal(stderr)) - defer progress.cleanFailureLine() - for index, id := range videos { - if err := importYouTubeVideo(ctx, extractor, client, api, slug, id, stdout, config.printTraceIDs); err != nil { - return fmt.Errorf("import YouTube video %q: %w", id, err) - } - //nolint:gosec // The quotient is bounded to 0..100 by the playlist loop. - if err := progress.update(uint32((index + 1) * 100 / len(videos))); err != nil { - return err - } - } - if err := progress.complete(); err != nil { - return err - } - if _, err := fmt.Fprintln(stdout, slug); err != nil { - return err - } - managementURL, err := showManagementURL(config.dashboardOrigin, show.TeamId, show.Id) - if err != nil { - return err - } - _, err = fmt.Fprintf(stderr, "Open in Listenbox: %s\n", managementURL) - return err -} - -func discoverYouTube(ctx context.Context, client *youtube.Client, sourceURL string) (string, []string, error) { - parsed, err := url.Parse(sourceURL) - if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Port() != "" { - return "", nil, fmt.Errorf("invalid YouTube source URL %q", sourceURL) - } - if parsed.Query().Get("list") != "" { - playlist, err := client.GetPlaylistContext(ctx, sourceURL) - if err != nil { - return "", nil, fmt.Errorf("read YouTube playlist: %w", err) - } - ids := make([]string, 0, len(playlist.Videos)) - seen := make(map[string]bool, len(playlist.Videos)) - for _, entry := range playlist.Videos { - if entry.ID != "" && !seen[entry.ID] { - ids = append(ids, entry.ID) - seen[entry.ID] = true - } - } - if len(ids) == 0 { - return "", nil, errors.New("YouTube playlist has no public videos") - } - return playlist.Title, ids, nil - } - video, err := client.GetVideoContext(ctx, sourceURL) - if err != nil { - return "", nil, fmt.Errorf("read YouTube video: %w", err) - } - return video.Title, []string{video.ID}, nil -} - -func ensureYouTubeShow(ctx context.Context, client *publicapi.Client, title, slug string) (publicapi.Show, error) { - listed, err := client.ListShows(ctx) - if err != nil { - return publicapi.Show{}, err - } - if listed == nil || listed.Status200 == nil { - return publicapi.Show{}, errors.New("cannot list writable shows") - } - for _, show := range *listed.Status200 { - if show.Slug == slug { - if show.SourceKind != youtubeVideoKind { - return publicapi.Show{}, fmt.Errorf("show %q is not a video show", slug) - } - return show, nil - } - } - id, err := mintShowID() - if err != nil { - return publicapi.Show{}, err - } - created, err := client.CreateShow(ctx, publicapi.CreateShowParams{Body: publicapi.CreateShow{Id: id, Title: title, Slug: slug, SourceKind: youtubeVideoKind, Language: "en"}}) - if err != nil { - return publicapi.Show{}, err - } - if created == nil { - return publicapi.Show{}, errors.New("create YouTube show returned no response") - } - if created.Status201 == nil { - return publicapi.Show{}, handleCreateShowResponse(io.Discard, "", slug, created) - } - return *created.Status201, nil -} - -//nolint:cyclop,funlen // Local download, media processing and upload failures retain explicit recovery boundaries. -func importYouTubeVideo(ctx context.Context, extractor *youtube.Client, client *http.Client, api *publicapi.Client, slug, id string, stdout io.Writer, printTraceIDs bool) (retErr error) { - video, err := extractor.GetVideoContext(ctx, id) - if err != nil { - return err - } - directory, err := os.MkdirTemp("", "listenbox-youtube-") - if err != nil { - return err - } - defer func() { _ = os.RemoveAll(directory) }() - if err := downloadYouTubeVideo(ctx, extractor, video, directory); err != nil { - return err - } - duration, err := packageYouTubeVideo(ctx, directory) - if err != nil { - return err - } - objects, err := describeYouTubePackage(directory) - if err != nil { - return err - } - response, err := api.CreateEpisodePackage(ctx, publicapi.CreateEpisodePackageParams{Body: publicapi.CreateEpisodePackage{ - ShowSlug: slug, SourceUrl: "https://www.youtube.com/watch?v=" + id, Title: video.Title, Description: &video.Description, - DurationSeconds: duration, Objects: objects, - }}) - if err != nil { - return err - } - if response != nil && response.Status402 != nil { - admission := response.Status402 - return fmt.Errorf("video storage quota: %d seconds requested, %d remaining; %s", admission.RequestedSeconds, admission.RemainingSeconds, admission.PricingUrl) - } - if response == nil || response.Status201 == nil { - status := 0 - if response != nil { - status = response.StatusCode - } - return fmt.Errorf("reserve prepared media: HTTP %d", status) - } - session := *response.Status201 - if session.Status == importCompletedStatus { - return nil - } - defer func() { - if retErr == nil { - return - } - cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), youtubeCleanupTimeout) - defer cancel() - response, err := api.CancelEpisodePackage(cleanupCtx, publicapi.CancelEpisodePackageParams{UploadSessionId: session.UploadSessionId}) - // A completed package survives an acknowledgement loss. - if err != nil { - retErr = errors.Join(retErr, fmt.Errorf("cancel pending package: %w", err)) - } else if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusConflict { - retErr = errors.Join(retErr, fmt.Errorf("cancel pending package: HTTP %d", response.StatusCode)) - } - }() - - if printTraceIDs { - if err := printImportTraceID(stdout, response.Raw.Header.Get("X-Trace-Id")); err != nil { - return err - } - } - for index, object := range objects { - if err := uploadYouTubeObject(ctx, api, client, session, int64(index), object, directory); err != nil { - return err - } - } - completed, err := api.CompleteEpisodePackage(ctx, publicapi.CompleteEpisodePackageParams{UploadSessionId: session.UploadSessionId}) - if err != nil { - return err - } - if completed == nil || completed.Status200 == nil || completed.Status200.Status != importCompletedStatus { - return errors.New("prepared media did not complete") - } - return nil -} - -//nolint:cyclop // Local download, media processing and upload failures retain explicit recovery boundaries. -func downloadYouTubeVideo(ctx context.Context, client *youtube.Client, video *youtube.Video, directory string) error { - formats := slices.Clone(video.Formats) - slices.SortFunc(formats, func(a, b youtube.Format) int { return b.Height - a.Height }) - var selected *youtube.Format - for index := range formats { - if strings.Contains(formats[index].MimeType, "avc1") && formats[index].Height <= 1080 { - selected = &formats[index] - break - } - } - if selected == nil { - return errors.New("YouTube video has no AVC rendition at or below 1080p") - } - videoPath := filepath.Join(directory, "source-video.mp4") - if err := downloadYouTubeStream(ctx, client, video, selected, videoPath); err != nil { - return err - } - audioPath := videoPath - if selected.AudioChannels == 0 { - var audio *youtube.Format - for index := range formats { - candidate := &formats[index] - if strings.HasPrefix(candidate.MimeType, "audio/") && (audio == nil || candidate.Bitrate > audio.Bitrate) { - audio = candidate - } - } - if audio == nil { - return errors.New("YouTube video has no audio stream") - } - audioPath = filepath.Join(directory, "source-audio") - if err := downloadYouTubeStream(ctx, client, video, audio, audioPath); err != nil { - return err - } - } - preparedAudio := filepath.Join(directory, "audio.m4a") - if err := prepareMediaAudio(ctx, audioPath, preparedAudio); err != nil { - return err - } - return muxMedia(ctx, videoPath, preparedAudio, filepath.Join(directory, "video.mp4")) -} - -func downloadYouTubeStream(ctx context.Context, client *youtube.Client, video *youtube.Video, format *youtube.Format, destination string) error { - streamURL, err := client.GetStreamURLContext(ctx, video, format) - if err != nil { - return err - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) - if err != nil { - return err - } - request.Header.Set("Origin", "https://youtube.com") - response, err := client.HTTPClient.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return fmt.Errorf("download YouTube media: HTTP %d", response.StatusCode) - } - file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, youtubeFileMode) - if err != nil { - return err - } - _, copyErr := io.Copy(file, response.Body) - return errors.Join(copyErr, file.Close()) -} - -func packageYouTubeVideo(ctx context.Context, directory string) (int64, error) { - source := filepath.Join(directory, "video.mp4") - width, height, err := mediaDimensions(source) - if err != nil { - return 0, err - } - for _, kind := range []string{youtubeVideoKind, youtubeAudioKind} { - root := filepath.Join(directory, "hls", kind) - if err := os.MkdirAll(root, youtubeDirectoryMode); err != nil { - return 0, err - } - mediaType := astiav.MediaTypeVideo - if kind == youtubeAudioKind { - mediaType = astiav.MediaTypeAudio - } - err := packageMediaTrack(ctx, source, root, mediaType) - if err != nil { - return 0, err - } - } - master := fmt.Sprintf("#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"Audio\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio/index.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=%dx%d,AUDIO=\"audio\"\nvideo/index.m3u8\n", width, height) - if err := os.WriteFile(filepath.Join(directory, "hls", "master.m3u8"), []byte(master), youtubeFileMode); err != nil { - return 0, err - } - videoDuration, err := localPlaylistDuration(filepath.Join(directory, "hls", youtubeVideoKind, "index.m3u8")) - if err != nil { - return 0, err - } - audioDuration, err := localPlaylistDuration(filepath.Join(directory, "hls", youtubeAudioKind, "index.m3u8")) - if err != nil { - return 0, err - } - return int64(math.Ceil(min(videoDuration, audioDuration))), nil -} - -func localPlaylistDuration(file string) (float64, error) { - content, err := os.ReadFile(file) - if err != nil { - return 0, err - } - var total float64 - for line := range strings.SplitSeq(string(content), "\n") { - if value, ok := strings.CutPrefix(line, "#EXTINF:"); ok { - seconds, _, _ := strings.Cut(value, ",") - duration, err := strconv.ParseFloat(seconds, 64) - if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 { - return 0, fmt.Errorf("invalid HLS segment duration %q", seconds) - } - total += duration - } - } - if total <= 0 { - return 0, fmt.Errorf("playlist %q has no segments", file) - } - return total, nil -} - -func describeYouTubePackage(directory string) ([]publicapi.PreparedMediaObject, error) { - objects := []publicapi.PreparedMediaObject{} - root, err := os.OpenRoot(directory) - if err != nil { - return nil, err - } - defer root.Close() - err = filepath.WalkDir(directory, func(file string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - name, err := filepath.Rel(directory, file) - if err != nil { - return err - } - name = filepath.ToSlash(name) - if name != "video.mp4" && !strings.HasPrefix(name, "hls/") { - return nil - } - contentType := "video/mp4" - if strings.HasSuffix(name, ".m3u8") { - contentType = "application/vnd.apple.mpegurl" - } else if strings.HasPrefix(name, "hls/audio/") { - contentType = "audio/mp4" - } - source, err := root.Open(name) - if err != nil { - return err - } - hash := sha256.New() - length, err := io.Copy(hash, source) - closeErr := source.Close() - if err := errors.Join(err, closeErr); err != nil { - return err - } - objects = append(objects, publicapi.PreparedMediaObject{Name: name, ContentType: contentType, ByteLength: length, Sha256: hex.EncodeToString(hash.Sum(nil))}) - return nil - }) - return objects, err -} - -//nolint:cyclop // Local download, media processing and upload failures retain explicit recovery boundaries. -func uploadYouTubeObject(ctx context.Context, api *publicapi.Client, client *http.Client, session publicapi.EpisodePackage, ordinal int64, object publicapi.PreparedMediaObject, directory string) error { - file, err := os.Open(filepath.Join(directory, filepath.FromSlash(object.Name))) - if err != nil { - return err - } - defer file.Close() - if session.PartSize < 5<<20 { - return fmt.Errorf("invalid upload part size %d", session.PartSize) - } - for start, number := int64(0), int32(1); start < object.ByteLength; start, number = start+session.PartSize, number+1 { - response, err := api.PresignEpisodePackageParts(ctx, publicapi.PresignEpisodePackagePartsParams{UploadSessionId: session.UploadSessionId, ObjectIndex: ordinal, Body: publicapi.PresignEpisodeUploadSessionParts{PartNumbers: []int32{number}}}) - if err != nil { - return err - } - if response == nil || response.Status200 == nil { - return errors.New("cannot sign media object upload") - } - if len(response.Status200.Parts) == 0 { - return nil - } - if len(response.Status200.Parts) != 1 { - return errors.New("invalid signed part response") - } - part := response.Status200.Parts[0] - length := min(session.PartSize, object.ByteLength-start) - request, err := http.NewRequestWithContext(ctx, http.MethodPut, part.UploadUrl, io.NewSectionReader(file, start, length)) - if err != nil { - return err - } - request.ContentLength = length - uploaded, err := client.Do(request) - if err != nil { - return err - } - _, readErr := io.Copy(io.Discard, io.LimitReader(uploaded.Body, youtubeResponseLimit)) - closeErr := uploaded.Body.Close() - if err := errors.Join(readErr, closeErr); err != nil { - return err - } - if uploaded.StatusCode != http.StatusOK { - return fmt.Errorf("upload media object %q: HTTP %d", object.Name, uploaded.StatusCode) - } - } - return nil -} diff --git a/internal/cliconfig/models.go b/internal/cliconfig/models.go deleted file mode 100644 index d528742..0000000 --- a/internal/cliconfig/models.go +++ /dev/null @@ -1,7 +0,0 @@ -package cliopenapi - -type CLIConfig struct { - ApiOrigin string `json:"api_origin"` - DashboardOrigin string `json:"dashboard_origin"` - PrintTraceIds bool `json:"print_trace_ids"` -} diff --git a/moon.yml b/moon.yml index abf80fd..567f704 100644 --- a/moon.yml +++ b/moon.yml @@ -1,108 +1,99 @@ +language: rust toolchains: default: system fileGroups: sources: - - prepare-ffmpeg.ts - - ffmpeg-env.sh - - go.mod - - go.sum - - ./**/*.go - - ./**/*.yaml + - Cargo.toml + - Cargo.lock + - rust-toolchain.toml + - src/**/*.rs + - prepare-ffmpeg.py + - .cargo/config.toml + - moon.yml tasks: + benchmark-measurer: + description: Build the Linux child-process accounting wrapper for API import benchmarks. + script: |- + set -euo pipefail + mkdir -p .cache + cc -O2 -Wall -Wextra -Werror bench/measure.c -o .cache/cli-measure + inputs: [bench/measure.c] + outputs: [.cache/cli-measure] + options: + runInCI: false native-ffmpeg: - description: Build pinned static FFmpeg libraries for go-astiav. + description: Build pinned FFmpeg 9 libraries for static linking through ffmpeg-the-third. inputs: - - prepare-ffmpeg.ts - command: node prepare-ffmpeg.ts + - prepare-ffmpeg.py + command: python3 prepare-ffmpeg.py outputs: - - .cache/ffmpeg + - .cache/ffmpeg/**/* options: cache: true - build: + prerequisites: deps: - - ~:mod-download - ~:native-ffmpeg + inputs: [] + options: + internal: true + build: + deps: + - ~:prerequisites inputs: - "@group(sources)" script: |- set -euo pipefail - source ffmpeg-env.sh - go build -trimpath -ldflags="-s -w" -o dist/listenbox ./cmd/listenbox + pkgx +llvm.org +cmake.org cargo build --locked + mkdir -p dist + cp target/debug/listenbox dist/listenbox + cp .cache/ffmpeg/NOTICE.txt dist/FFmpeg-NOTICE.txt outputs: - dist/listenbox - mod-download: - inputs: - - go.mod - - go.sum - script: |- - set -euo pipefail - go mod download - options: - cache: true - test: + - dist/FFmpeg-NOTICE.txt + lint: deps: - - ~:mod-download - - ~:native-ffmpeg + - ~:prerequisites inputs: - - moon.yml - - ffmpeg-env.sh - - go.mod - - go.sum - - ./**/*.go - - ./**/*.yaml - - internal/cli/testdata/**/* + - "@group(sources)" script: |- set -euo pipefail - source ffmpeg-env.sh - go test -count=1 -tags=dev -timeout=30s ./... - options: - cache: true - lint: + cargo fmt --all + pkgx +llvm.org +cmake.org cargo clippy --locked --all-targets -- -D warnings + test: deps: - - ~:mod-download - - ~:native-ffmpeg + - ~:prerequisites inputs: - - moon.yml - - ffmpeg-env.sh - - go.mod - - go.sum - - .golangci.yaml - - ./**/*.go - - ./**/*.yaml - - internal/cli/testdata/**/* - script: |- - set -euo pipefail - source ffmpeg-env.sh - if [[ "${CI:-}" == "true" ]]; then - test -z "$(gofmt -l .)" - else - gofmt -w . - fi - go vet -tags=dev ./... - mkdir -p .cache/golangci-lint-tmp - GOLANGCI_LINT_CACHE="${GOLANGCI_LINT_CACHE:-$PWD/.cache/golangci-lint}" \ - TMPDIR="$PWD/.cache/golangci-lint-tmp" \ - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 run --timeout=15m --build-tags=dev ./... - options: - cache: true + - "@group(sources)" + command: pkgx +llvm.org +cmake.org cargo test --locked smoke: - description: Smoke-test the CLI executable. deps: - ~:build + inputs: + - "@group(sources)" command: dist/listenbox help - options: - cache: true + build-release: + deps: [~:prerequisites] + inputs: ["@group(sources)"] + script: |- + set -euo pipefail + pkgx +llvm.org +cmake.org cargo build --locked --release + mkdir -p dist/release + cp target/release/listenbox dist/release/listenbox + cp .cache/ffmpeg/NOTICE.txt dist/release/FFmpeg-NOTICE.txt + outputs: + - dist/release/listenbox + - dist/release/FFmpeg-NOTICE.txt package: - description: Package the CLI with its native media runtime and license notices. deps: - - ~:build - script: |- - cp .cache/ffmpeg/NOTICE.txt dist/ffmpeg-NOTICE.txt - tar -czf dist/listenbox.tar.gz -C dist listenbox ffmpeg-NOTICE.txt -C .. LICENSE + - ~:build-release + inputs: + - "@group(sources)" + - LICENSE + - THIRD-PARTY-NOTICES.txt + script: tar -czf dist/listenbox.tar.gz -C dist/release listenbox FFmpeg-NOTICE.txt -C ../.. LICENSE THIRD-PARTY-NOTICES.txt outputs: - dist/listenbox.tar.gz check: - description: Lint, test, and smoke-test the CLI. deps: - ~:lint - ~:test @@ -111,17 +102,7 @@ tasks: inputs: [] options: runInCI: false - mod-tidy: - options: - runInCI: false - script: |- - set -euo pipefail - go mod tidy outdated: + command: cargo update --dry-run options: runInCI: false - script: >- - set -euo pipefail - - go list -u -m -f '{{if and (not .Main) (not .Indirect) .Update}}{{.Path}} {{.Version}} => - {{.Update.Version}}{{end}}' all diff --git a/prepare-ffmpeg.py b/prepare-ffmpeg.py new file mode 100644 index 0000000..5bd403e --- /dev/null +++ b/prepare-ffmpeg.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Build the pinned static FFmpeg libraries with Python's standard library.""" + +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import urllib.request + + +cache = Path(__file__).resolve().parent / ".cache" +prefix = cache / "ffmpeg" +version = "9.0.2" +checksum = "8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e" +archive = cache / f"ffmpeg-{version}.tar.xz" +source = cache / f"ffmpeg-{version}" +url = f"https://ffmpeg.org/releases/ffmpeg-{version}.tar.xz" + +cache.mkdir(parents=True, exist_ok=True) +with urllib.request.urlopen(url, timeout=30) as response: + data = response.read() +if hashlib.sha256(data).hexdigest() != checksum: + raise ValueError("FFmpeg source checksum mismatch") +archive.write_bytes(data) +with tarfile.open(archive) as compressed: + compressed.extractall(cache, filter="data") +subprocess.run([ + "./configure", f"--prefix={prefix}", + "--disable-everything", "--disable-autodetect", "--disable-programs", + "--disable-doc", "--disable-debug", "--disable-network", "--disable-shared", + "--enable-static", "--enable-pic", "--disable-avdevice", "--disable-avfilter", + "--disable-swscale", "--enable-swresample", "--enable-protocol=file", + "--enable-demuxer=mov,matroska,ogg,aac", "--enable-muxer=ipod,mp4,hls,mpegts", + "--enable-decoder=aac,opus,vorbis,h264", "--enable-encoder=aac", + "--enable-parser=aac,opus,vorbis,h264", "--enable-bsf=aac_adtstoasc,h264_mp4toannexb", + "--disable-x86asm", +], cwd=source, check=True) +subprocess.run(["make", f"-j{os.cpu_count() or 1}"], cwd=source, check=True) +subprocess.run(["make", "install"], cwd=source, check=True) +shutil.copyfile(source / "COPYING.LGPLv2.1", prefix / "COPYING.LGPLv2.1") +(prefix / "NOTICE.txt").write_text( + f"FFmpeg {version}\nSource: {url}\nSHA-256: {checksum}\n" + "Configuration: see prepare-ffmpeg.py\n" + + (source / "COPYING.LGPLv2.1").read_text() +) diff --git a/prepare-ffmpeg.ts b/prepare-ffmpeg.ts deleted file mode 100644 index 2ed17df..0000000 --- a/prepare-ffmpeg.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { spawn } from "node:child_process" -import { createHash } from "node:crypto" -import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises" -import { availableParallelism } from "node:os" -import { join, resolve } from "node:path" - -const cache = resolve(import.meta.dirname, ".cache") -const prefix = join(cache, "ffmpeg") -const version = "9.0.2" -const checksum = - "8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e" -const archive = join(cache, `ffmpeg-${version}.tar.xz`) -const source = join(cache, `ffmpeg-${version}`) - -async function run(command: string, args: string[], cwd: string) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, stdio: "inherit" }) - child.once("error", reject) - child.once("exit", (code, signal) => { - if (code === 0) resolve() - else reject(new Error(`${command} failed: ${signal ?? code}`)) - }) - }) -} - -await mkdir(cache, { recursive: true }) -const response = await fetch( - `https://ffmpeg.org/releases/ffmpeg-${version}.tar.xz`, -) -if (!response.ok) throw new Error(`Download FFmpeg: HTTP ${response.status}`) -const bytes = Buffer.from(await response.arrayBuffer()) -if (createHash("sha256").update(bytes).digest("hex") !== checksum) { - throw new Error("FFmpeg source checksum mismatch") -} -await writeFile(archive, bytes) -await run("tar", ["-xf", archive, "-C", cache], cache) -await run( - "./configure", - [ - `--prefix=${prefix}`, - "--disable-everything", - "--disable-autodetect", - "--disable-programs", - "--disable-doc", - "--disable-debug", - "--disable-network", - "--disable-shared", - "--enable-static", - "--enable-pic", - "--enable-avdevice", - "--enable-avfilter", - "--enable-swscale", - "--enable-swresample", - "--enable-protocol=file", - "--enable-demuxer=mov,matroska,ogg,aac", - "--enable-muxer=ipod,mp4,hls,mpegts", - "--enable-decoder=aac,opus,vorbis,h264", - "--enable-encoder=aac", - "--enable-parser=aac,opus,vorbis,h264", - "--enable-bsf=aac_adtstoasc,h264_mp4toannexb", - "--disable-x86asm", - ], - source, -) -await run("make", [`-j${availableParallelism()}`], source) -await run("make", ["install"], source) -await copyFile( - join(source, "COPYING.LGPLv2.1"), - join(prefix, "COPYING.LGPLv2.1"), -) -await writeFile( - join(prefix, "NOTICE.txt"), - `FFmpeg ${version}\nSource: https://ffmpeg.org/releases/ffmpeg-${version}.tar.xz\nSHA-256: ${checksum}\nConfiguration: see prepare-ffmpeg.ts\n${await readFile(join(source, "COPYING.LGPLv2.1"), "utf8")}`, -) diff --git a/publicapi/client.go b/publicapi/client.go deleted file mode 100644 index 7160d20..0000000 --- a/publicapi/client.go +++ /dev/null @@ -1,4544 +0,0 @@ -package publicapi - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -const ( - maxDecodedBodyBytes = 4 << 20 - maxDiagnosticBodyBytes = 64 << 10 - maxSSEEventBytes = 4 << 20 - defaultResponseTimeout = 30 * time.Second - defaultSSEIdleTimeout = 60 * time.Second - defaultSSEMaxRetries = 5 - defaultSSEReconnectBaseDelay = 200 * time.Millisecond - maxSSERetries = 100 -) - -type HTTPClient interface { - Do(*http.Request) (*http.Response, error) -} - -type HTTPClientFunc func(*http.Request) (*http.Response, error) - -func (f HTTPClientFunc) Do(req *http.Request) (*http.Response, error) { - return f(req) -} - -// RequestEditorFn mutates a request immediately before transport execution. -// The context is the operation context, including its response timeout. -type RequestEditorFn func(context.Context, *http.Request) error - -type Option func(*ClientOptions) - -type ClientOptions struct { - BaseURL string - httpClient HTTPClient - requestEditors []RequestEditorFn - responseTimeout time.Duration - sseIdleTimeout time.Duration - sseMaxRetries int - sseReconnectBaseDelay time.Duration - sseReconnectOnStreamEnd bool -} - -// WithHTTPClient replaces the default http.DefaultClient without mutating a -// caller-owned http.Client. -func WithHTTPClient(client HTTPClient) Option { - return func(options *ClientOptions) { - options.httpClient = client - } -} - -// WithRequestEditorFn appends a client-level request editor. Editors run in -// registration order and must be safe for concurrent use when Client is shared. -func WithRequestEditorFn(editor RequestEditorFn) Option { - return func(options *ClientOptions) { - options.requestEditors = append(options.requestEditors, editor) - } -} - -// WithResponseTimeout bounds response headers and, for ordinary responses, -// reading and decoding the response body. For SSE it only bounds connection -// establishment. Zero disables the timeout. -func WithResponseTimeout(timeout time.Duration) Option { - return func(options *ClientOptions) { - options.responseTimeout = timeout - } -} - -// WithSSEIdleTimeout bounds silence between SSE chunks. Every received chunk, -// including a heartbeat comment, resets it. Zero disables the timeout. -func WithSSEIdleTimeout(timeout time.Duration) Option { - return func(options *ClientOptions) { - options.sseIdleTimeout = timeout - } -} - -// WithSSEMaxRetries bounds consecutive reconnect failures for generated GET -// event streams. Zero disables reconnect attempts; the hard cap is 100. -func WithSSEMaxRetries(retries int) Option { - return func(options *ClientOptions) { - options.sseMaxRetries = retries - } -} - -// WithSSEReconnectBaseDelay configures the first reconnect backoff. -func WithSSEReconnectBaseDelay(delay time.Duration) Option { - return func(options *ClientOptions) { - options.sseReconnectBaseDelay = delay - } -} - -// WithSSEReconnectOnStreamEnd controls whether a clean EOF reconnects a -// generated event stream. It defaults to true for compatibility with streams -// that use EOF as a transient disconnect; terminal-aware proxies can disable it. -func WithSSEReconnectOnStreamEnd(reconnect bool) Option { - return func(options *ClientOptions) { - options.sseReconnectOnStreamEnd = reconnect - } -} - -type Client struct { - baseURL string - httpClient HTTPClient - requestEditors []RequestEditorFn - responseTimeout time.Duration - sseIdleTimeout time.Duration - sseMaxRetries int - sseReconnectBaseDelay time.Duration - sseReconnectOnStreamEnd bool -} - -func NewClient(config ClientOptions, options ...Option) (*Client, error) { - configuredBaseURL := strings.TrimSpace(config.BaseURL) - if configuredBaseURL == "" { - return nil, fmt.Errorf("base URL must not be empty: %q", config.BaseURL) - } - parsed, err := url.Parse(configuredBaseURL) - if err != nil { - return nil, fmt.Errorf("parse base URL %q: %w", config.BaseURL, err) - } - if parsed.Scheme == "" || parsed.Host == "" { - return nil, fmt.Errorf("base URL %q must include scheme and host", config.BaseURL) - } - config.httpClient = http.DefaultClient - config.responseTimeout = defaultResponseTimeout - config.sseIdleTimeout = defaultSSEIdleTimeout - config.sseMaxRetries = defaultSSEMaxRetries - config.sseReconnectBaseDelay = defaultSSEReconnectBaseDelay - config.sseReconnectOnStreamEnd = true - for _, option := range options { - if option == nil { - return nil, fmt.Errorf("client option must not be nil") - } - option(&config) - } - if config.httpClient == nil { - return nil, fmt.Errorf("HTTP client must not be nil") - } - if config.responseTimeout < 0 { - return nil, fmt.Errorf("response timeout must not be negative") - } - if config.sseIdleTimeout < 0 { - return nil, fmt.Errorf("SSE idle timeout must not be negative") - } - if config.sseMaxRetries < 0 || config.sseMaxRetries > maxSSERetries { - return nil, fmt.Errorf("SSE max retries must be between 0 and %d", maxSSERetries) - } - if config.sseReconnectBaseDelay < 0 { - return nil, fmt.Errorf("SSE reconnect base delay must not be negative") - } - for index, editor := range config.requestEditors { - if editor == nil { - return nil, fmt.Errorf("request editor at index %d must not be nil", index) - } - } - return &Client{ - baseURL: strings.TrimRight(parsed.String(), "/"), - httpClient: config.httpClient, - requestEditors: append([]RequestEditorFn(nil), config.requestEditors...), - responseTimeout: config.responseTimeout, - sseIdleTimeout: config.sseIdleTimeout, - sseMaxRetries: config.sseMaxRetries, - sseReconnectBaseDelay: config.sseReconnectBaseDelay, - sseReconnectOnStreamEnd: config.sseReconnectOnStreamEnd, - }, nil -} - -func (c *Client) do(ctx context.Context, request *http.Request) (*http.Response, error) { - for index, editor := range c.requestEditors { - if err := editor(ctx, request); err != nil { - return nil, fmt.Errorf("request editor at index %d: %w", index, err) - } - } - // Keep caller cancellation and response timeout authoritative even if an - // editor replaces the request value or its context. - request = request.Clone(ctx) - return c.httpClient.Do(request) -} - -type ResponseTimeoutError struct { - Duration time.Duration -} - -func (err *ResponseTimeoutError) Error() string { - return fmt.Sprintf("response timed out after %s", err.Duration) -} - -func (err *ResponseTimeoutError) Timeout() bool { return true } - -type responseLifecycle struct { - cancel context.CancelCauseFunc - timer *time.Timer - closeOnce sync.Once -} - -func (c *Client) responseContext(parent context.Context) (context.Context, *responseLifecycle) { - ctx, cancel := context.WithCancelCause(parent) - lifecycle := &responseLifecycle{cancel: cancel} - if c.responseTimeout > 0 { - lifecycle.timer = time.AfterFunc(c.responseTimeout, func() { - cancel(&ResponseTimeoutError{Duration: c.responseTimeout}) - }) - } - return ctx, lifecycle -} - -func (lifecycle *responseLifecycle) stopTimeout() { - if lifecycle.timer != nil { - lifecycle.timer.Stop() - } -} - -func (lifecycle *responseLifecycle) close() { - lifecycle.closeOnce.Do(func() { - lifecycle.stopTimeout() - lifecycle.cancel(context.Canceled) - }) -} - -type UnexpectedStatusError struct { - Method string - URL string - StatusCode int - Body string -} - -func (err *UnexpectedStatusError) Error() string { - if err.Body == "" { - return fmt.Sprintf("%s %s returned unexpected HTTP status %d", err.Method, err.URL, err.StatusCode) - } - return fmt.Sprintf("%s %s returned unexpected HTTP status %d: %s", err.Method, err.URL, err.StatusCode, err.Body) -} - -type sseAttempt struct { - response *http.Response - lifecycle *responseLifecycle -} - -func retryableSSEStatus(status int) bool { - return status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout -} - -func sseRetryDelay(base time.Duration, failure int, serverDelay *time.Duration) time.Duration { - if serverDelay != nil { - return *serverDelay - } - scale := 1.0 - for index := 1; index < failure && scale < 1<<20; index++ { - scale *= 2 - } - return time.Duration(float64(base) * scale * (0.9 + rand.Float64()*0.2)) -} - -func waitForSSERetry(ctx context.Context, delay time.Duration) error { - if err := sseContextError(ctx); err != nil { - return err - } - timer := time.NewTimer(maxDuration(delay, 0)) - defer timer.Stop() - select { - case <-ctx.Done(): - return sseContextError(ctx) - case <-timer.C: - return nil - } -} - -func sseContextError(ctx context.Context) error { - if cause := context.Cause(ctx); cause != nil { - return cause - } - return ctx.Err() -} - -func maxDuration(value time.Duration, minimum time.Duration) time.Duration { - if value < minimum { - return minimum - } - return value -} - -func (c *Client) openReconnectingSSE( - ctx context.Context, - open func(context.Context) (*http.Response, *responseLifecycle, error), -) (*sseAttempt, error) { - failures := 0 - for { - if err := sseContextError(ctx); err != nil { - return nil, err - } - response, lifecycle, err := open(ctx) - if err != nil { - if ctx.Err() != nil { - return nil, sseContextError(ctx) - } - if failures >= c.sseMaxRetries { - return nil, err - } - failures++ - if err := waitForSSERetry(ctx, sseRetryDelay(c.sseReconnectBaseDelay, failures, nil)); err != nil { - return nil, err - } - continue - } - attempt := &sseAttempt{response: response, lifecycle: lifecycle} - if !retryableSSEStatus(response.StatusCode) { - return attempt, nil - } - if failures >= c.sseMaxRetries { - return attempt, nil - } - _ = response.Body.Close() - lifecycle.close() - failures++ - if err := waitForSSERetry(ctx, sseRetryDelay(c.sseReconnectBaseDelay, failures, nil)); err != nil { - return nil, err - } - } -} - -type reconnectingSSEBody struct { - ctx context.Context - open func(context.Context) (*http.Response, *responseLifecycle, error) - maxRetries int - baseDelay time.Duration - idleTimeout time.Duration - reconnectOnStreamEnd bool - - mu sync.Mutex - current io.ReadCloser - currentLifecycle *responseLifecycle - closed bool - terminalErr error - failures int - serverDelay *time.Duration - pending []byte - ready [][]byte -} - -func newReconnectingSSEBody( - attempt *sseAttempt, - ctx context.Context, - open func(context.Context) (*http.Response, *responseLifecycle, error), - maxRetries int, - baseDelay time.Duration, - idleTimeout time.Duration, - reconnectOnStreamEnd bool, -) io.ReadCloser { - return &reconnectingSSEBody{ - ctx: ctx, - open: open, - maxRetries: maxRetries, - baseDelay: baseDelay, - idleTimeout: idleTimeout, - reconnectOnStreamEnd: reconnectOnStreamEnd, - current: newSSEIdleBody(attempt.response.Body, idleTimeout), - currentLifecycle: attempt.lifecycle, - } -} - -func newSSEIdleBody(body io.ReadCloser, timeout time.Duration) io.ReadCloser { - if body == nil { - body = http.NoBody - } - return newIdleTimeoutBody(body, timeout, nil) -} - -func (body *reconnectingSSEBody) Read(buffer []byte) (int, error) { - if len(buffer) == 0 { - return 0, nil - } - for { - if read := body.popReady(buffer); read > 0 { - return read, nil - } - body.mu.Lock() - if body.closed { - err := body.terminalErr - body.mu.Unlock() - if err == nil { - return 0, io.EOF - } - return 0, err - } - current := body.current - body.mu.Unlock() - if err := sseContextError(body.ctx); err != nil { - body.fail(err) - return 0, err - } - if current == nil { - err := fmt.Errorf("SSE response has no readable body") - body.fail(err) - return 0, err - } - chunk := make([]byte, 32<<10) - read, err := current.Read(chunk) - if read > 0 { - body.push(chunk[:read]) - if ready := body.popReady(buffer); ready > 0 { - return ready, nil - } - } - if err == nil { - continue - } - if err == io.EOF && !body.reconnectOnStreamEnd { - _ = body.Close() - return 0, io.EOF - } - if reconnectErr := body.reconnect(err); reconnectErr != nil { - body.fail(reconnectErr) - return 0, reconnectErr - } - } -} - -func (body *reconnectingSSEBody) push(chunk []byte) { - body.mu.Lock() - defer body.mu.Unlock() - body.pending = append(body.pending, chunk...) - for { - end := sseFrameEnd(body.pending) - if end < 0 { - if len(body.pending) > maxSSEEventBytes { - body.terminalErr = fmt.Errorf("SSE event exceeds %d bytes", maxSSEEventBytes) - } - return - } - frame := append([]byte(nil), body.pending[:end]...) - body.pending = append([]byte(nil), body.pending[end:]...) - if retry, ok := sseRetryValue(frame); ok { - body.serverDelay = &retry - } - body.failures = 0 - body.ready = append(body.ready, frame) - } -} - -func (body *reconnectingSSEBody) popReady(buffer []byte) int { - body.mu.Lock() - defer body.mu.Unlock() - if len(body.ready) == 0 { - return 0 - } - frame := body.ready[0] - read := copy(buffer, frame) - if read == len(frame) { - body.ready = body.ready[1:] - } else { - body.ready[0] = frame[read:] - } - return read -} - -func (body *reconnectingSSEBody) reconnect(lastErr error) error { - for { - body.mu.Lock() - if body.closed { - err := body.terminalErr - body.mu.Unlock() - if err == nil { - return io.ErrClosedPipe - } - return err - } - if body.failures >= body.maxRetries { - body.mu.Unlock() - return lastErr - } - body.failures++ - failure := body.failures - serverDelay := body.serverDelay - body.serverDelay = nil - current := body.current - lifecycle := body.currentLifecycle - body.current = nil - body.currentLifecycle = nil - body.pending = nil - body.mu.Unlock() - if current != nil { - _ = current.Close() - } - if lifecycle != nil { - lifecycle.close() - } - if err := waitForSSERetry(body.ctx, sseRetryDelay(body.baseDelay, failure, serverDelay)); err != nil { - return err - } - response, nextLifecycle, err := body.open(body.ctx) - if err != nil { - lastErr = err - continue - } - if retryableSSEStatus(response.StatusCode) { - _ = response.Body.Close() - nextLifecycle.close() - lastErr = fmt.Errorf("SSE reconnect returned HTTP %d", response.StatusCode) - body.mu.Lock() - if body.failures >= body.maxRetries { - body.mu.Unlock() - return lastErr - } - body.failures++ - failure = body.failures - body.mu.Unlock() - if err := waitForSSERetry(body.ctx, sseRetryDelay(body.baseDelay, failure, nil)); err != nil { - return err - } - continue - } - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - _ = response.Body.Close() - nextLifecycle.close() - return &UnexpectedStatusError{ - Method: response.Request.Method, - URL: response.Request.URL.String(), - StatusCode: response.StatusCode, - } - } - nextBody := newSSEIdleBody(response.Body, body.idleTimeout) - body.mu.Lock() - if body.closed { - body.mu.Unlock() - _ = nextBody.Close() - nextLifecycle.close() - return io.ErrClosedPipe - } - body.current = nextBody - body.currentLifecycle = nextLifecycle - body.mu.Unlock() - return nil - } -} - -func (body *reconnectingSSEBody) fail(err error) { - body.mu.Lock() - if !body.closed { - body.terminalErr = err - body.closed = true - } - body.mu.Unlock() -} - -func (body *reconnectingSSEBody) Close() error { - body.mu.Lock() - if body.closed { - err := body.terminalErr - body.mu.Unlock() - return err - } - body.closed = true - current := body.current - lifecycle := body.currentLifecycle - body.current = nil - body.currentLifecycle = nil - body.mu.Unlock() - if current != nil { - _ = current.Close() - } - if lifecycle != nil { - lifecycle.close() - } - return nil -} - -func sseFrameEnd(bytes []byte) int { - for index := 1; index < len(bytes); index++ { - if bytes[index] == '\n' && (bytes[index-1] == '\n' || (bytes[index-1] == '\r' && index >= 2 && bytes[index-2] == '\n')) { - return index + 1 - } - } - return -1 -} - -func sseRetryValue(frame []byte) (time.Duration, bool) { - for _, line := range strings.Split(strings.ReplaceAll(string(frame), "\r\n", "\n"), "\n") { - if !strings.HasPrefix(line, "retry:") { - continue - } - var millis int64 - if _, err := fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(line, "retry:")), "%d", &millis); err == nil && millis >= 0 { - return time.Duration(millis) * time.Millisecond, true - } - } - return 0, false -} - -type OpenapiResponse struct { - StatusCode int - Raw *http.Response - Status200 *OpenAPIJSONDocument - Status400 *ValidationErr -} - -func (c *Client) NewOpenapiRequest(ctx context.Context) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build Openapi request: context must not be nil") - } - path := "/" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build Openapi URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build Openapi request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) Openapi(ctx context.Context) (*OpenapiResponse, error) { - - req, err := c.NewOpenapiRequest(ctx) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute Openapi request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute Openapi request: HTTP client returned nil response") - } - - result := &OpenapiResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded OpenAPIJSONDocument - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode Openapi status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode Openapi status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 304: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected Openapi response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type HeadOpenapiResponse struct { - StatusCode int - Raw *http.Response -} - -func (c *Client) NewHeadOpenapiRequest(ctx context.Context) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build HeadOpenapi request: context must not be nil") - } - path := "/" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build HeadOpenapi URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "HEAD", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build HeadOpenapi request: %w", err) - } - req.Header.Set("Accept", "*/*") - return req, nil -} - -func (c *Client) HeadOpenapi(ctx context.Context) (*HeadOpenapiResponse, error) { - - req, err := c.NewHeadOpenapiRequest(ctx) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute HeadOpenapi request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute HeadOpenapi request: HTTP client returned nil response") - } - - result := &HeadOpenapiResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200, 304, 400: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected HeadOpenapi response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateCLIAuthorizationParams struct { - Body CreateCLIAuthorization -} - -type CreateCLIAuthorizationResponse struct { - StatusCode int - Raw *http.Response - Status201 *CreatedCLIAuthorization - Status400 *ValidationErr -} - -func (c *Client) NewCreateCLIAuthorizationRequest(ctx context.Context, params CreateCLIAuthorizationParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateCLIAuthorization request: context must not be nil") - } - path := "/cli/authorizations" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateCLIAuthorization URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateCLIAuthorization JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateCLIAuthorization request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateCLIAuthorization(ctx context.Context, params CreateCLIAuthorizationParams) (*CreateCLIAuthorizationResponse, error) { - - req, err := c.NewCreateCLIAuthorizationRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateCLIAuthorization request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateCLIAuthorization request: HTTP client returned nil response") - } - - result := &CreateCLIAuthorizationResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded CreatedCLIAuthorization - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateCLIAuthorization status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateCLIAuthorization status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateCLIAuthorization response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CliAuthorizationEventsParams struct { - Code CLIAuthorizationCode -} - -type CliAuthorizationEventsResponse struct { - StatusCode int - Raw *http.Response - Status200 *SSEStream[CLIAuthorizationEvent] - Status400 *ValidationErr -} - -func (c *Client) NewCliAuthorizationEventsRequest(ctx context.Context, params CliAuthorizationEventsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CliAuthorizationEvents request: context must not be nil") - } - if params.Code == "" { - return nil, fmt.Errorf("build CliAuthorizationEvents request: required parameter code is empty") - } - path := "/cli/authorizations/{code}/events" - - path = strings.ReplaceAll(path, "{code}", url.PathEscape(fmt.Sprint(params.Code))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CliAuthorizationEvents URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CliAuthorizationEvents request: %w", err) - } - req.Header.Set("Accept", "text/event-stream") - return req, nil -} - -func (c *Client) CliAuthorizationEvents(ctx context.Context, params CliAuthorizationEventsParams) (*CliAuthorizationEventsResponse, error) { - - var requestForError *http.Request - open := func(attemptCtx context.Context) (*http.Response, *responseLifecycle, error) { - req, err := c.NewCliAuthorizationEventsRequest(attemptCtx, params) - if err != nil { - return nil, nil, err - } - requestForError = req - responseCtx, lifecycle := c.responseContext(attemptCtx) - req = req.Clone(responseCtx) - res, err := c.do(responseCtx, req) - if err != nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute CliAuthorizationEvents request: %w", err) - } - if res == nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute CliAuthorizationEvents request: HTTP client returned nil response") - } - if res.Request == nil { - res.Request = req - } - return res, lifecycle, nil - } - attempt, err := c.openReconnectingSSE(ctx, open) - if err != nil { - return nil, err - } - res := attempt.response - lifecycle := attempt.lifecycle - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - - result := &CliAuthorizationEventsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - lifecycle.stopTimeout() - keepLifecycle = true - body := newReconnectingSSEBody( - attempt, - ctx, - open, - c.sseMaxRetries, - c.sseReconnectBaseDelay, - c.sseIdleTimeout, - c.sseReconnectOnStreamEnd, - ) - res.Body = body - result.Status200 = newReconnectingSSEStream[CLIAuthorizationEvent](body, lifecycle.close) - - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CliAuthorizationEvents status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CliAuthorizationEvents response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: requestForError.Method, - URL: requestForError.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type EpisodeDeletionEventsParams struct { - EpisodeDeletionRunId EpisodeDeletionRunID -} - -type EpisodeDeletionEventsResponse struct { - StatusCode int - Raw *http.Response - Status200 *SSEStream[EpisodeDeletionEvent] - Status400 *ValidationErr -} - -func (c *Client) NewEpisodeDeletionEventsRequest(ctx context.Context, params EpisodeDeletionEventsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build EpisodeDeletionEvents request: context must not be nil") - } - if params.EpisodeDeletionRunId == "" { - return nil, fmt.Errorf("build EpisodeDeletionEvents request: required parameter episode_deletion_run_id is empty") - } - path := "/s/episode-deletions/{episode_deletion_run_id}/events" - - path = strings.ReplaceAll(path, "{episode_deletion_run_id}", url.PathEscape(fmt.Sprint(params.EpisodeDeletionRunId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build EpisodeDeletionEvents URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build EpisodeDeletionEvents request: %w", err) - } - req.Header.Set("Accept", "text/event-stream") - return req, nil -} - -func (c *Client) EpisodeDeletionEvents(ctx context.Context, params EpisodeDeletionEventsParams) (*EpisodeDeletionEventsResponse, error) { - - var requestForError *http.Request - open := func(attemptCtx context.Context) (*http.Response, *responseLifecycle, error) { - req, err := c.NewEpisodeDeletionEventsRequest(attemptCtx, params) - if err != nil { - return nil, nil, err - } - requestForError = req - responseCtx, lifecycle := c.responseContext(attemptCtx) - req = req.Clone(responseCtx) - res, err := c.do(responseCtx, req) - if err != nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute EpisodeDeletionEvents request: %w", err) - } - if res == nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute EpisodeDeletionEvents request: HTTP client returned nil response") - } - if res.Request == nil { - res.Request = req - } - return res, lifecycle, nil - } - attempt, err := c.openReconnectingSSE(ctx, open) - if err != nil { - return nil, err - } - res := attempt.response - lifecycle := attempt.lifecycle - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - - result := &EpisodeDeletionEventsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - lifecycle.stopTimeout() - keepLifecycle = true - body := newReconnectingSSEBody( - attempt, - ctx, - open, - c.sseMaxRetries, - c.sseReconnectBaseDelay, - c.sseIdleTimeout, - c.sseReconnectOnStreamEnd, - ) - res.Body = body - result.Status200 = newReconnectingSSEStream[EpisodeDeletionEvent](body, lifecycle.close) - - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode EpisodeDeletionEvents status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected EpisodeDeletionEvents response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: requestForError.Method, - URL: requestForError.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateEpisodePackageParams struct { - Body CreateEpisodePackage -} - -type CreateEpisodePackageResponse struct { - StatusCode int - Raw *http.Response - Status201 *EpisodePackage - Status400 *ValidationErr - Status402 *VideoAdmissionError -} - -func (c *Client) NewCreateEpisodePackageRequest(ctx context.Context, params CreateEpisodePackageParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateEpisodePackage request: context must not be nil") - } - path := "/s/episode-packages" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodePackage URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateEpisodePackage JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodePackage request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateEpisodePackage(ctx context.Context, params CreateEpisodePackageParams) (*CreateEpisodePackageResponse, error) { - - req, err := c.NewCreateEpisodePackageRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateEpisodePackage request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateEpisodePackage request: HTTP client returned nil response") - } - - result := &CreateEpisodePackageResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded EpisodePackage - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodePackage status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodePackage status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 402: - var decoded VideoAdmissionError - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodePackage status 402 response: %w", err) - } - _ = res.Body.Close() - result.Status402 = &decoded - return result, nil - case 401, 404, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateEpisodePackage response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CancelEpisodePackageParams struct { - UploadSessionId UploadSessionID -} - -type CancelEpisodePackageResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewCancelEpisodePackageRequest(ctx context.Context, params CancelEpisodePackageParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CancelEpisodePackage request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build CancelEpisodePackage request: required parameter upload_session_id is empty") - } - path := "/s/episode-packages/{upload_session_id}" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CancelEpisodePackage URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CancelEpisodePackage request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) CancelEpisodePackage(ctx context.Context, params CancelEpisodePackageParams) (*CancelEpisodePackageResponse, error) { - - req, err := c.NewCancelEpisodePackageRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CancelEpisodePackage request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CancelEpisodePackage request: HTTP client returned nil response") - } - - result := &CancelEpisodePackageResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CancelEpisodePackage status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CancelEpisodePackage response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CompleteEpisodePackageParams struct { - UploadSessionId UploadSessionID -} - -type CompleteEpisodePackageResponse struct { - StatusCode int - Raw *http.Response - Status200 *EpisodePackage - Status400 *ValidationErr -} - -func (c *Client) NewCompleteEpisodePackageRequest(ctx context.Context, params CompleteEpisodePackageParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CompleteEpisodePackage request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build CompleteEpisodePackage request: required parameter upload_session_id is empty") - } - path := "/s/episode-packages/{upload_session_id}/complete" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CompleteEpisodePackage URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CompleteEpisodePackage request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) CompleteEpisodePackage(ctx context.Context, params CompleteEpisodePackageParams) (*CompleteEpisodePackageResponse, error) { - - req, err := c.NewCompleteEpisodePackageRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CompleteEpisodePackage request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CompleteEpisodePackage request: HTTP client returned nil response") - } - - result := &CompleteEpisodePackageResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded EpisodePackage - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteEpisodePackage status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteEpisodePackage status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CompleteEpisodePackage response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type PresignEpisodePackagePartsParams struct { - UploadSessionId UploadSessionID - ObjectIndex int64 - Body PresignEpisodeUploadSessionParts -} - -type PresignEpisodePackagePartsResponse struct { - StatusCode int - Raw *http.Response - Status200 *PresignedEpisodeUploadParts - Status400 *ValidationErr -} - -func (c *Client) NewPresignEpisodePackagePartsRequest(ctx context.Context, params PresignEpisodePackagePartsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build PresignEpisodePackageParts request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build PresignEpisodePackageParts request: required parameter upload_session_id is empty") - } - path := "/s/episode-packages/{upload_session_id}/objects/{object_index}/parts" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - path = strings.ReplaceAll(path, "{object_index}", url.PathEscape(fmt.Sprint(params.ObjectIndex))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build PresignEpisodePackageParts URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode PresignEpisodePackageParts JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build PresignEpisodePackageParts request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) PresignEpisodePackageParts(ctx context.Context, params PresignEpisodePackagePartsParams) (*PresignEpisodePackagePartsResponse, error) { - - req, err := c.NewPresignEpisodePackagePartsRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute PresignEpisodePackageParts request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute PresignEpisodePackageParts request: HTTP client returned nil response") - } - - result := &PresignEpisodePackagePartsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded PresignedEpisodeUploadParts - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode PresignEpisodePackageParts status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode PresignEpisodePackageParts status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected PresignEpisodePackageParts response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateEpisodeUploadSessionParams struct { - Body CreateEpisodeUploadSession -} - -type CreateEpisodeUploadSessionResponse struct { - StatusCode int - Raw *http.Response - Status200 *EpisodeUploadSession - Status201 *EpisodeUploadSession - Status400 *ValidationErr -} - -func (c *Client) NewCreateEpisodeUploadSessionRequest(ctx context.Context, params CreateEpisodeUploadSessionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateEpisodeUploadSession request: context must not be nil") - } - path := "/s/episode-upload-sessions" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodeUploadSession URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateEpisodeUploadSession JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodeUploadSession request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateEpisodeUploadSession(ctx context.Context, params CreateEpisodeUploadSessionParams) (*CreateEpisodeUploadSessionResponse, error) { - - req, err := c.NewCreateEpisodeUploadSessionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateEpisodeUploadSession request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateEpisodeUploadSession request: HTTP client returned nil response") - } - - result := &CreateEpisodeUploadSessionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded EpisodeUploadSession - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodeUploadSession status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 201: - var decoded EpisodeUploadSession - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodeUploadSession status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodeUploadSession status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404, 409, 410: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateEpisodeUploadSession response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type GetEpisodeUploadSessionParams struct { - UploadSessionId UploadSessionID -} - -type GetEpisodeUploadSessionResponse struct { - StatusCode int - Raw *http.Response - Status200 *EpisodeUploadSession - Status400 *ValidationErr -} - -func (c *Client) NewGetEpisodeUploadSessionRequest(ctx context.Context, params GetEpisodeUploadSessionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build GetEpisodeUploadSession request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build GetEpisodeUploadSession request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build GetEpisodeUploadSession URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build GetEpisodeUploadSession request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) GetEpisodeUploadSession(ctx context.Context, params GetEpisodeUploadSessionParams) (*GetEpisodeUploadSessionResponse, error) { - - req, err := c.NewGetEpisodeUploadSessionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute GetEpisodeUploadSession request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute GetEpisodeUploadSession request: HTTP client returned nil response") - } - - result := &GetEpisodeUploadSessionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded EpisodeUploadSession - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode GetEpisodeUploadSession status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode GetEpisodeUploadSession status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 404, 410: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected GetEpisodeUploadSession response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type UpdateEpisodeUploadSessionParams struct { - UploadSessionId UploadSessionID - Body PatchEpisodeUploadSession -} - -type UpdateEpisodeUploadSessionResponse struct { - StatusCode int - Raw *http.Response - Status200 *EpisodeUploadSession - Status400 *ValidationErr -} - -func (c *Client) NewUpdateEpisodeUploadSessionRequest(ctx context.Context, params UpdateEpisodeUploadSessionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build UpdateEpisodeUploadSession request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build UpdateEpisodeUploadSession request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build UpdateEpisodeUploadSession URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode UpdateEpisodeUploadSession JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "PUT", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build UpdateEpisodeUploadSession request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) UpdateEpisodeUploadSession(ctx context.Context, params UpdateEpisodeUploadSessionParams) (*UpdateEpisodeUploadSessionResponse, error) { - - req, err := c.NewUpdateEpisodeUploadSessionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute UpdateEpisodeUploadSession request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute UpdateEpisodeUploadSession request: HTTP client returned nil response") - } - - result := &UpdateEpisodeUploadSessionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded EpisodeUploadSession - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateEpisodeUploadSession status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateEpisodeUploadSession status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 404, 409, 410: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected UpdateEpisodeUploadSession response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CancelEpisodeUploadSessionParams struct { - UploadSessionId UploadSessionID -} - -type CancelEpisodeUploadSessionResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewCancelEpisodeUploadSessionRequest(ctx context.Context, params CancelEpisodeUploadSessionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CancelEpisodeUploadSession request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build CancelEpisodeUploadSession request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CancelEpisodeUploadSession URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CancelEpisodeUploadSession request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) CancelEpisodeUploadSession(ctx context.Context, params CancelEpisodeUploadSessionParams) (*CancelEpisodeUploadSessionResponse, error) { - - req, err := c.NewCancelEpisodeUploadSessionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CancelEpisodeUploadSession request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CancelEpisodeUploadSession request: HTTP client returned nil response") - } - - result := &CancelEpisodeUploadSessionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CancelEpisodeUploadSession status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 404, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CancelEpisodeUploadSession response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CompleteEpisodeUploadSessionParams struct { - UploadSessionId UploadSessionID - Body CompleteEpisodeUploadSession -} - -type CompleteEpisodeUploadSessionResponse struct { - StatusCode int - Raw *http.Response - Status200 *Episode - Status201 *Episode - Status400 *ValidationErr -} - -func (c *Client) NewCompleteEpisodeUploadSessionRequest(ctx context.Context, params CompleteEpisodeUploadSessionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CompleteEpisodeUploadSession request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build CompleteEpisodeUploadSession request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}/complete" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CompleteEpisodeUploadSession URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CompleteEpisodeUploadSession JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CompleteEpisodeUploadSession request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CompleteEpisodeUploadSession(ctx context.Context, params CompleteEpisodeUploadSessionParams) (*CompleteEpisodeUploadSessionResponse, error) { - - req, err := c.NewCompleteEpisodeUploadSessionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CompleteEpisodeUploadSession request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CompleteEpisodeUploadSession request: HTTP client returned nil response") - } - - result := &CompleteEpisodeUploadSessionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded Episode - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteEpisodeUploadSession status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 201: - var decoded Episode - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteEpisodeUploadSession status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteEpisodeUploadSession status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404, 409, 410: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CompleteEpisodeUploadSession response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type EpisodeUploadSessionEventsParams struct { - UploadSessionId UploadSessionID -} - -type EpisodeUploadSessionEventsResponse struct { - StatusCode int - Raw *http.Response - Status200 *SSEStream[EpisodeProcessingEvent] - Status400 *ValidationErr -} - -func (c *Client) NewEpisodeUploadSessionEventsRequest(ctx context.Context, params EpisodeUploadSessionEventsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build EpisodeUploadSessionEvents request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build EpisodeUploadSessionEvents request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}/events" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build EpisodeUploadSessionEvents URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build EpisodeUploadSessionEvents request: %w", err) - } - req.Header.Set("Accept", "text/event-stream") - return req, nil -} - -func (c *Client) EpisodeUploadSessionEvents(ctx context.Context, params EpisodeUploadSessionEventsParams) (*EpisodeUploadSessionEventsResponse, error) { - - var requestForError *http.Request - open := func(attemptCtx context.Context) (*http.Response, *responseLifecycle, error) { - req, err := c.NewEpisodeUploadSessionEventsRequest(attemptCtx, params) - if err != nil { - return nil, nil, err - } - requestForError = req - responseCtx, lifecycle := c.responseContext(attemptCtx) - req = req.Clone(responseCtx) - res, err := c.do(responseCtx, req) - if err != nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute EpisodeUploadSessionEvents request: %w", err) - } - if res == nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute EpisodeUploadSessionEvents request: HTTP client returned nil response") - } - if res.Request == nil { - res.Request = req - } - return res, lifecycle, nil - } - attempt, err := c.openReconnectingSSE(ctx, open) - if err != nil { - return nil, err - } - res := attempt.response - lifecycle := attempt.lifecycle - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - - result := &EpisodeUploadSessionEventsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - lifecycle.stopTimeout() - keepLifecycle = true - body := newReconnectingSSEBody( - attempt, - ctx, - open, - c.sseMaxRetries, - c.sseReconnectBaseDelay, - c.sseIdleTimeout, - c.sseReconnectOnStreamEnd, - ) - res.Body = body - result.Status200 = newReconnectingSSEStream[EpisodeProcessingEvent](body, lifecycle.close) - - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode EpisodeUploadSessionEvents status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected EpisodeUploadSessionEvents response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: requestForError.Method, - URL: requestForError.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type PresignEpisodeUploadSessionPartsParams struct { - UploadSessionId UploadSessionID - Body PresignEpisodeUploadSessionParts -} - -type PresignEpisodeUploadSessionPartsResponse struct { - StatusCode int - Raw *http.Response - Status201 *PresignedEpisodeUploadParts - Status400 *ValidationErr -} - -func (c *Client) NewPresignEpisodeUploadSessionPartsRequest(ctx context.Context, params PresignEpisodeUploadSessionPartsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build PresignEpisodeUploadSessionParts request: context must not be nil") - } - if params.UploadSessionId == "" { - return nil, fmt.Errorf("build PresignEpisodeUploadSessionParts request: required parameter upload_session_id is empty") - } - path := "/s/episode-upload-sessions/{upload_session_id}/parts/presign" - - path = strings.ReplaceAll(path, "{upload_session_id}", url.PathEscape(fmt.Sprint(params.UploadSessionId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build PresignEpisodeUploadSessionParts URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode PresignEpisodeUploadSessionParts JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build PresignEpisodeUploadSessionParts request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) PresignEpisodeUploadSessionParts(ctx context.Context, params PresignEpisodeUploadSessionPartsParams) (*PresignEpisodeUploadSessionPartsResponse, error) { - - req, err := c.NewPresignEpisodeUploadSessionPartsRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute PresignEpisodeUploadSessionParts request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute PresignEpisodeUploadSessionParts request: HTTP client returned nil response") - } - - result := &PresignEpisodeUploadSessionPartsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded PresignedEpisodeUploadParts - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode PresignEpisodeUploadSessionParts status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode PresignEpisodeUploadSessionParts status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 402, 404, 409, 410: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected PresignEpisodeUploadSessionParts response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateEpisodeDeletionParams struct { - EpisodeId EpisodeID -} - -type CreateEpisodeDeletionResponse struct { - StatusCode int - Raw *http.Response - Status202 *CreatedPublicEpisodeDeletion - Status400 *ValidationErr -} - -func (c *Client) NewCreateEpisodeDeletionRequest(ctx context.Context, params CreateEpisodeDeletionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateEpisodeDeletion request: context must not be nil") - } - if params.EpisodeId == "" { - return nil, fmt.Errorf("build CreateEpisodeDeletion request: required parameter episode_id is empty") - } - path := "/s/episodes/{episode_id}/deletions" - - path = strings.ReplaceAll(path, "{episode_id}", url.PathEscape(fmt.Sprint(params.EpisodeId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodeDeletion URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CreateEpisodeDeletion request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) CreateEpisodeDeletion(ctx context.Context, params CreateEpisodeDeletionParams) (*CreateEpisodeDeletionResponse, error) { - - req, err := c.NewCreateEpisodeDeletionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateEpisodeDeletion request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateEpisodeDeletion request: HTTP client returned nil response") - } - - result := &CreateEpisodeDeletionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 202: - var decoded CreatedPublicEpisodeDeletion - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodeDeletion status 202 response: %w", err) - } - _ = res.Body.Close() - result.Status202 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateEpisodeDeletion status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateEpisodeDeletion response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateImageUploadPresignParams struct { - Body CreateImageUploadPresign -} - -type CreateImageUploadPresignResponse struct { - StatusCode int - Raw *http.Response - Status201 *CreatedImageUploadPresign -} - -func (c *Client) NewCreateImageUploadPresignRequest(ctx context.Context, params CreateImageUploadPresignParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateImageUploadPresign request: context must not be nil") - } - path := "/s/image-uploads/presign" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateImageUploadPresign URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateImageUploadPresign JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateImageUploadPresign request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateImageUploadPresign(ctx context.Context, params CreateImageUploadPresignParams) (*CreateImageUploadPresignResponse, error) { - - req, err := c.NewCreateImageUploadPresignRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateImageUploadPresign request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateImageUploadPresign request: HTTP client returned nil response") - } - - result := &CreateImageUploadPresignResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded CreatedImageUploadPresign - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateImageUploadPresign status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400, 401, 402, 403, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateImageUploadPresign response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CompleteImageUploadParams struct { - ImageAssetId ImageAssetID - Body CompleteImageUpload -} - -type CompleteImageUploadResponse struct { - StatusCode int - Raw *http.Response - Status201 *ImageAsset -} - -func (c *Client) NewCompleteImageUploadRequest(ctx context.Context, params CompleteImageUploadParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CompleteImageUpload request: context must not be nil") - } - if params.ImageAssetId == "" { - return nil, fmt.Errorf("build CompleteImageUpload request: required parameter image_asset_id is empty") - } - path := "/s/image-uploads/{image_asset_id}/complete" - - path = strings.ReplaceAll(path, "{image_asset_id}", url.PathEscape(fmt.Sprint(params.ImageAssetId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CompleteImageUpload URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CompleteImageUpload JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CompleteImageUpload request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CompleteImageUpload(ctx context.Context, params CompleteImageUploadParams) (*CompleteImageUploadResponse, error) { - - req, err := c.NewCompleteImageUploadRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CompleteImageUpload request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CompleteImageUpload request: HTTP client returned nil response") - } - - result := &CompleteImageUploadResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded ImageAsset - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CompleteImageUpload status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400, 401, 403, 404, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CompleteImageUpload response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ImportRSSParams struct { - Body ImportRSSRequest -} - -type ImportRSSResponse struct { - StatusCode int - Raw *http.Response - Status202 *CreatedPublicRSSImport - Status400 *ValidationErr - Status402 *RSSImportEntitlementError -} - -func (c *Client) NewImportRSSRequest(ctx context.Context, params ImportRSSParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ImportRSS request: context must not be nil") - } - path := "/s/rss-imports" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ImportRSS URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode ImportRSS JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build ImportRSS request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) ImportRSS(ctx context.Context, params ImportRSSParams) (*ImportRSSResponse, error) { - - req, err := c.NewImportRSSRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute ImportRSS request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute ImportRSS request: HTTP client returned nil response") - } - - result := &ImportRSSResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 202: - var decoded CreatedPublicRSSImport - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ImportRSS status 202 response: %w", err) - } - _ = res.Body.Close() - result.Status202 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ImportRSS status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 402: - var decoded RSSImportEntitlementError - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ImportRSS status 402 response: %w", err) - } - _ = res.Body.Close() - result.Status402 = &decoded - return result, nil - case 401, 403, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ImportRSS response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ImportRSSRunEventsParams struct { - ImportRunId ImportRunID -} - -type ImportRSSRunEventsResponse struct { - StatusCode int - Raw *http.Response - Status200 *SSEStream[PublicRSSImportEvent] - Status400 *ValidationErr -} - -func (c *Client) NewImportRSSRunEventsRequest(ctx context.Context, params ImportRSSRunEventsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ImportRSSRunEvents request: context must not be nil") - } - if params.ImportRunId == "" { - return nil, fmt.Errorf("build ImportRSSRunEvents request: required parameter import_run_id is empty") - } - path := "/s/rss-imports/{import_run_id}/events" - - path = strings.ReplaceAll(path, "{import_run_id}", url.PathEscape(fmt.Sprint(params.ImportRunId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ImportRSSRunEvents URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ImportRSSRunEvents request: %w", err) - } - req.Header.Set("Accept", "text/event-stream") - return req, nil -} - -func (c *Client) ImportRSSRunEvents(ctx context.Context, params ImportRSSRunEventsParams) (*ImportRSSRunEventsResponse, error) { - - var requestForError *http.Request - open := func(attemptCtx context.Context) (*http.Response, *responseLifecycle, error) { - req, err := c.NewImportRSSRunEventsRequest(attemptCtx, params) - if err != nil { - return nil, nil, err - } - requestForError = req - responseCtx, lifecycle := c.responseContext(attemptCtx) - req = req.Clone(responseCtx) - res, err := c.do(responseCtx, req) - if err != nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute ImportRSSRunEvents request: %w", err) - } - if res == nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute ImportRSSRunEvents request: HTTP client returned nil response") - } - if res.Request == nil { - res.Request = req - } - return res, lifecycle, nil - } - attempt, err := c.openReconnectingSSE(ctx, open) - if err != nil { - return nil, err - } - res := attempt.response - lifecycle := attempt.lifecycle - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - - result := &ImportRSSRunEventsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - lifecycle.stopTimeout() - keepLifecycle = true - body := newReconnectingSSEBody( - attempt, - ctx, - open, - c.sseMaxRetries, - c.sseReconnectBaseDelay, - c.sseIdleTimeout, - c.sseReconnectOnStreamEnd, - ) - res.Body = body - result.Status200 = newReconnectingSSEStream[PublicRSSImportEvent](body, lifecycle.close) - - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ImportRSSRunEvents status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ImportRSSRunEvents response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: requestForError.Method, - URL: requestForError.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ShowDeletionEventsParams struct { - ShowDeletionRunId ShowDeletionRunID -} - -type ShowDeletionEventsResponse struct { - StatusCode int - Raw *http.Response - Status200 *SSEStream[ShowDeletionEvent] - Status400 *ValidationErr -} - -func (c *Client) NewShowDeletionEventsRequest(ctx context.Context, params ShowDeletionEventsParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ShowDeletionEvents request: context must not be nil") - } - if params.ShowDeletionRunId == "" { - return nil, fmt.Errorf("build ShowDeletionEvents request: required parameter show_deletion_run_id is empty") - } - path := "/s/show-deletions/{show_deletion_run_id}/events" - - path = strings.ReplaceAll(path, "{show_deletion_run_id}", url.PathEscape(fmt.Sprint(params.ShowDeletionRunId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ShowDeletionEvents URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ShowDeletionEvents request: %w", err) - } - req.Header.Set("Accept", "text/event-stream") - return req, nil -} - -func (c *Client) ShowDeletionEvents(ctx context.Context, params ShowDeletionEventsParams) (*ShowDeletionEventsResponse, error) { - - var requestForError *http.Request - open := func(attemptCtx context.Context) (*http.Response, *responseLifecycle, error) { - req, err := c.NewShowDeletionEventsRequest(attemptCtx, params) - if err != nil { - return nil, nil, err - } - requestForError = req - responseCtx, lifecycle := c.responseContext(attemptCtx) - req = req.Clone(responseCtx) - res, err := c.do(responseCtx, req) - if err != nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute ShowDeletionEvents request: %w", err) - } - if res == nil { - lifecycle.close() - return nil, nil, fmt.Errorf("execute ShowDeletionEvents request: HTTP client returned nil response") - } - if res.Request == nil { - res.Request = req - } - return res, lifecycle, nil - } - attempt, err := c.openReconnectingSSE(ctx, open) - if err != nil { - return nil, err - } - res := attempt.response - lifecycle := attempt.lifecycle - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - - result := &ShowDeletionEventsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - lifecycle.stopTimeout() - keepLifecycle = true - body := newReconnectingSSEBody( - attempt, - ctx, - open, - c.sseMaxRetries, - c.sseReconnectBaseDelay, - c.sseIdleTimeout, - c.sseReconnectOnStreamEnd, - ) - res.Body = body - result.Status200 = newReconnectingSSEStream[ShowDeletionEvent](body, lifecycle.close) - - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ShowDeletionEvents status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ShowDeletionEvents response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: requestForError.Method, - URL: requestForError.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ListShowsResponse struct { - StatusCode int - Raw *http.Response - Status200 *[]Show - Status400 *ValidationErr -} - -func (c *Client) NewListShowsRequest(ctx context.Context) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ListShows request: context must not be nil") - } - path := "/s/shows" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ListShows URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ListShows request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) ListShows(ctx context.Context) (*ListShowsResponse, error) { - - req, err := c.NewListShowsRequest(ctx) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute ListShows request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute ListShows request: HTTP client returned nil response") - } - - result := &ListShowsResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded []Show - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListShows status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListShows status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ListShows response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateShowParams struct { - Body CreateShow -} - -type CreateShowResponse struct { - StatusCode int - Raw *http.Response - Status201 *Show - Status400 *ValidationErr -} - -func (c *Client) NewCreateShowRequest(ctx context.Context, params CreateShowParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateShow request: context must not be nil") - } - path := "/s/shows" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateShow URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateShow JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateShow request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateShow(ctx context.Context, params CreateShowParams) (*CreateShowResponse, error) { - - req, err := c.NewCreateShowRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateShow request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateShow request: HTTP client returned nil response") - } - - result := &CreateShowResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded Show - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShow status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShow status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateShow response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateShowDeletionParams struct { - ShowSlug ShowSlug -} - -type CreateShowDeletionResponse struct { - StatusCode int - Raw *http.Response - Status202 *CreatedPublicShowDeletion - Status400 *ValidationErr -} - -func (c *Client) NewCreateShowDeletionRequest(ctx context.Context, params CreateShowDeletionParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateShowDeletion request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build CreateShowDeletion request: required parameter show_slug is empty") - } - path := "/s/shows/{show_slug}/deletions" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateShowDeletion URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build CreateShowDeletion request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) CreateShowDeletion(ctx context.Context, params CreateShowDeletionParams) (*CreateShowDeletionResponse, error) { - - req, err := c.NewCreateShowDeletionRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateShowDeletion request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateShowDeletion request: HTTP client returned nil response") - } - - result := &CreateShowDeletionResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 202: - var decoded CreatedPublicShowDeletion - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShowDeletion status 202 response: %w", err) - } - _ = res.Body.Close() - result.Status202 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShowDeletion status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateShowDeletion response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ListEpisodesParams struct { - ShowSlug ShowSlug - Limit *EpisodePageLimit - Cursor *EpisodePageCursor -} - -type ListEpisodesResponse struct { - StatusCode int - Raw *http.Response - Status200 *EpisodePage - Status400 *ValidationErr -} - -func (c *Client) NewListEpisodesRequest(ctx context.Context, params ListEpisodesParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ListEpisodes request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build ListEpisodes request: required parameter show_slug is empty") - } - path := "/s/shows/{show_slug}/episodes" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ListEpisodes URL: %w", err) - } - query := endpoint.Query() - if params.Limit != nil { - query.Set("limit", fmt.Sprint(*params.Limit)) - } - if params.Cursor != nil { - query.Set("cursor", fmt.Sprint(*params.Cursor)) - } - endpoint.RawQuery = query.Encode() - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ListEpisodes request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) ListEpisodes(ctx context.Context, params ListEpisodesParams) (*ListEpisodesResponse, error) { - - req, err := c.NewListEpisodesRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute ListEpisodes request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute ListEpisodes request: HTTP client returned nil response") - } - - result := &ListEpisodesResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded EpisodePage - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListEpisodes status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListEpisodes status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ListEpisodes response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateShowInvitationParams struct { - ShowSlug ShowSlug - Body CreateShowInvitation -} - -type CreateShowInvitationResponse struct { - StatusCode int - Raw *http.Response - Status201 *PendingShowInvitation - Status400 *ValidationErr -} - -func (c *Client) NewCreateShowInvitationRequest(ctx context.Context, params CreateShowInvitationParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateShowInvitation request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build CreateShowInvitation request: required parameter show_slug is empty") - } - path := "/s/shows/{show_slug}/invitations" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateShowInvitation URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateShowInvitation JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateShowInvitation request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateShowInvitation(ctx context.Context, params CreateShowInvitationParams) (*CreateShowInvitationResponse, error) { - - req, err := c.NewCreateShowInvitationRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateShowInvitation request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateShowInvitation request: HTTP client returned nil response") - } - - result := &CreateShowInvitationResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded PendingShowInvitation - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShowInvitation status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateShowInvitation status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateShowInvitation response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type UpdateShowInvitationRoleParams struct { - ShowSlug ShowSlug - InvitationId ShowInvitationID - Body UpdateShowMemberRole -} - -type UpdateShowInvitationRoleResponse struct { - StatusCode int - Raw *http.Response - Status200 *PendingShowInvitation - Status400 *ValidationErr -} - -func (c *Client) NewUpdateShowInvitationRoleRequest(ctx context.Context, params UpdateShowInvitationRoleParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build UpdateShowInvitationRole request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build UpdateShowInvitationRole request: required parameter show_slug is empty") - } - if params.InvitationId == "" { - return nil, fmt.Errorf("build UpdateShowInvitationRole request: required parameter invitation_id is empty") - } - path := "/s/shows/{show_slug}/invitations/{invitation_id}" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - path = strings.ReplaceAll(path, "{invitation_id}", url.PathEscape(fmt.Sprint(params.InvitationId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build UpdateShowInvitationRole URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode UpdateShowInvitationRole JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "PUT", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build UpdateShowInvitationRole request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) UpdateShowInvitationRole(ctx context.Context, params UpdateShowInvitationRoleParams) (*UpdateShowInvitationRoleResponse, error) { - - req, err := c.NewUpdateShowInvitationRoleRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute UpdateShowInvitationRole request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute UpdateShowInvitationRole request: HTTP client returned nil response") - } - - result := &UpdateShowInvitationRoleResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded PendingShowInvitation - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateShowInvitationRole status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateShowInvitationRole status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected UpdateShowInvitationRole response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type RevokeShowInvitationParams struct { - ShowSlug ShowSlug - InvitationId ShowInvitationID -} - -type RevokeShowInvitationResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewRevokeShowInvitationRequest(ctx context.Context, params RevokeShowInvitationParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build RevokeShowInvitation request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build RevokeShowInvitation request: required parameter show_slug is empty") - } - if params.InvitationId == "" { - return nil, fmt.Errorf("build RevokeShowInvitation request: required parameter invitation_id is empty") - } - path := "/s/shows/{show_slug}/invitations/{invitation_id}" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - path = strings.ReplaceAll(path, "{invitation_id}", url.PathEscape(fmt.Sprint(params.InvitationId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build RevokeShowInvitation URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build RevokeShowInvitation request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) RevokeShowInvitation(ctx context.Context, params RevokeShowInvitationParams) (*RevokeShowInvitationResponse, error) { - - req, err := c.NewRevokeShowInvitationRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute RevokeShowInvitation request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute RevokeShowInvitation request: HTTP client returned nil response") - } - - result := &RevokeShowInvitationResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode RevokeShowInvitation status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected RevokeShowInvitation response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ListShowMembersParams struct { - ShowSlug ShowSlug -} - -type ListShowMembersResponse struct { - StatusCode int - Raw *http.Response - Status200 *ShowMemberCollection - Status400 *ValidationErr -} - -func (c *Client) NewListShowMembersRequest(ctx context.Context, params ListShowMembersParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ListShowMembers request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build ListShowMembers request: required parameter show_slug is empty") - } - path := "/s/shows/{show_slug}/members" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ListShowMembers URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ListShowMembers request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) ListShowMembers(ctx context.Context, params ListShowMembersParams) (*ListShowMembersResponse, error) { - - req, err := c.NewListShowMembersRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute ListShowMembers request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute ListShowMembers request: HTTP client returned nil response") - } - - result := &ListShowMembersResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded ShowMemberCollection - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListShowMembers status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListShowMembers status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ListShowMembers response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type UpdateShowMemberRoleParams struct { - ShowSlug ShowSlug - UserId UserID - Body UpdateShowMemberRole -} - -type UpdateShowMemberRoleResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewUpdateShowMemberRoleRequest(ctx context.Context, params UpdateShowMemberRoleParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build UpdateShowMemberRole request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build UpdateShowMemberRole request: required parameter show_slug is empty") - } - if params.UserId == "" { - return nil, fmt.Errorf("build UpdateShowMemberRole request: required parameter user_id is empty") - } - path := "/s/shows/{show_slug}/members/{user_id}" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - path = strings.ReplaceAll(path, "{user_id}", url.PathEscape(fmt.Sprint(params.UserId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build UpdateShowMemberRole URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode UpdateShowMemberRole JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "PUT", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build UpdateShowMemberRole request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) UpdateShowMemberRole(ctx context.Context, params UpdateShowMemberRoleParams) (*UpdateShowMemberRoleResponse, error) { - - req, err := c.NewUpdateShowMemberRoleRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute UpdateShowMemberRole request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute UpdateShowMemberRole request: HTTP client returned nil response") - } - - result := &UpdateShowMemberRoleResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateShowMemberRole status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected UpdateShowMemberRole response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type RemoveShowMemberParams struct { - ShowSlug ShowSlug - UserId UserID -} - -type RemoveShowMemberResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewRemoveShowMemberRequest(ctx context.Context, params RemoveShowMemberParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build RemoveShowMember request: context must not be nil") - } - if params.ShowSlug == "" { - return nil, fmt.Errorf("build RemoveShowMember request: required parameter show_slug is empty") - } - if params.UserId == "" { - return nil, fmt.Errorf("build RemoveShowMember request: required parameter user_id is empty") - } - path := "/s/shows/{show_slug}/members/{user_id}" - - path = strings.ReplaceAll(path, "{show_slug}", url.PathEscape(fmt.Sprint(params.ShowSlug))) - path = strings.ReplaceAll(path, "{user_id}", url.PathEscape(fmt.Sprint(params.UserId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build RemoveShowMember URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build RemoveShowMember request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) RemoveShowMember(ctx context.Context, params RemoveShowMemberParams) (*RemoveShowMemberResponse, error) { - - req, err := c.NewRemoveShowMemberRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute RemoveShowMember request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute RemoveShowMember request: HTTP client returned nil response") - } - - result := &RemoveShowMemberResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode RemoveShowMember status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected RemoveShowMember response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type CreateTeamInvitationParams struct { - Body CreateTeamInvitation -} - -type CreateTeamInvitationResponse struct { - StatusCode int - Raw *http.Response - Status201 *PendingTeamInvitation - Status400 *ValidationErr -} - -func (c *Client) NewCreateTeamInvitationRequest(ctx context.Context, params CreateTeamInvitationParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build CreateTeamInvitation request: context must not be nil") - } - path := "/s/team/invitations" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build CreateTeamInvitation URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode CreateTeamInvitation JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "POST", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build CreateTeamInvitation request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) CreateTeamInvitation(ctx context.Context, params CreateTeamInvitationParams) (*CreateTeamInvitationResponse, error) { - - req, err := c.NewCreateTeamInvitationRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute CreateTeamInvitation request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute CreateTeamInvitation request: HTTP client returned nil response") - } - - result := &CreateTeamInvitationResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 201: - var decoded PendingTeamInvitation - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateTeamInvitation status 201 response: %w", err) - } - _ = res.Body.Close() - result.Status201 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode CreateTeamInvitation status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 409: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected CreateTeamInvitation response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type UpdateTeamInvitationRoleParams struct { - InvitationId TeamInvitationID - Body UpdateTeamMemberRole -} - -type UpdateTeamInvitationRoleResponse struct { - StatusCode int - Raw *http.Response - Status200 *PendingTeamInvitation - Status400 *ValidationErr -} - -func (c *Client) NewUpdateTeamInvitationRoleRequest(ctx context.Context, params UpdateTeamInvitationRoleParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build UpdateTeamInvitationRole request: context must not be nil") - } - if params.InvitationId == "" { - return nil, fmt.Errorf("build UpdateTeamInvitationRole request: required parameter invitation_id is empty") - } - path := "/s/team/invitations/{invitation_id}" - - path = strings.ReplaceAll(path, "{invitation_id}", url.PathEscape(fmt.Sprint(params.InvitationId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build UpdateTeamInvitationRole URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode UpdateTeamInvitationRole JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "PUT", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build UpdateTeamInvitationRole request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) UpdateTeamInvitationRole(ctx context.Context, params UpdateTeamInvitationRoleParams) (*UpdateTeamInvitationRoleResponse, error) { - - req, err := c.NewUpdateTeamInvitationRoleRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute UpdateTeamInvitationRole request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute UpdateTeamInvitationRole request: HTTP client returned nil response") - } - - result := &UpdateTeamInvitationRoleResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded PendingTeamInvitation - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateTeamInvitationRole status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateTeamInvitationRole status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected UpdateTeamInvitationRole response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type RevokeTeamInvitationParams struct { - InvitationId TeamInvitationID -} - -type RevokeTeamInvitationResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewRevokeTeamInvitationRequest(ctx context.Context, params RevokeTeamInvitationParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build RevokeTeamInvitation request: context must not be nil") - } - if params.InvitationId == "" { - return nil, fmt.Errorf("build RevokeTeamInvitation request: required parameter invitation_id is empty") - } - path := "/s/team/invitations/{invitation_id}" - - path = strings.ReplaceAll(path, "{invitation_id}", url.PathEscape(fmt.Sprint(params.InvitationId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build RevokeTeamInvitation URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build RevokeTeamInvitation request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) RevokeTeamInvitation(ctx context.Context, params RevokeTeamInvitationParams) (*RevokeTeamInvitationResponse, error) { - - req, err := c.NewRevokeTeamInvitationRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute RevokeTeamInvitation request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute RevokeTeamInvitation request: HTTP client returned nil response") - } - - result := &RevokeTeamInvitationResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode RevokeTeamInvitation status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected RevokeTeamInvitation response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type ListTeamMembersResponse struct { - StatusCode int - Raw *http.Response - Status200 *TeamMemberCollection - Status400 *ValidationErr -} - -func (c *Client) NewListTeamMembersRequest(ctx context.Context) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build ListTeamMembers request: context must not be nil") - } - path := "/s/team/members" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build ListTeamMembers URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build ListTeamMembers request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) ListTeamMembers(ctx context.Context) (*ListTeamMembersResponse, error) { - - req, err := c.NewListTeamMembersRequest(ctx) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute ListTeamMembers request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute ListTeamMembers request: HTTP client returned nil response") - } - - result := &ListTeamMembersResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded TeamMemberCollection - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListTeamMembers status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode ListTeamMembers status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401, 403: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected ListTeamMembers response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type UpdateTeamMemberRoleParams struct { - UserId UserID - Body UpdateTeamMemberRole -} - -type UpdateTeamMemberRoleResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewUpdateTeamMemberRoleRequest(ctx context.Context, params UpdateTeamMemberRoleParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build UpdateTeamMemberRole request: context must not be nil") - } - if params.UserId == "" { - return nil, fmt.Errorf("build UpdateTeamMemberRole request: required parameter user_id is empty") - } - path := "/s/team/members/{user_id}" - - path = strings.ReplaceAll(path, "{user_id}", url.PathEscape(fmt.Sprint(params.UserId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build UpdateTeamMemberRole URL: %w", err) - } - encodedBody, err := json.Marshal(params.Body) - if err != nil { - return nil, fmt.Errorf("encode UpdateTeamMemberRole JSON body: %w", err) - } - requestBody := bytes.NewReader(encodedBody) - req, err := http.NewRequestWithContext(ctx, "PUT", endpoint.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("build UpdateTeamMemberRole request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - return req, nil -} - -func (c *Client) UpdateTeamMemberRole(ctx context.Context, params UpdateTeamMemberRoleParams) (*UpdateTeamMemberRoleResponse, error) { - - req, err := c.NewUpdateTeamMemberRoleRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute UpdateTeamMemberRole request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute UpdateTeamMemberRole request: HTTP client returned nil response") - } - - result := &UpdateTeamMemberRoleResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode UpdateTeamMemberRole status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected UpdateTeamMemberRole response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type RemoveTeamMemberParams struct { - UserId UserID -} - -type RemoveTeamMemberResponse struct { - StatusCode int - Raw *http.Response - Status400 *ValidationErr -} - -func (c *Client) NewRemoveTeamMemberRequest(ctx context.Context, params RemoveTeamMemberParams) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build RemoveTeamMember request: context must not be nil") - } - if params.UserId == "" { - return nil, fmt.Errorf("build RemoveTeamMember request: required parameter user_id is empty") - } - path := "/s/team/members/{user_id}" - - path = strings.ReplaceAll(path, "{user_id}", url.PathEscape(fmt.Sprint(params.UserId))) - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build RemoveTeamMember URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build RemoveTeamMember request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) RemoveTeamMember(ctx context.Context, params RemoveTeamMemberParams) (*RemoveTeamMemberResponse, error) { - - req, err := c.NewRemoveTeamMemberRequest(ctx, params) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute RemoveTeamMember request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute RemoveTeamMember request: HTTP client returned nil response") - } - - result := &RemoveTeamMemberResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode RemoveTeamMember status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 204, 401, 403, 404: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected RemoveTeamMember response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type WhoamiResponse struct { - StatusCode int - Raw *http.Response - Status200 *APIKeyWhoami - Status400 *ValidationErr -} - -func (c *Client) NewWhoamiRequest(ctx context.Context) (*http.Request, error) { - if ctx == nil { - return nil, fmt.Errorf("build Whoami request: context must not be nil") - } - path := "/s/whoami" - - endpoint, err := url.Parse(c.baseURL + path) - if err != nil { - return nil, fmt.Errorf("build Whoami URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("build Whoami request: %w", err) - } - req.Header.Set("Accept", "application/json") - return req, nil -} - -func (c *Client) Whoami(ctx context.Context) (*WhoamiResponse, error) { - - req, err := c.NewWhoamiRequest(ctx) - if err != nil { - return nil, err - } - responseCtx, lifecycle := c.responseContext(ctx) - req = req.Clone(responseCtx) - keepLifecycle := false - defer func() { - if !keepLifecycle { - lifecycle.close() - } - }() - res, err := c.do(responseCtx, req) - if err != nil { - return nil, fmt.Errorf("execute Whoami request: %w", err) - } - if res == nil { - return nil, fmt.Errorf("execute Whoami request: HTTP client returned nil response") - } - - result := &WhoamiResponse{StatusCode: res.StatusCode, Raw: res} - switch res.StatusCode { - case 200: - var decoded APIKeyWhoami - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode Whoami status 200 response: %w", err) - } - _ = res.Body.Close() - result.Status200 = &decoded - return result, nil - case 400: - var decoded ValidationErr - if err := json.NewDecoder(io.LimitReader(res.Body, maxDecodedBodyBytes)).Decode(&decoded); err != nil { - _ = res.Body.Close() - return nil, fmt.Errorf("decode Whoami status 400 response: %w", err) - } - _ = res.Body.Close() - result.Status400 = &decoded - return result, nil - case 401: - _ = res.Body.Close() - return result, nil - default: - rawBody, readErr := io.ReadAll(io.LimitReader(res.Body, maxDiagnosticBodyBytes)) - _ = res.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("read unexpected Whoami response status %d: %w", res.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: req.Method, - URL: req.URL.String(), - StatusCode: res.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } -} - -type SSEIdleTimeoutError struct { - Duration time.Duration -} - -func (err *SSEIdleTimeoutError) Error() string { - return fmt.Sprintf("SSE stream was idle for %s", err.Duration) -} - -func (err *SSEIdleTimeoutError) Timeout() bool { return true } - -type idleTimeoutBody struct { - body io.ReadCloser - duration time.Duration - onTimeout func() - - mu sync.Mutex - timer *time.Timer - timedOut bool - closed bool - closeOnce sync.Once - closeErr error -} - -func newIdleTimeoutBody(body io.ReadCloser, duration time.Duration, onTimeout func()) io.ReadCloser { - if duration == 0 { - return body - } - wrapped := &idleTimeoutBody{ - body: body, - duration: duration, - onTimeout: onTimeout, - } - wrapped.timer = time.AfterFunc(duration, wrapped.expire) - return wrapped -} - -func (body *idleTimeoutBody) expire() { - body.mu.Lock() - if body.closed { - body.mu.Unlock() - return - } - body.timedOut = true - body.mu.Unlock() - if body.onTimeout != nil { - body.onTimeout() - } - _ = body.body.Close() -} - -func (body *idleTimeoutBody) Read(buffer []byte) (int, error) { - read, err := body.body.Read(buffer) - body.mu.Lock() - if read > 0 && !body.timedOut && !body.closed { - body.timer.Reset(body.duration) - } - timedOut := body.timedOut - body.mu.Unlock() - if timedOut && err != nil { - return read, &SSEIdleTimeoutError{Duration: body.duration} - } - return read, err -} - -func (body *idleTimeoutBody) Close() error { - body.closeOnce.Do(func() { - body.mu.Lock() - body.closed = true - body.timer.Stop() - body.mu.Unlock() - body.closeErr = body.body.Close() - }) - return body.closeErr -} - -type SSEStream[T any] struct { - body io.ReadCloser - scanner *bufio.Scanner - closeOnce sync.Once - nextMu sync.Mutex - data []string - currentEvent string - lastEvent string - finished bool - closeErr error - cleanup func() -} - -func NewSSEStream[T any](body io.ReadCloser) *SSEStream[T] { - return newSSEStream[T](body, defaultSSEIdleTimeout, nil) -} - -func newSSEStream[T any](body io.ReadCloser, idleTimeout time.Duration, cleanup func()) *SSEStream[T] { - body = newIdleTimeoutBody(body, idleTimeout, cleanup) - return newSSEStreamWithoutIdle[T](body, cleanup) -} - -func newReconnectingSSEStream[T any](body io.ReadCloser, cleanup func()) *SSEStream[T] { - return newSSEStreamWithoutIdle[T](body, cleanup) -} - -func newSSEStreamWithoutIdle[T any](body io.ReadCloser, cleanup func()) *SSEStream[T] { - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 64<<10), maxSSEEventBytes) - return &SSEStream[T]{body: body, scanner: scanner, cleanup: cleanup} -} - -// EventName returns the event field associated with the item most recently -// returned by Next. An omitted event field produces an empty string. -func (stream *SSEStream[T]) EventName() string { - stream.nextMu.Lock() - defer stream.nextMu.Unlock() - return stream.lastEvent -} - -// Next returns one decoded SSE data payload without buffering the full stream. -// The boolean is false after clean EOF. Close ends consumption early. -func (stream *SSEStream[T]) Next(ctx context.Context) (T, bool, error) { - stream.nextMu.Lock() - defer stream.nextMu.Unlock() - - var zero T - if stream.finished { - return zero, false, nil - } - if err := ctx.Err(); err != nil { - _ = stream.Close() - return zero, false, err - } - wake := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - _ = stream.Close() - case <-wake: - } - }() - defer close(wake) - - for stream.scanner.Scan() { - line := stream.scanner.Text() - if line == "" { - decoded, ok, err := stream.decodeEvent() - if ok || err != nil { - return decoded, ok, err - } - continue - } - if strings.HasPrefix(line, ":") { - continue - } - field, value, found := strings.Cut(line, ":") - if !found { - value = "" - } - value = strings.TrimPrefix(value, " ") - switch field { - case "data": - stream.data = append(stream.data, value) - case "event": - stream.currentEvent = value - } - } - if err := ctx.Err(); err != nil { - stream.finished = true - _ = stream.Close() - return zero, false, err - } - if err := stream.scanner.Err(); err != nil { - stream.finished = true - _ = stream.Close() - return zero, false, fmt.Errorf("read SSE stream: %w", err) - } - stream.finished = true - decoded, ok, err := stream.decodeEvent() - _ = stream.Close() - return decoded, ok, err -} - -func (stream *SSEStream[T]) decodeEvent() (T, bool, error) { - var zero T - payload := strings.Join(stream.data, "\n") - stream.data = nil - stream.lastEvent = stream.currentEvent - stream.currentEvent = "" - if strings.TrimSpace(payload) == "" { - return zero, false, nil - } - var decoded T - if err := json.Unmarshal([]byte(payload), &decoded); err != nil { - stream.finished = true - _ = stream.Close() - return zero, false, fmt.Errorf("decode SSE JSON event: %w", err) - } - return decoded, true, nil -} - -func (stream *SSEStream[T]) Close() error { - stream.closeOnce.Do(func() { - stream.closeErr = stream.body.Close() - if stream.cleanup != nil { - stream.cleanup() - } - }) - return stream.closeErr -} diff --git a/publicapi/models.go b/publicapi/models.go deleted file mode 100644 index 050b2e1..0000000 --- a/publicapi/models.go +++ /dev/null @@ -1,1332 +0,0 @@ -package publicapi - -import ( - "encoding/json" - "fmt" -) - -type APIKeyWhoami struct { - Email string `json:"email"` - Id ApiKeyID `json:"id"` - Name *NonEmptyString `json:"name,omitempty"` - Scopes []ApiKeyScope `json:"scopes"` - TeamId TeamID `json:"team_id"` -} - -type ApiKeyID = string - -type ApiKeyScope string - -const ( - ApiKeyScopeTeamRead ApiKeyScope = "team:read" - ApiKeyScopeTeamManage ApiKeyScope = "team:manage" - ApiKeyScopeShowRead ApiKeyScope = "show:read" - ApiKeyScopeShowCreate ApiKeyScope = "show:create" - ApiKeyScopeShowUpdate ApiKeyScope = "show:update" - ApiKeyScopeEpisodeCreate ApiKeyScope = "episode:create" - ApiKeyScopeEpisodeDelete ApiKeyScope = "episode:delete" - ApiKeyScopeEpisodeUpdate ApiKeyScope = "episode:update" - ApiKeyScopeEpisodePublish ApiKeyScope = "episode:publish" -) - -type AssignableTeamRole string - -const ( - AssignableTeamRoleWrite AssignableTeamRole = "write" - AssignableTeamRoleRead AssignableTeamRole = "read" -) - -type CLIAuthorizationApprovedEvent struct { - TeamId TeamID `json:"team_id"` - Type string `json:"type"` -} - -type CLIAuthorizationCode = string - -type CLIAuthorizationDeniedEvent struct { - Type string `json:"type"` -} - -// CLIAuthorizationEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: CLIAuthorizationPendingEvent -// oneOf variant: CLIAuthorizationApprovedEvent -// oneOf variant: CLIAuthorizationDeniedEvent -// oneOf variant: CLIAuthorizationExpiredEvent -type CLIAuthorizationEvent struct { - CLIAuthorizationPendingEvent *CLIAuthorizationPendingEvent - CLIAuthorizationApprovedEvent *CLIAuthorizationApprovedEvent - CLIAuthorizationDeniedEvent *CLIAuthorizationDeniedEvent - CLIAuthorizationExpiredEvent *CLIAuthorizationExpiredEvent -} - -func (dst *CLIAuthorizationEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"type"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode CLIAuthorizationEvent discriminator: %w", err) - } - switch discriminator.Value { - case "cli.authorization.approved": - var decoded CLIAuthorizationApprovedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode CLIAuthorizationEvent as CLIAuthorizationApprovedEvent: %w", err) - } - *dst = CLIAuthorizationEvent{CLIAuthorizationApprovedEvent: &decoded} - return nil - case "cli.authorization.denied": - var decoded CLIAuthorizationDeniedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode CLIAuthorizationEvent as CLIAuthorizationDeniedEvent: %w", err) - } - *dst = CLIAuthorizationEvent{CLIAuthorizationDeniedEvent: &decoded} - return nil - case "cli.authorization.expired": - var decoded CLIAuthorizationExpiredEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode CLIAuthorizationEvent as CLIAuthorizationExpiredEvent: %w", err) - } - *dst = CLIAuthorizationEvent{CLIAuthorizationExpiredEvent: &decoded} - return nil - case "cli.authorization.pending": - var decoded CLIAuthorizationPendingEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode CLIAuthorizationEvent as CLIAuthorizationPendingEvent: %w", err) - } - *dst = CLIAuthorizationEvent{CLIAuthorizationPendingEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported CLIAuthorizationEvent discriminator %q", discriminator.Value) - } -} - -func (src CLIAuthorizationEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.CLIAuthorizationPendingEvent != nil { - matchCount++ - } - if src.CLIAuthorizationApprovedEvent != nil { - matchCount++ - } - if src.CLIAuthorizationDeniedEvent != nil { - matchCount++ - } - if src.CLIAuthorizationExpiredEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("CLIAuthorizationEvent must contain exactly one variant, got %d", matchCount) - } - if src.CLIAuthorizationPendingEvent != nil { - return json.Marshal(src.CLIAuthorizationPendingEvent) - } - if src.CLIAuthorizationApprovedEvent != nil { - return json.Marshal(src.CLIAuthorizationApprovedEvent) - } - if src.CLIAuthorizationDeniedEvent != nil { - return json.Marshal(src.CLIAuthorizationDeniedEvent) - } - if src.CLIAuthorizationExpiredEvent != nil { - return json.Marshal(src.CLIAuthorizationExpiredEvent) - } - return nil, fmt.Errorf("CLIAuthorizationEvent has no variant") -} - -func (src CLIAuthorizationEvent) GetActualInstance() any { - if src.CLIAuthorizationPendingEvent != nil { - return src.CLIAuthorizationPendingEvent - } - if src.CLIAuthorizationApprovedEvent != nil { - return src.CLIAuthorizationApprovedEvent - } - if src.CLIAuthorizationDeniedEvent != nil { - return src.CLIAuthorizationDeniedEvent - } - if src.CLIAuthorizationExpiredEvent != nil { - return src.CLIAuthorizationExpiredEvent - } - return nil -} - -func CLIAuthorizationPendingEventAsCLIAuthorizationEvent(v CLIAuthorizationPendingEvent) CLIAuthorizationEvent { - return CLIAuthorizationEvent{CLIAuthorizationPendingEvent: &v} -} - -func CLIAuthorizationApprovedEventAsCLIAuthorizationEvent(v CLIAuthorizationApprovedEvent) CLIAuthorizationEvent { - return CLIAuthorizationEvent{CLIAuthorizationApprovedEvent: &v} -} - -func CLIAuthorizationDeniedEventAsCLIAuthorizationEvent(v CLIAuthorizationDeniedEvent) CLIAuthorizationEvent { - return CLIAuthorizationEvent{CLIAuthorizationDeniedEvent: &v} -} - -func CLIAuthorizationExpiredEventAsCLIAuthorizationEvent(v CLIAuthorizationExpiredEvent) CLIAuthorizationEvent { - return CLIAuthorizationEvent{CLIAuthorizationExpiredEvent: &v} -} - -type CLIAuthorizationExpiredEvent struct { - Type string `json:"type"` -} - -type CLIAuthorizationPendingEvent struct { - Credential string `json:"credential"` - Type string `json:"type"` - VerificationUrl string `json:"verification_url"` -} - -type CompleteEpisodeUploadSession struct { - Publication EpisodePublication `json:"publication"` -} - -type CompleteImageUpload struct { - ObjectKey ImageUploadObjectKey `json:"object_key"` -} - -type CompletedEpisodeUploadPart struct { - Etag NonEmptyString `json:"etag"` - PartNumber MediaUploadPartNumber `json:"part_number"` - Size int64 `json:"size"` -} - -type CreateCLIAuthorization struct { - Scopes []ApiKeyScope `json:"scopes"` -} - -type CreateEpisodePackage struct { - Description *string `json:"description,omitempty"` - DurationSeconds int64 `json:"duration_seconds"` - Objects []PreparedMediaObject `json:"objects"` - ShowSlug ShowSlug `json:"show_slug"` - SourceUrl string `json:"source_url"` - Title string `json:"title"` -} - -type CreateEpisodeUploadSession struct { - ByteLength int64 `json:"byte_length"` - ContentType EpisodeUploadContentType `json:"content_type"` - Description *NonEmptyString `json:"description,omitempty"` - FileName NonEmptyString `json:"file_name"` - ShowSlug ShowSlug `json:"show_slug"` - SourceSha256 SHA256Hex `json:"source_sha256"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - Title NonEmptyString `json:"title"` -} - -type CreateImageUploadPresign struct { - ByteLength int64 `json:"byte_length"` - ContentType string `json:"content_type"` - FileName *NonEmptyString `json:"file_name,omitempty"` - ShowId ShowID `json:"show_id"` -} - -type CreateShow struct { - Id ShowID `json:"id"` - ImageAssetId *ImageAssetID `json:"image_asset_id,omitempty"` - Language ShowLanguage `json:"language"` - Slug ShowSlug `json:"slug"` - SourceKind ShowSourceKind `json:"source_kind"` - Title NonEmptyString `json:"title"` -} - -type CreateShowInvitation struct { - Email string `json:"email"` - Role AssignableTeamRole `json:"role"` -} - -type CreateTeamInvitation struct { - Email string `json:"email"` - Role AssignableTeamRole `json:"role"` -} - -type CreatedCLIAuthorization struct { - Code CLIAuthorizationCode `json:"code"` - Credential string `json:"credential"` - VerificationUrl string `json:"verification_url"` -} - -type CreatedImageUploadPresign struct { - ContentType string `json:"content_type"` - ExpiresAt int64 `json:"expires_at"` - ImageAssetId ImageAssetID `json:"image_asset_id"` - Method string `json:"method"` - ObjectKey ImageUploadObjectKey `json:"object_key"` - PublicUrl string `json:"public_url"` - UploadUrl string `json:"upload_url"` -} - -type CreatedPublicEpisodeDeletion struct { - EpisodeDeletionRunId EpisodeDeletionRunID `json:"episode_deletion_run_id"` -} - -type CreatedPublicRSSImport struct { - ImportRunId ImportRunID `json:"import_run_id"` -} - -type CreatedPublicShowDeletion struct { - ShowDeletionRunId ShowDeletionRunID `json:"show_deletion_run_id"` -} - -type Episode struct { - Description *string `json:"description,omitempty"` - Enclosures []EpisodeEnclosure `json:"enclosures"` - Id EpisodeID `json:"id"` - Link *string `json:"link,omitempty"` - Processing *EpisodeProcessingSnapshot `json:"processing,omitempty"` - RepublishedAt *UnixMillis `json:"republished_at,omitempty"` - ShowId ShowID `json:"show_id"` - Slug *NonEmptyString `json:"slug,omitempty"` - Status EpisodeStatus `json:"status"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - Title NonEmptyString `json:"title"` - VideoHlsUrl *string `json:"video_hls_url,omitempty"` -} - -type EpisodeDeletionCompletedEvent struct { - EpisodeDeletionRunId EpisodeDeletionRunID `json:"episode_deletion_run_id"` - EpisodeId EpisodeID `json:"episode_id"` - Status string `json:"status"` - Type string `json:"type"` -} - -// EpisodeDeletionEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: EpisodeDeletionProgressEvent -// oneOf variant: EpisodeDeletionTerminalEvent -type EpisodeDeletionEvent struct { - EpisodeDeletionProgressEvent *EpisodeDeletionProgressEvent - EpisodeDeletionTerminalEvent *EpisodeDeletionTerminalEvent -} - -func (dst *EpisodeDeletionEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"type"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode EpisodeDeletionEvent discriminator: %w", err) - } - switch discriminator.Value { - case "progress": - var decoded EpisodeDeletionProgressEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeDeletionEvent as EpisodeDeletionProgressEvent: %w", err) - } - *dst = EpisodeDeletionEvent{EpisodeDeletionProgressEvent: &decoded} - return nil - case "terminal": - var decoded EpisodeDeletionTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeDeletionEvent as EpisodeDeletionTerminalEvent: %w", err) - } - *dst = EpisodeDeletionEvent{EpisodeDeletionTerminalEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported EpisodeDeletionEvent discriminator %q", discriminator.Value) - } -} - -func (src EpisodeDeletionEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.EpisodeDeletionProgressEvent != nil { - matchCount++ - } - if src.EpisodeDeletionTerminalEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("EpisodeDeletionEvent must contain exactly one variant, got %d", matchCount) - } - if src.EpisodeDeletionProgressEvent != nil { - return json.Marshal(src.EpisodeDeletionProgressEvent) - } - if src.EpisodeDeletionTerminalEvent != nil { - return json.Marshal(src.EpisodeDeletionTerminalEvent) - } - return nil, fmt.Errorf("EpisodeDeletionEvent has no variant") -} - -func (src EpisodeDeletionEvent) GetActualInstance() any { - if src.EpisodeDeletionProgressEvent != nil { - return src.EpisodeDeletionProgressEvent - } - if src.EpisodeDeletionTerminalEvent != nil { - return src.EpisodeDeletionTerminalEvent - } - return nil -} - -func EpisodeDeletionProgressEventAsEpisodeDeletionEvent(v EpisodeDeletionProgressEvent) EpisodeDeletionEvent { - return EpisodeDeletionEvent{EpisodeDeletionProgressEvent: &v} -} - -func EpisodeDeletionTerminalEventAsEpisodeDeletionEvent(v EpisodeDeletionTerminalEvent) EpisodeDeletionEvent { - return EpisodeDeletionEvent{EpisodeDeletionTerminalEvent: &v} -} - -type EpisodeDeletionFailedEvent struct { - EpisodeDeletionRunId EpisodeDeletionRunID `json:"episode_deletion_run_id"` - ErrorCode NonEmptyString `json:"error_code"` - ErrorMessage NonEmptyString `json:"error_message"` - Status string `json:"status"` - Type string `json:"type"` -} - -type EpisodeDeletionProgressEvent struct { - CurrentStep NonEmptyString `json:"current_step"` - EpisodeDeletionRunId EpisodeDeletionRunID `json:"episode_deletion_run_id"` - Percent int32 `json:"percent"` - Status string `json:"status"` - Type string `json:"type"` -} - -type EpisodeDeletionRunID = string - -// EpisodeDeletionTerminalEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: EpisodeDeletionCompletedEvent -// oneOf variant: EpisodeDeletionFailedEvent -type EpisodeDeletionTerminalEvent struct { - EpisodeDeletionCompletedEvent *EpisodeDeletionCompletedEvent - EpisodeDeletionFailedEvent *EpisodeDeletionFailedEvent -} - -func (dst *EpisodeDeletionTerminalEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"status"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode EpisodeDeletionTerminalEvent discriminator: %w", err) - } - switch discriminator.Value { - case "completed": - var decoded EpisodeDeletionCompletedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeDeletionTerminalEvent as EpisodeDeletionCompletedEvent: %w", err) - } - *dst = EpisodeDeletionTerminalEvent{EpisodeDeletionCompletedEvent: &decoded} - return nil - case "failed": - var decoded EpisodeDeletionFailedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeDeletionTerminalEvent as EpisodeDeletionFailedEvent: %w", err) - } - *dst = EpisodeDeletionTerminalEvent{EpisodeDeletionFailedEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported EpisodeDeletionTerminalEvent discriminator %q", discriminator.Value) - } -} - -func (src EpisodeDeletionTerminalEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.EpisodeDeletionCompletedEvent != nil { - matchCount++ - } - if src.EpisodeDeletionFailedEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("EpisodeDeletionTerminalEvent must contain exactly one variant, got %d", matchCount) - } - if src.EpisodeDeletionCompletedEvent != nil { - return json.Marshal(src.EpisodeDeletionCompletedEvent) - } - if src.EpisodeDeletionFailedEvent != nil { - return json.Marshal(src.EpisodeDeletionFailedEvent) - } - return nil, fmt.Errorf("EpisodeDeletionTerminalEvent has no variant") -} - -func (src EpisodeDeletionTerminalEvent) GetActualInstance() any { - if src.EpisodeDeletionCompletedEvent != nil { - return src.EpisodeDeletionCompletedEvent - } - if src.EpisodeDeletionFailedEvent != nil { - return src.EpisodeDeletionFailedEvent - } - return nil -} - -func EpisodeDeletionCompletedEventAsEpisodeDeletionTerminalEvent(v EpisodeDeletionCompletedEvent) EpisodeDeletionTerminalEvent { - return EpisodeDeletionTerminalEvent{EpisodeDeletionCompletedEvent: &v} -} - -func EpisodeDeletionFailedEventAsEpisodeDeletionTerminalEvent(v EpisodeDeletionFailedEvent) EpisodeDeletionTerminalEvent { - return EpisodeDeletionTerminalEvent{EpisodeDeletionFailedEvent: &v} -} - -type EpisodeEnclosure struct { - ByteLength int64 `json:"byte_length"` - ContentType NonEmptyString `json:"content_type"` - DurationSeconds int64 `json:"duration_seconds"` - FeedId FeedID `json:"feed_id"` - Format EpisodeEnclosureFormat `json:"format"` - Url string `json:"url"` -} - -type EpisodeEnclosureFormat string - -const ( - EpisodeEnclosureFormatMp3 EpisodeEnclosureFormat = "mp3" - EpisodeEnclosureFormatM4a EpisodeEnclosureFormat = "m4a" - EpisodeEnclosureFormatWav EpisodeEnclosureFormat = "wav" - EpisodeEnclosureFormatFlac EpisodeEnclosureFormat = "flac" - EpisodeEnclosureFormatMp4 EpisodeEnclosureFormat = "mp4" -) - -type EpisodeID = string - -type EpisodeListItem struct { - AudioUrl *string `json:"audio_url,omitempty"` - Description *string `json:"description,omitempty"` - DurationSeconds *int64 `json:"duration_seconds,omitempty"` - Id EpisodeID `json:"id"` - Processing *EpisodeProcessingSnapshot `json:"processing,omitempty"` - RepublishedAt *UnixMillis `json:"republished_at,omitempty"` - ShowId ShowID `json:"show_id"` - Slug *NonEmptyString `json:"slug,omitempty"` - Status EpisodeStatus `json:"status"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - Title NonEmptyString `json:"title"` - VideoHlsUrl *string `json:"video_hls_url,omitempty"` -} - -type EpisodePackage struct { - EpisodeId EpisodeID `json:"episode_id"` - ObjectPrefix NonEmptyString `json:"object_prefix"` - PartSize int64 `json:"part_size"` - ShowId ShowID `json:"show_id"` - Status string `json:"status"` - TeamId TeamID `json:"team_id"` - UploadSessionId UploadSessionID `json:"upload_session_id"` -} - -type EpisodePage struct { - Episodes []EpisodeListItem `json:"episodes"` - NextCursor *EpisodePageCursor `json:"next_cursor,omitempty"` -} - -type EpisodePageCursor = string - -type EpisodePageLimit = int32 - -// EpisodeProcessingEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: EpisodeProcessingProgressEvent -// oneOf variant: EpisodeProcessingSafeHandoffEvent -// oneOf variant: EpisodeProcessingFailedEvent -type EpisodeProcessingEvent struct { - EpisodeProcessingProgressEvent *EpisodeProcessingProgressEvent - EpisodeProcessingSafeHandoffEvent *EpisodeProcessingSafeHandoffEvent - EpisodeProcessingFailedEvent *EpisodeProcessingFailedEvent -} - -func (dst *EpisodeProcessingEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"type"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode EpisodeProcessingEvent discriminator: %w", err) - } - switch discriminator.Value { - case "episode.processing.failed": - var decoded EpisodeProcessingFailedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeProcessingEvent as EpisodeProcessingFailedEvent: %w", err) - } - *dst = EpisodeProcessingEvent{EpisodeProcessingFailedEvent: &decoded} - return nil - case "episode.processing.progress": - var decoded EpisodeProcessingProgressEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeProcessingEvent as EpisodeProcessingProgressEvent: %w", err) - } - *dst = EpisodeProcessingEvent{EpisodeProcessingProgressEvent: &decoded} - return nil - case "episode.processing.safe_handoff": - var decoded EpisodeProcessingSafeHandoffEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode EpisodeProcessingEvent as EpisodeProcessingSafeHandoffEvent: %w", err) - } - *dst = EpisodeProcessingEvent{EpisodeProcessingSafeHandoffEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported EpisodeProcessingEvent discriminator %q", discriminator.Value) - } -} - -func (src EpisodeProcessingEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.EpisodeProcessingProgressEvent != nil { - matchCount++ - } - if src.EpisodeProcessingSafeHandoffEvent != nil { - matchCount++ - } - if src.EpisodeProcessingFailedEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("EpisodeProcessingEvent must contain exactly one variant, got %d", matchCount) - } - if src.EpisodeProcessingProgressEvent != nil { - return json.Marshal(src.EpisodeProcessingProgressEvent) - } - if src.EpisodeProcessingSafeHandoffEvent != nil { - return json.Marshal(src.EpisodeProcessingSafeHandoffEvent) - } - if src.EpisodeProcessingFailedEvent != nil { - return json.Marshal(src.EpisodeProcessingFailedEvent) - } - return nil, fmt.Errorf("EpisodeProcessingEvent has no variant") -} - -func (src EpisodeProcessingEvent) GetActualInstance() any { - if src.EpisodeProcessingProgressEvent != nil { - return src.EpisodeProcessingProgressEvent - } - if src.EpisodeProcessingSafeHandoffEvent != nil { - return src.EpisodeProcessingSafeHandoffEvent - } - if src.EpisodeProcessingFailedEvent != nil { - return src.EpisodeProcessingFailedEvent - } - return nil -} - -func EpisodeProcessingProgressEventAsEpisodeProcessingEvent(v EpisodeProcessingProgressEvent) EpisodeProcessingEvent { - return EpisodeProcessingEvent{EpisodeProcessingProgressEvent: &v} -} - -func EpisodeProcessingSafeHandoffEventAsEpisodeProcessingEvent(v EpisodeProcessingSafeHandoffEvent) EpisodeProcessingEvent { - return EpisodeProcessingEvent{EpisodeProcessingSafeHandoffEvent: &v} -} - -func EpisodeProcessingFailedEventAsEpisodeProcessingEvent(v EpisodeProcessingFailedEvent) EpisodeProcessingEvent { - return EpisodeProcessingEvent{EpisodeProcessingFailedEvent: &v} -} - -type EpisodeProcessingFailedEvent struct { - EpisodeId EpisodeID `json:"episode_id"` - ErrorCode NonEmptyString `json:"error_code"` - ErrorMessage NonEmptyString `json:"error_message"` - RunId ProcessingRunID `json:"run_id"` - Status string `json:"status"` - Type string `json:"type"` -} - -type EpisodeProcessingProgressEvent struct { - CurrentStep NonEmptyString `json:"current_step"` - Percent int32 `json:"percent"` - RunId ProcessingRunID `json:"run_id"` - Status EpisodeProcessingStatus `json:"status"` - Type string `json:"type"` -} - -type EpisodeProcessingSafeHandoffEvent struct { - EpisodeId EpisodeID `json:"episode_id"` - MediaVersionId MediaVersionID `json:"media_version_id"` - RunId ProcessingRunID `json:"run_id"` - Status string `json:"status"` - Type string `json:"type"` - YoutubeVideoId *NonEmptyString `json:"youtube_video_id,omitempty"` -} - -type EpisodeProcessingSnapshot struct { - CurrentStep NonEmptyString `json:"current_step"` - ErrorCode *NonEmptyString `json:"error_code,omitempty"` - ErrorMessage *NonEmptyString `json:"error_message,omitempty"` - Percent int32 `json:"percent"` - RunId ProcessingRunID `json:"run_id"` - Status EpisodeProcessingStatus `json:"status"` -} - -type EpisodeProcessingStatus string - -const ( - EpisodeProcessingStatusQueued EpisodeProcessingStatus = "queued" - EpisodeProcessingStatusProcessing EpisodeProcessingStatus = "processing" - EpisodeProcessingStatusCompleted EpisodeProcessingStatus = "completed" - EpisodeProcessingStatusFailed EpisodeProcessingStatus = "failed" -) - -type EpisodePublication string - -const ( - EpisodePublicationDraft EpisodePublication = "draft" - EpisodePublicationPublish EpisodePublication = "publish" -) - -type EpisodeStatus string - -const ( - EpisodeStatusDraft EpisodeStatus = "draft" - EpisodeStatusPublished EpisodeStatus = "published" -) - -type EpisodeUploadContentType string - -const ( - EpisodeUploadContentTypeAudioMpeg EpisodeUploadContentType = "audio/mpeg" - EpisodeUploadContentTypeAudioMp4 EpisodeUploadContentType = "audio/mp4" - EpisodeUploadContentTypeAudioWav EpisodeUploadContentType = "audio/wav" - EpisodeUploadContentTypeAudioFlac EpisodeUploadContentType = "audio/flac" - EpisodeUploadContentTypeVideoMp4 EpisodeUploadContentType = "video/mp4" - EpisodeUploadContentTypeVideoQuicktime EpisodeUploadContentType = "video/quicktime" -) - -type EpisodeUploadSession struct { - ByteLength int64 `json:"byte_length"` - CompletedParts []CompletedEpisodeUploadPart `json:"completed_parts"` - ContentType EpisodeUploadContentType `json:"content_type"` - Description *string `json:"description,omitempty"` - EpisodeId *EpisodeID `json:"episode_id,omitempty"` - ExpiresAt UnixMillis `json:"expires_at"` - FileName NonEmptyString `json:"file_name"` - MaxParallelism int32 `json:"max_parallelism"` - ObjectKey R2ObjectKey `json:"object_key"` - PartSize int64 `json:"part_size"` - Phase EpisodeUploadSessionPhase `json:"phase"` - Processing *EpisodeProcessingSnapshot `json:"processing,omitempty"` - ShowId ShowID `json:"show_id"` - SourceDurationSeconds *int64 `json:"source_duration_seconds,omitempty"` - SourceSha256 SHA256Hex `json:"source_sha256"` - TeamId TeamID `json:"team_id"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - Title NonEmptyString `json:"title"` - UploadSessionId UploadSessionID `json:"upload_session_id"` -} - -type EpisodeUploadSessionPhase string - -const ( - EpisodeUploadSessionPhaseCreated EpisodeUploadSessionPhase = "created" - EpisodeUploadSessionPhaseUploading EpisodeUploadSessionPhase = "uploading" - EpisodeUploadSessionPhaseProcessing EpisodeUploadSessionPhase = "processing" - EpisodeUploadSessionPhaseCompleted EpisodeUploadSessionPhase = "completed" - EpisodeUploadSessionPhaseFailed EpisodeUploadSessionPhase = "failed" - EpisodeUploadSessionPhaseExpired EpisodeUploadSessionPhase = "expired" -) - -type FeedID = string - -type ImageAsset struct { - ByteLength int64 `json:"byte_length"` - ContentType string `json:"content_type"` - HasTransparency bool `json:"has_transparency"` - HeightPixels int64 `json:"height_pixels"` - Id ImageAssetID `json:"id"` - PublicUrl string `json:"public_url"` - WidthPixels int64 `json:"width_pixels"` -} - -type ImageAssetID = string - -type ImageUploadObjectKey = string - -type ImportRSSRequest struct { - Slug *ShowSlug `json:"slug,omitempty"` - SourceUrl string `json:"source_url"` -} - -type ImportRunID = string - -type MediaUploadPartNumber = int32 - -type MediaVersionID = string - -type NonEmptyString = string - -type OpenAPIJSONDocument struct { -} - -type OpenAPIYAMLDocument = string - -type PatchEpisodeUploadSession struct { - Description *string `json:"description,omitempty"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` -} - -type PendingShowInvitation struct { - Email string `json:"email"` - ExpiresAt UnixMillis `json:"expires_at"` - Id ShowInvitationID `json:"id"` - InvitedAt UnixMillis `json:"invited_at"` - Role AssignableTeamRole `json:"role"` -} - -type PendingTeamInvitation struct { - Email string `json:"email"` - ExpiresAt UnixMillis `json:"expires_at"` - Id TeamInvitationID `json:"id"` - InvitedAt UnixMillis `json:"invited_at"` - Role AssignableTeamRole `json:"role"` -} - -type PreparedMediaObject struct { - ByteLength int64 `json:"byte_length"` - ContentType string `json:"content_type"` - Name string `json:"name"` - Sha256 SHA256Hex `json:"sha256"` -} - -type PresignEpisodeUploadSessionParts struct { - PartNumbers []MediaUploadPartNumber `json:"part_numbers"` -} - -type PresignedEpisodeUploadPart struct { - ExpiresAt UnixMillis `json:"expires_at"` - Method string `json:"method"` - PartNumber MediaUploadPartNumber `json:"part_number"` - UploadUrl string `json:"upload_url"` -} - -type PresignedEpisodeUploadParts struct { - Parts []PresignedEpisodeUploadPart `json:"parts"` - UploadSessionId UploadSessionID `json:"upload_session_id"` -} - -type ProcessingRunID = string - -type PublicRSSImportCompletedTerminalEvent struct { - FeedId FeedID `json:"feed_id"` - ShowId ShowID `json:"show_id"` - ShowSlug ShowSlug `json:"show_slug"` - Status string `json:"status"` - TeamId TeamID `json:"team_id"` - Type string `json:"type"` -} - -// PublicRSSImportEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: RSSImportProgressEvent -// oneOf variant: PublicRSSImportTerminalEvent -type PublicRSSImportEvent struct { - RSSImportProgressEvent *RSSImportProgressEvent - PublicRSSImportTerminalEvent *PublicRSSImportTerminalEvent -} - -func (dst *PublicRSSImportEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"type"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode PublicRSSImportEvent discriminator: %w", err) - } - switch discriminator.Value { - case "progress": - var decoded RSSImportProgressEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode PublicRSSImportEvent as RSSImportProgressEvent: %w", err) - } - *dst = PublicRSSImportEvent{RSSImportProgressEvent: &decoded} - return nil - case "terminal": - var decoded PublicRSSImportTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode PublicRSSImportEvent as PublicRSSImportTerminalEvent: %w", err) - } - *dst = PublicRSSImportEvent{PublicRSSImportTerminalEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported PublicRSSImportEvent discriminator %q", discriminator.Value) - } -} - -func (src PublicRSSImportEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.RSSImportProgressEvent != nil { - matchCount++ - } - if src.PublicRSSImportTerminalEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("PublicRSSImportEvent must contain exactly one variant, got %d", matchCount) - } - if src.RSSImportProgressEvent != nil { - return json.Marshal(src.RSSImportProgressEvent) - } - if src.PublicRSSImportTerminalEvent != nil { - return json.Marshal(src.PublicRSSImportTerminalEvent) - } - return nil, fmt.Errorf("PublicRSSImportEvent has no variant") -} - -func (src PublicRSSImportEvent) GetActualInstance() any { - if src.RSSImportProgressEvent != nil { - return src.RSSImportProgressEvent - } - if src.PublicRSSImportTerminalEvent != nil { - return src.PublicRSSImportTerminalEvent - } - return nil -} - -func RSSImportProgressEventAsPublicRSSImportEvent(v RSSImportProgressEvent) PublicRSSImportEvent { - return PublicRSSImportEvent{RSSImportProgressEvent: &v} -} - -func PublicRSSImportTerminalEventAsPublicRSSImportEvent(v PublicRSSImportTerminalEvent) PublicRSSImportEvent { - return PublicRSSImportEvent{PublicRSSImportTerminalEvent: &v} -} - -// PublicRSSImportTerminalEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: PublicRSSImportCompletedTerminalEvent -// oneOf variant: RSSImportFailedTerminalEvent -// oneOf variant: RSSImportCancelledTerminalEvent -type PublicRSSImportTerminalEvent struct { - PublicRSSImportCompletedTerminalEvent *PublicRSSImportCompletedTerminalEvent - RSSImportFailedTerminalEvent *RSSImportFailedTerminalEvent - RSSImportCancelledTerminalEvent *RSSImportCancelledTerminalEvent -} - -func (dst *PublicRSSImportTerminalEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"status"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode PublicRSSImportTerminalEvent discriminator: %w", err) - } - switch discriminator.Value { - case "cancelled": - var decoded RSSImportCancelledTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode PublicRSSImportTerminalEvent as RSSImportCancelledTerminalEvent: %w", err) - } - *dst = PublicRSSImportTerminalEvent{RSSImportCancelledTerminalEvent: &decoded} - return nil - case "completed": - var decoded PublicRSSImportCompletedTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode PublicRSSImportTerminalEvent as PublicRSSImportCompletedTerminalEvent: %w", err) - } - *dst = PublicRSSImportTerminalEvent{PublicRSSImportCompletedTerminalEvent: &decoded} - return nil - case "failed": - var decoded RSSImportFailedTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode PublicRSSImportTerminalEvent as RSSImportFailedTerminalEvent: %w", err) - } - *dst = PublicRSSImportTerminalEvent{RSSImportFailedTerminalEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported PublicRSSImportTerminalEvent discriminator %q", discriminator.Value) - } -} - -func (src PublicRSSImportTerminalEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.PublicRSSImportCompletedTerminalEvent != nil { - matchCount++ - } - if src.RSSImportFailedTerminalEvent != nil { - matchCount++ - } - if src.RSSImportCancelledTerminalEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("PublicRSSImportTerminalEvent must contain exactly one variant, got %d", matchCount) - } - if src.PublicRSSImportCompletedTerminalEvent != nil { - return json.Marshal(src.PublicRSSImportCompletedTerminalEvent) - } - if src.RSSImportFailedTerminalEvent != nil { - return json.Marshal(src.RSSImportFailedTerminalEvent) - } - if src.RSSImportCancelledTerminalEvent != nil { - return json.Marshal(src.RSSImportCancelledTerminalEvent) - } - return nil, fmt.Errorf("PublicRSSImportTerminalEvent has no variant") -} - -func (src PublicRSSImportTerminalEvent) GetActualInstance() any { - if src.PublicRSSImportCompletedTerminalEvent != nil { - return src.PublicRSSImportCompletedTerminalEvent - } - if src.RSSImportFailedTerminalEvent != nil { - return src.RSSImportFailedTerminalEvent - } - if src.RSSImportCancelledTerminalEvent != nil { - return src.RSSImportCancelledTerminalEvent - } - return nil -} - -func PublicRSSImportCompletedTerminalEventAsPublicRSSImportTerminalEvent(v PublicRSSImportCompletedTerminalEvent) PublicRSSImportTerminalEvent { - return PublicRSSImportTerminalEvent{PublicRSSImportCompletedTerminalEvent: &v} -} - -func RSSImportFailedTerminalEventAsPublicRSSImportTerminalEvent(v RSSImportFailedTerminalEvent) PublicRSSImportTerminalEvent { - return PublicRSSImportTerminalEvent{RSSImportFailedTerminalEvent: &v} -} - -func RSSImportCancelledTerminalEventAsPublicRSSImportTerminalEvent(v RSSImportCancelledTerminalEvent) PublicRSSImportTerminalEvent { - return PublicRSSImportTerminalEvent{RSSImportCancelledTerminalEvent: &v} -} - -type R2ObjectKey = string - -type RSSImportCancelledTerminalEvent struct { - Status string `json:"status"` - Type string `json:"type"` -} - -type RSSImportEntitlementError struct { - Code string `json:"code"` - CurrentEntitlement VideoDeliveryEntitlement `json:"current_entitlement"` - MediaKind RSSImportMediaKind `json:"media_kind"` - PricingUrl string `json:"pricing_url"` - RequiredEntitlement VideoDeliveryEntitlement `json:"required_entitlement"` -} - -type RSSImportFailedTerminalEvent struct { - ErrorCode NonEmptyString `json:"error_code"` - ErrorMessage NonEmptyString `json:"error_message"` - HttpStatus *int64 `json:"http_status,omitempty"` - LimitSeconds *int64 `json:"limit_seconds,omitempty"` - PricingUrl *string `json:"pricing_url,omitempty"` - RemainingSeconds *int64 `json:"remaining_seconds,omitempty"` - RequestedSeconds *int64 `json:"requested_seconds,omitempty"` - RequiredNextPlan *NonEmptyString `json:"required_next_plan,omitempty"` - ReservedSeconds *int64 `json:"reserved_seconds,omitempty"` - RetainedSeconds *int64 `json:"retained_seconds,omitempty"` - SalesContactRequired *bool `json:"sales_contact_required,omitempty"` - Status string `json:"status"` - Type string `json:"type"` -} - -type RSSImportMediaKind string - -const ( - RSSImportMediaKindAudio RSSImportMediaKind = "audio" - RSSImportMediaKindVideo RSSImportMediaKind = "video" -) - -type RSSImportProgressEvent struct { - CurrentStep NonEmptyString `json:"current_step"` - Percent int32 `json:"percent"` - Type string `json:"type"` -} - -type SHA256Hex = string - -type Show struct { - Id ShowID `json:"id"` - Language ShowLanguage `json:"language"` - Slug ShowSlug `json:"slug"` - SourceKind ShowSourceKind `json:"source_kind"` - TeamId TeamID `json:"team_id"` - Title NonEmptyString `json:"title"` -} - -type ShowAccessSource string - -const ( - ShowAccessSourceTeam ShowAccessSource = "team" - ShowAccessSourceShow ShowAccessSource = "show" -) - -type ShowDeletionCompletedEvent struct { - ShowDeletionRunId ShowDeletionRunID `json:"show_deletion_run_id"` - ShowSlug ShowSlug `json:"show_slug"` - Status string `json:"status"` - Type string `json:"type"` -} - -// ShowDeletionEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: ShowDeletionProgressEvent -// oneOf variant: ShowDeletionTerminalEvent -type ShowDeletionEvent struct { - ShowDeletionProgressEvent *ShowDeletionProgressEvent - ShowDeletionTerminalEvent *ShowDeletionTerminalEvent -} - -func (dst *ShowDeletionEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"type"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode ShowDeletionEvent discriminator: %w", err) - } - switch discriminator.Value { - case "progress": - var decoded ShowDeletionProgressEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode ShowDeletionEvent as ShowDeletionProgressEvent: %w", err) - } - *dst = ShowDeletionEvent{ShowDeletionProgressEvent: &decoded} - return nil - case "terminal": - var decoded ShowDeletionTerminalEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode ShowDeletionEvent as ShowDeletionTerminalEvent: %w", err) - } - *dst = ShowDeletionEvent{ShowDeletionTerminalEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported ShowDeletionEvent discriminator %q", discriminator.Value) - } -} - -func (src ShowDeletionEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.ShowDeletionProgressEvent != nil { - matchCount++ - } - if src.ShowDeletionTerminalEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("ShowDeletionEvent must contain exactly one variant, got %d", matchCount) - } - if src.ShowDeletionProgressEvent != nil { - return json.Marshal(src.ShowDeletionProgressEvent) - } - if src.ShowDeletionTerminalEvent != nil { - return json.Marshal(src.ShowDeletionTerminalEvent) - } - return nil, fmt.Errorf("ShowDeletionEvent has no variant") -} - -func (src ShowDeletionEvent) GetActualInstance() any { - if src.ShowDeletionProgressEvent != nil { - return src.ShowDeletionProgressEvent - } - if src.ShowDeletionTerminalEvent != nil { - return src.ShowDeletionTerminalEvent - } - return nil -} - -func ShowDeletionProgressEventAsShowDeletionEvent(v ShowDeletionProgressEvent) ShowDeletionEvent { - return ShowDeletionEvent{ShowDeletionProgressEvent: &v} -} - -func ShowDeletionTerminalEventAsShowDeletionEvent(v ShowDeletionTerminalEvent) ShowDeletionEvent { - return ShowDeletionEvent{ShowDeletionTerminalEvent: &v} -} - -type ShowDeletionFailedEvent struct { - ErrorCode NonEmptyString `json:"error_code"` - ErrorMessage NonEmptyString `json:"error_message"` - ShowDeletionRunId ShowDeletionRunID `json:"show_deletion_run_id"` - Status string `json:"status"` - Type string `json:"type"` -} - -type ShowDeletionProgressEvent struct { - CurrentStep NonEmptyString `json:"current_step"` - Percent int32 `json:"percent"` - ShowDeletionRunId ShowDeletionRunID `json:"show_deletion_run_id"` - Status string `json:"status"` - Type string `json:"type"` -} - -type ShowDeletionRunID = string - -// ShowDeletionTerminalEvent is generated from an OpenAPI oneOf schema. -// oneOf variant: ShowDeletionCompletedEvent -// oneOf variant: ShowDeletionFailedEvent -type ShowDeletionTerminalEvent struct { - ShowDeletionCompletedEvent *ShowDeletionCompletedEvent - ShowDeletionFailedEvent *ShowDeletionFailedEvent -} - -func (dst *ShowDeletionTerminalEvent) UnmarshalJSON(data []byte) error { - var discriminator struct { - Value string `json:"status"` - } - if err := json.Unmarshal(data, &discriminator); err != nil { - return fmt.Errorf("decode ShowDeletionTerminalEvent discriminator: %w", err) - } - switch discriminator.Value { - case "completed": - var decoded ShowDeletionCompletedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode ShowDeletionTerminalEvent as ShowDeletionCompletedEvent: %w", err) - } - *dst = ShowDeletionTerminalEvent{ShowDeletionCompletedEvent: &decoded} - return nil - case "failed": - var decoded ShowDeletionFailedEvent - if err := json.Unmarshal(data, &decoded); err != nil { - return fmt.Errorf("decode ShowDeletionTerminalEvent as ShowDeletionFailedEvent: %w", err) - } - *dst = ShowDeletionTerminalEvent{ShowDeletionFailedEvent: &decoded} - return nil - default: - return fmt.Errorf("unsupported ShowDeletionTerminalEvent discriminator %q", discriminator.Value) - } -} - -func (src ShowDeletionTerminalEvent) MarshalJSON() ([]byte, error) { - matchCount := 0 - if src.ShowDeletionCompletedEvent != nil { - matchCount++ - } - if src.ShowDeletionFailedEvent != nil { - matchCount++ - } - if matchCount != 1 { - return nil, fmt.Errorf("ShowDeletionTerminalEvent must contain exactly one variant, got %d", matchCount) - } - if src.ShowDeletionCompletedEvent != nil { - return json.Marshal(src.ShowDeletionCompletedEvent) - } - if src.ShowDeletionFailedEvent != nil { - return json.Marshal(src.ShowDeletionFailedEvent) - } - return nil, fmt.Errorf("ShowDeletionTerminalEvent has no variant") -} - -func (src ShowDeletionTerminalEvent) GetActualInstance() any { - if src.ShowDeletionCompletedEvent != nil { - return src.ShowDeletionCompletedEvent - } - if src.ShowDeletionFailedEvent != nil { - return src.ShowDeletionFailedEvent - } - return nil -} - -func ShowDeletionCompletedEventAsShowDeletionTerminalEvent(v ShowDeletionCompletedEvent) ShowDeletionTerminalEvent { - return ShowDeletionTerminalEvent{ShowDeletionCompletedEvent: &v} -} - -func ShowDeletionFailedEventAsShowDeletionTerminalEvent(v ShowDeletionFailedEvent) ShowDeletionTerminalEvent { - return ShowDeletionTerminalEvent{ShowDeletionFailedEvent: &v} -} - -type ShowID = string - -type ShowInvitationID = string - -type ShowLanguage = string - -type ShowMember struct { - AccessSource ShowAccessSource `json:"access_source"` - JoinedAt UnixMillis `json:"joined_at"` - Role TeamRole `json:"role"` - User User `json:"user"` -} - -type ShowMemberCollection struct { - Invitations []PendingShowInvitation `json:"invitations"` - Members []ShowMember `json:"members"` -} - -type ShowSlug = string - -type ShowSourceKind string - -const ( - ShowSourceKindAudio ShowSourceKind = "audio" - ShowSourceKindVideo ShowSourceKind = "video" -) - -type TeamID = string - -type TeamInvitationID = string - -type TeamMember struct { - JoinedAt UnixMillis `json:"joined_at"` - Role TeamRole `json:"role"` - User User `json:"user"` -} - -type TeamMemberCollection struct { - Invitations []PendingTeamInvitation `json:"invitations"` - Members []TeamMember `json:"members"` -} - -type TeamRole string - -const ( - TeamRoleOwner TeamRole = "owner" - TeamRoleWrite TeamRole = "write" - TeamRoleRead TeamRole = "read" -) - -type UnixMillis = int64 - -type UpdateShowMemberRole struct { - Role AssignableTeamRole `json:"role"` -} - -type UpdateTeamMemberRole struct { - Role AssignableTeamRole `json:"role"` -} - -type UploadSessionID = string - -type User struct { - Email string `json:"email"` - Id UserID `json:"id"` - Name *NonEmptyString `json:"name,omitempty"` - PictureUrl *string `json:"picture_url,omitempty"` -} - -type UserID = string - -type ValidationErr struct { - Errors []ValidationIssue `json:"errors"` - Message NonEmptyString `json:"message"` -} - -type ValidationIssue struct { - Code ValidationIssueCode `json:"code"` - Field *string `json:"field,omitempty"` - In ValidationLocation `json:"in"` - Message NonEmptyString `json:"message"` - Path *[]String `json:"path,omitempty"` -} - -type ValidationIssueCode = string - -type ValidationLocation string - -const ( - ValidationLocationBody ValidationLocation = "body" - ValidationLocationQuery ValidationLocation = "query" - ValidationLocationPath ValidationLocation = "path" - ValidationLocationHeader ValidationLocation = "header" - ValidationLocationCookie ValidationLocation = "cookie" -) - -type VideoAdmissionError struct { - Code VideoAdmissionErrorCode `json:"code"` - CurrentPlan VideoPlan `json:"current_plan"` - DeliveryEntitlement VideoDeliveryEntitlement `json:"delivery_entitlement"` - LimitSeconds int64 `json:"limit_seconds"` - PricingUrl string `json:"pricing_url"` - RemainingSeconds int64 `json:"remaining_seconds"` - RequestedSeconds int64 `json:"requested_seconds"` - RequiredNextPlan VideoPlan `json:"required_next_plan"` - ReservedSeconds int64 `json:"reserved_seconds"` - RetainedSeconds int64 `json:"retained_seconds"` - SalesContactRequired bool `json:"sales_contact_required"` -} - -type VideoAdmissionErrorCode string - -const ( - VideoAdmissionErrorCodeVideoPlanRequired VideoAdmissionErrorCode = "video_plan_required" - VideoAdmissionErrorCodeVideoHoursExhausted VideoAdmissionErrorCode = "video_hours_exhausted" - VideoAdmissionErrorCodeVideoHoursExceeded VideoAdmissionErrorCode = "video_hours_exceeded" -) - -type VideoDeliveryEntitlement string - -const ( - VideoDeliveryEntitlementFree VideoDeliveryEntitlement = "free" - VideoDeliveryEntitlementAudio VideoDeliveryEntitlement = "audio" - VideoDeliveryEntitlementVideoHd VideoDeliveryEntitlement = "video_hd" - VideoDeliveryEntitlementVideo4k VideoDeliveryEntitlement = "video_4k" -) - -type VideoPlan string - -const ( - VideoPlanStarter VideoPlan = "starter" - VideoPlanProfessional VideoPlan = "professional" - VideoPlanStudio VideoPlan = "studio" -) - -type String = string diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..e630049 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.1" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..54162e4 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,153 @@ +use crate::{auth::StoredAuth, config::Config}; +use anyhow::{Context, Result, bail, ensure}; +use reqwest::{Client, RequestBuilder, Response}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::{future::Future, time::Duration}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct Api { + pub config: Config, + pub credential: Option, + pub http: Client, + pub cancel: CancellationToken, +} + +impl Api { + pub fn new(config: Config, cancel: CancellationToken) -> Result { + let credential = std::fs::read(config.directory.join("auth.json")) + .ok() + .filter(|raw| raw.len() <= 64 << 10) + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + .filter(|auth| crate::config::credential_valid(&auth.api_key)) + .map(|auth| auth.api_key); + let mut builder = Client::builder() + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(30 * 60)); + if let Some(path) = std::env::var_os("SSL_CERT_FILE") { + for certificate in reqwest::Certificate::from_pem_bundle(&std::fs::read(path)?)? { + builder = builder.add_root_certificate(certificate); + } + } + Ok(Self { + config, + credential, + http: builder.build()?, + cancel, + }) + } + + pub fn client(&self) -> crate::publicapi::Client { + crate::publicapi::Client::new( + self.http.clone(), + self.config.api_origin.clone(), + self.credential.clone(), + ) + } + + pub async fn wait(&self, work: impl Future>) -> Result { + tokio::select! { + biased; + _ = self.cancel.cancelled() => bail!("operation interrupted; resumable source upload state preserved"), + result = work => result, + } + } + + pub async fn send(&self, request: RequestBuilder) -> Result { + self.wait(async { + request + .send() + .await + .map_err(|error| error.without_url()) + .context("send HTTP request") + }) + .await + } + + pub async fn bytes(&self, response: Response, limit: usize) -> Result> { + self.wait(read_bounded(response, limit)).await + } + + pub async fn decode(&self, response: Response) -> Result { + serde_json::from_slice(&self.bytes(response, 4 << 20).await?).context("decode API response") + } + + pub async fn json(&self, request: RequestBuilder, statuses: &[u16]) -> Result { + let response = self.send(request).await?; + self.accept(response, statuses).await + } + + pub async fn accept(&self, response: Response, statuses: &[u16]) -> Result { + if !statuses.contains(&response.status().as_u16()) { + return Err(self.response_error(response).await); + } + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(Value::Null); + } + self.decode(response).await + } + + pub async fn response_error(&self, response: Response) -> anyhow::Error { + let status = response.status().as_u16(); + let raw = self.bytes(response, 64 << 10).await.unwrap_or_default(); + let message = serde_json::from_slice::(&raw) + .ok() + .and_then(|body| { + body.get("message") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| String::from_utf8_lossy(&raw).trim().to_owned()); + let hint = match status { + 401 => "authentication failed; run listenbox login", + 403 => "permission denied", + _ => &message, + }; + anyhow::anyhow!("HTTP {status}: {hint}") + } + + pub fn trace(&self, response: &Response, stdout: bool) -> Result<()> { + if self.config.print_trace_ids { + let trace = response + .headers() + .get("X-Trace-Id") + .context("response missing X-Trace-Id")? + .to_str()?; + crate::config::check_trace(trace)?; + if stdout { + println!("Trace ID: {trace}"); + } else { + eprintln!("Trace ID: {trace}"); + } + } + Ok(()) + } +} + +pub async fn read_bounded(mut response: Response, limit: usize) -> Result> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + ensure!( + chunk.len() <= limit.saturating_sub(body.len()), + "HTTP response exceeds {limit} bytes" + ); + body.extend_from_slice(&chunk); + } + Ok(body) +} + +pub fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .with_context(|| format!("response missing {key}")) +} + +pub fn number(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_i64) + .with_context(|| format!("response missing numeric {key}")) +} diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..b1b2f20 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,287 @@ +//! FFmpeg objects stay on one worker thread and are owned by ffmpeg-the-third's Rust wrappers. +use anyhow::{Context, Result, ensure}; +use ffmpeg::{ + ChannelLayout, ChannelLayoutMask, Dictionary, Packet, codec, format, frame, media, + software::resampling, +}; +use std::{ + collections::VecDeque, + fs::File, + io::{Read, Seek, SeekFrom}, + path::Path, +}; +use tokio_util::sync::CancellationToken; + +pub fn prepare_m4a(input: &Path, output: &Path, cancelled: &CancellationToken) -> Result { + ensure!(!cancelled.is_cancelled(), "Audio conversion cancelled"); + ensure!( + input != output && !output.exists(), + "Audio output must be a new file" + ); + ffmpeg::init()?; + let result = convert(input, output, cancelled).and_then(|()| { + ensure!(fast_start(output)?, "M4A fast-start validation failed"); + Ok(output.metadata()?.len()) + }); + if result.is_err() { + let _ = std::fs::remove_file(output); + } + result +} +fn convert(input: &Path, output: &Path, cancelled: &CancellationToken) -> Result<()> { + let mut input = format::input(input)?; + let source = input + .streams() + .best(media::Type::Audio) + .context("No audio stream")?; + let index = source.index(); + let time_base = source.time_base(); + let parameters = source.parameters(); + let mut output = format::output_as(output, "ipod")?; + if parameters.id() != codec::Id::AAC { + let decoder = codec::Context::from_parameters(parameters)? + .decoder() + .audio()?; + return transcode(input, output, index, decoder, cancelled); + } + + { + let mut stream = output.add_stream(ffmpeg::encoder::find(codec::Id::None))?; + stream.set_parameters(parameters); + stream.set_time_base(time_base); + // Reset the source-container codec tag when writing an M4A container. + // SAFETY: the uniquely borrowed output stream owns these codec parameters. + unsafe { + (*stream.parameters_mut().as_mut_ptr()).codec_tag = 0; + } + } + header(&mut output)?; + let destination_time = output.stream(0).context("No output stream")?.time_base(); + let mut count = 0; + loop { + ensure!(!cancelled.is_cancelled(), "Audio conversion cancelled"); + let mut packet = Packet::empty(); + match packet.read(&mut input) { + Ok(()) => (), + Err(ffmpeg::Error::Eof) => break, + Err(error) => return Err(error.into()), + } + if packet.stream() == index { + packet.rescale_ts(time_base, destination_time); + packet.set_stream(0); + packet.set_position(-1); + packet.write_interleaved(&mut output)?; + count += 1; + } + } + ensure!(count > 0, "Downloaded audio was empty"); + output.write_trailer()?; + Ok(()) +} +fn header(output: &mut format::context::Output) -> Result<()> { + let mut options = Dictionary::new(); + options.set("movflags", "+faststart"); + output.write_header_with(options)?; + Ok(()) +} +fn waiting(error: ffmpeg::Error) -> bool { + matches!(error, ffmpeg::Error::Eof) + || matches!(error, ffmpeg::Error::Other { errno } if std::io::Error::from_raw_os_error(errno).kind() == std::io::ErrorKind::WouldBlock) +} + +struct Transcoder { + decoder: codec::decoder::Audio, + encoder: codec::encoder::Audio, + resampler: resampling::Context, + fifo: Vec>, + output: format::context::Output, + layout: ChannelLayoutMask, + timestamp: i64, + output_time: ffmpeg::Rational, +} +impl Transcoder { + fn drain_encoder(&mut self) -> Result<()> { + loop { + let mut packet = Packet::empty(); + match self.encoder.receive_packet(&mut packet) { + Ok(()) => (), + Err(error) if waiting(error) => break, + Err(error) => return Err(error.into()), + } + packet.set_stream(0); + packet.rescale_ts((1, 48000), self.output_time); + packet.write_interleaved(&mut self.output)?; + } + Ok(()) + } + fn encode_ready(&mut self, finish: bool) -> Result<()> { + let frame_size = self.encoder.frame_size() as usize; + while self.fifo[0].len() >= frame_size || (finish && !self.fifo[0].is_empty()) { + let count = frame_size.min(self.fifo[0].len()); + let mut frame = frame::Audio::new(self.encoder.format(), count, self.layout); + frame.set_rate(48000); + for (channel, samples) in self.fifo.iter_mut().enumerate() { + for (target, value) in frame + .plane_mut::(channel) + .iter_mut() + .zip(samples.drain(..count)) + { + *target = value; + } + } + frame.set_pts(Some(self.timestamp)); + self.timestamp += count as i64; + self.encoder.send_frame(&frame)?; + self.drain_encoder()?; + } + Ok(()) + } + fn collect(&mut self, converted: &frame::Audio) -> Result<()> { + for (channel, fifo) in self.fifo.iter_mut().enumerate() { + fifo.extend(converted.plane::(channel)); + } + self.encode_ready(false) + } + fn drain_decoder(&mut self) -> Result<()> { + loop { + let mut decoded = frame::Audio::empty(); + match self.decoder.receive_frame(&mut decoded) { + Ok(()) => (), + Err(error) if waiting(error) => break, + Err(error) => return Err(error.into()), + } + let delay = self + .resampler + .delay() + .map_or(0, |delay| delay.output.max(0) as usize); + let capacity = (decoded.samples() as u64 * 48000) + .div_ceil(u64::from(self.decoder.rate())) as usize + + delay + + 32; + let mut converted = frame::Audio::new(self.encoder.format(), capacity, self.layout); + self.resampler.run(&decoded, &mut converted)?; + self.collect(&converted)?; + } + Ok(()) + } +} +fn transcode( + mut input: format::context::Input, + mut output: format::context::Output, + index: usize, + decoder: codec::decoder::Audio, + cancelled: &CancellationToken, +) -> Result<()> { + ensure!( + decoder.rate() > 0 && decoder.ch_layout().channels() > 0, + "Invalid input audio format" + ); + let layout = if decoder.ch_layout().channels() == 1 { + ChannelLayoutMask::MONO + } else { + ChannelLayoutMask::STEREO + }; + let codec = ffmpeg::encoder::find(codec::Id::AAC).context("Bundled AAC encoder unavailable")?; + let mut encoder = codec::Context::new_with_codec(codec).encoder().audio()?; + encoder.set_rate(48000); + encoder.set_format(format::Sample::F32(format::sample::Type::Planar)); + encoder.set_ch_layout(ChannelLayout::from_mask(layout).context("AAC channel layout")?); + encoder.set_bit_rate(if layout == ChannelLayoutMask::MONO { + 64000 + } else { + 128000 + }); + encoder.set_time_base((1, 48000)); + encoder.set_flags(codec::Flags::GLOBAL_HEADER); + let encoder = encoder.open_as(codec)?; + { + let mut stream = output.add_stream(codec)?; + stream.set_time_base((1, 48000)); + stream.set_parameters(codec::Parameters::from(&encoder)); + } + header(&mut output)?; + let output_time = output + .stream(0) + .context("No AAC output stream")? + .time_base(); + let resampler = resampling::Context::get2( + decoder.format(), + decoder.ch_layout(), + decoder.rate(), + encoder.format(), + ChannelLayout::from_mask(layout).context("AAC channel layout")?, + 48000, + )?; + let mut state = Transcoder { + decoder, + encoder, + resampler, + output, + layout, + output_time, + timestamp: 0, + fifo: vec![VecDeque::new(); layout.bits().count_ones() as usize], + }; + loop { + ensure!(!cancelled.is_cancelled(), "Audio conversion cancelled"); + let mut packet = Packet::empty(); + match packet.read(&mut input) { + Ok(()) => (), + Err(ffmpeg::Error::Eof) => break, + Err(error) => return Err(error.into()), + } + if packet.stream() == index { + state.decoder.send_packet(&packet)?; + state.drain_decoder()?; + } + } + state.decoder.send_eof()?; + state.drain_decoder()?; + loop { + ensure!(!cancelled.is_cancelled(), "Audio conversion cancelled"); + let capacity = state + .resampler + .delay() + .map_or(32, |delay| delay.output.max(0) as usize + 32); + let mut converted = frame::Audio::new(state.encoder.format(), capacity, layout); + let remaining = state.resampler.flush(&mut converted)?; + state.collect(&converted)?; + if remaining.is_none() || converted.samples() == 0 { + break; + } + } + state.encode_ready(true)?; + ensure!(state.timestamp > 0, "Downloaded audio was empty"); + state.encoder.send_eof()?; + state.drain_encoder()?; + state.output.write_trailer()?; + Ok(()) +} + +pub fn fast_start(path: &Path) -> Result { + let mut file = File::open(path)?; + let total = file.metadata()?.len(); + let mut offset = 0; + while offset + 8 <= total { + file.seek(SeekFrom::Start(offset))?; + let mut bytes = [0; 8]; + file.read_exact(&mut bytes)?; + let mut size = u64::from(u32::from_be_bytes(bytes[..4].try_into()?)); + let kind = bytes[4..8].to_vec(); + if size == 1 { + file.read_exact(&mut bytes)?; + size = u64::from_be_bytes(bytes); + } + if size < 8 || size > total - offset { + return Ok(false); + } + if kind == b"moov" { + return Ok(true); + } + if kind == b"mdat" { + return Ok(false); + } + offset += size; + } + Ok(false) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..62aea50 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,126 @@ +use crate::publicapi as p; +use crate::{ + api::{Api, string}, + config::{credential_valid, write_private_json}, + events::Events, +}; +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use url::Url; + +#[derive(Serialize, Deserialize)] +pub struct StoredAuth { + pub api_key: String, +} + +const SCOPES: &[&str] = &[ + "team:read", + "team:manage", + "show:read", + "show:create", + "show:update", + "episode:create", + "episode:delete", + "episode:update", + "episode:publish", +]; + +pub async fn login(api: &mut Api) -> Result<()> { + let mut response = api + .send( + api.client() + .create_cli_authorization(p::CreateCLIAuthorizationParams { + body: serde_json::from_value(json!({"scopes": SCOPES}))?, + }), + ) + .await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED && api.credential.is_some() { + api.credential = None; + response = api + .send( + api.client() + .create_cli_authorization(p::CreateCLIAuthorizationParams { + body: serde_json::from_value(json!({"scopes": SCOPES}))?, + }), + ) + .await?; + } + let created = api.accept(response, &[201]).await?; + let credential = string(&created, "credential")?; + ensure!(credential_valid(credential), "invalid pending credential"); + let mut verification = Url::parse(string(&created, "verification_url")?)?; + ensure!( + matches!(verification.scheme(), "http" | "https") + && verification.host_str().is_some() + && verification.username().is_empty() + && verification.password().is_none() + && verification.fragment().is_none(), + "invalid verification URL" + ); + let dashboard = Url::parse(&api.config.dashboard_origin)?; + verification + .set_scheme(dashboard.scheme()) + .map_err(|()| anyhow::anyhow!("invalid dashboard scheme"))?; + verification.set_host(dashboard.host_str())?; + verification + .set_port(dashboard.port()) + .map_err(|()| anyhow::anyhow!("invalid dashboard port"))?; + println!("Verification URL: {verification}"); + let mut events = Events::open( + api, + api.client() + .cli_authorization_events(p::CliAuthorizationEventsParams { + code: string(&created, "code")?.into(), + }), + false, + ) + .await?; + let event = events.next(api).await?; + match string(&event, "type")? { + "cli.authorization.approved" => { + api.credential = Some(credential.to_owned()); + let identity = api.json(api.client().whoami(), &[200]).await?; + ensure!( + string(&identity, "team_id")? == string(&event, "team_id")?, + "approved credential team does not match approved team" + ); + let scopes = identity["scopes"] + .as_array() + .context("identity missing scopes")?; + for scope in SCOPES { + ensure!( + scopes.iter().any(|value| value.as_str() == Some(scope)), + "approved credential missing scope {scope}" + ); + } + write_private_json( + &api.config.directory.join("auth.json"), + &StoredAuth { + api_key: credential.into(), + }, + ) + } + "cli.authorization.denied" => bail!("CLI authorization was denied"), + "cli.authorization.expired" => bail!("CLI authorization expired"), + kind => bail!("unexpected authorization event {kind:?}"), + } +} + +pub async fn status(api: &Api) -> Result<()> { + let identity = api.json(api.client().whoami(), &[200]).await?; + let email = string(&identity, "email")?.trim(); + ensure!(!email.is_empty(), "identity email is empty"); + let name = identity["name"] + .as_str() + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" "); + if name.is_empty() { + println!("Logged in as {email}"); + } else { + println!("Logged in as {name} <{email}>"); + } + Ok(()) +} diff --git a/src/cliconfig/mod.rs b/src/cliconfig/mod.rs new file mode 100644 index 0000000..6f086b7 --- /dev/null +++ b/src/cliconfig/mod.rs @@ -0,0 +1,15 @@ +// Code generated by oasmith. DO NOT EDIT. +#![allow(dead_code, clippy::large_enum_variant, clippy::enum_variant_names)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIConfig { + #[serde(rename = "api_origin")] + pub api_origin: std::string::String, + #[serde(rename = "dashboard_origin")] + pub dashboard_origin: std::string::String, + #[serde(rename = "print_trace_ids")] + pub print_trace_ids: bool, +} + diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..c3159b1 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,331 @@ +use crate::{ + MemberCommand, ShowCommand, + api::{Api, number, string}, + events::{Events, Progress}, + publicapi as p, +}; +use anyhow::{Context, Result, bail, ensure}; +use serde_json::json; + +pub async fn shows(api: &Api, command: ShowCommand) -> Result<()> { + match command { + ShowCommand::List => { + let result = api.json(api.client().list_shows(), &[200]).await?; + let shows: Vec = serde_json::from_value(result)?; + for show in shows { + println!("{}", show.slug); + } + } + ShowCommand::Create { + title, + slug, + source_kind, + language, + artwork, + } => { + let id = format!("shw_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]); + let image_asset_id = match artwork { + Some(path) => Some(upload_artwork(api, &id, &path).await?), + None => None, + }; + let request = api.client().create_show(p::CreateShowParams { + body: p::CreateShow { + id, + title, + slug: slug.clone(), + language, + image_asset_id, + source_kind: serde_json::from_value(serde_json::to_value(source_kind)?)?, + }, + }); + let response = api.send(request).await?; + if response.status() == reqwest::StatusCode::CONFLICT { + bail!("create show: slug {slug:?} already exists"); + } + let show: p::Show = serde_json::from_value(api.accept(response, &[201]).await?)?; + println!( + "Created show {:?}\nOpen in Listenbox: {}", + show.slug, + api.config.show_url(&show.team_id, &show.id)? + ); + } + ShowCommand::Delete { show, yes } => { + ensure!(yes, "shows delete: --yes is required"); + let result = api + .json( + api.client() + .create_show_deletion(p::CreateShowDeletionParams { + show_slug: show.clone(), + }), + &[202], + ) + .await?; + delete_events( + api, + api.client() + .show_deletion_events(p::ShowDeletionEventsParams { + show_deletion_run_id: string(&result, "show_deletion_run_id")?.into(), + }), + "show", + "show_slug", + &show, + ) + .await?; + } + } + Ok(()) +} + +async fn upload_artwork(api: &Api, show: &str, path: &std::path::Path) -> Result { + let raw = std::fs::read(path).with_context(|| format!("read artwork {}", path.display()))?; + let reader = image::ImageReader::new(std::io::Cursor::new(&raw)).with_guessed_format()?; + let content_type = match reader.format() { + Some(image::ImageFormat::Jpeg) => "image/jpeg", + Some(image::ImageFormat::Png) => "image/png", + _ => bail!("artwork must be a JPEG or PNG image"), + }; + let (width, height) = reader + .into_dimensions() + .context("read artwork dimensions")?; + ensure!( + width == height && (1400..=3000).contains(&width), + "upload a square image between 1400 and 3000 pixels. Attempted resolution: {width} × {height} pixels" + ); + let result = api.json(api.client().create_image_upload_presign(p::CreateImageUploadPresignParams { body: serde_json::from_value(json!({ + "show_id": show, "byte_length": raw.len(), "content_type": content_type, + "file_name": path.file_name().context("artwork filename")?.to_string_lossy(), + }))? }), &[201]).await?; + let response = api + .send( + api.http + .request( + string(&result, "method")?.parse()?, + string(&result, "upload_url")?, + ) + .header("Content-Type", content_type) + .body(raw), + ) + .await?; + ensure!( + response.status().is_success(), + "upload artwork: HTTP {}", + response.status() + ); + let completed = api + .json( + api.client() + .complete_image_upload(p::CompleteImageUploadParams { + image_asset_id: string(&result, "image_asset_id")?.into(), + body: p::CompleteImageUpload { + object_key: string(&result, "object_key")?.into(), + }, + }), + &[201], + ) + .await?; + Ok(string(&completed, "id")?.into()) +} + +pub async fn import_rss(api: &Api, source: &str, slug: Option<&str>) -> Result<()> { + let response = api + .send(api.client().import_rss(p::ImportRSSParams { + body: p::ImportRSSRequest { + source_url: source.into(), + slug: slug.map(str::to_owned), + }, + })) + .await?; + if response.status() == reqwest::StatusCode::CONFLICT { + if let Some(slug) = slug { + bail!("requested slug {slug:?} conflicts"); + } + bail!("import choice conflicts"); + } + if response.status() != reqwest::StatusCode::ACCEPTED { + return Err(api.response_error(response).await); + } + api.trace(&response, true)?; + let created: p::CreatedPublicRSSImport = api.decode(response).await?; + let mut stream = Events::open( + api, + api.client() + .import_rss_run_events(p::ImportRSSRunEventsParams { + import_run_id: created.import_run_id, + }), + false, + ) + .await?; + let mut progress = Progress::default(); + loop { + let event = stream.next(api).await?; + match string(&event, "type")? { + "progress" => progress.update(number(&event, "percent")?)?, + "terminal" => match string(&event, "status")? { + "completed" => { + let slug = + crate::slug(string(&event, "show_slug")?).map_err(anyhow::Error::msg)?; + ensure!( + crate::valid_id(string(&event, "feed_id")?, "lb_"), + "invalid import feed ID" + ); + let location = api + .config + .show_url(string(&event, "team_id")?, string(&event, "show_id")?)?; + progress.update(100)?; + println!("{slug}"); + eprintln!("Open in Listenbox: {location}"); + return Ok(()); + } + "cancelled" => bail!("RSS import was cancelled"), + "failed" => bail!( + "RSS import failed ({}): {}", + string(&event, "error_code")?, + string(&event, "error_message")? + ), + status => bail!("malformed import terminal status {status:?}"), + }, + kind => bail!("malformed import event {kind:?}"), + } + } +} + +pub async fn delete_events( + api: &Api, + request: reqwest::RequestBuilder, + kind: &str, + identity_key: &str, + identity: &str, +) -> Result<()> { + let mut stream = Events::open(api, request, false).await?; + let mut progress = Progress::default(); + loop { + let event = stream.next(api).await?; + match string(&event, "type")? { + "progress" => { + string(&event, "current_step")?; + progress.update(number(&event, "percent")?)?; + } + "terminal" => match string(&event, "status")? { + "completed" => { + ensure!( + string(&event, identity_key)? == identity, + "deletion completed for another {kind}" + ); + progress.update(100)?; + println!("Deleted {kind} {identity:?}"); + return Ok(()); + } + "failed" => bail!( + "delete {kind} failed ({}): {}", + string(&event, "error_code")?, + string(&event, "error_message")? + ), + status => bail!("malformed deletion terminal status {status:?}"), + }, + kind => bail!("malformed deletion event {kind:?}"), + } + } +} + +pub async fn members(api: &Api, command: MemberCommand) -> Result<()> { + let client = api.client(); + match command { + MemberCommand::List { show } => { + let request = match &show { + Some(show) => client.list_show_members(p::ListShowMembersParams { + show_slug: show.clone(), + }), + None => client.list_team_members(), + }; + let result = api.json(request, &[200]).await?; + for member in result["members"].as_array().context("missing members")? { + if show.is_some() { + print!("{}\t", string(member, "access_source")?); + } + println!( + "{}\t{}\t{}", + string(member, "role")?, + string(&member["user"], "email")?, + string(&member["user"], "id")? + ); + } + for invite in result["invitations"] + .as_array() + .context("missing invitations")? + { + let expiry = chrono::DateTime::from_timestamp_millis(number(invite, "expires_at")?) + .context("invalid invitation expiry")?; + println!( + "pending\t{}\t{}\t{}\t{}", + string(invite, "role")?, + string(invite, "email")?, + string(invite, "id")?, + expiry.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + ); + } + } + MemberCommand::Invite { email, role, show } => { + let body = json!({"email": email.trim().to_lowercase(), "role": role}); + let request = match &show { + Some(show) => client.create_show_invitation(p::CreateShowInvitationParams { + show_slug: show.clone(), + body: serde_json::from_value(body)?, + }), + None => client.create_team_invitation(p::CreateTeamInvitationParams { + body: serde_json::from_value(body)?, + }), + }; + let result = api.json(request, &[201]).await?; + let target = show.map_or(String::new(), |show| format!(" to {show}")); + println!( + "Invited {}{target} as {}.", + string(&result, "email")?, + string(&result, "role")? + ); + } + MemberCommand::Role { member, role, show } => { + let label = if show.is_some() { + "show member" + } else { + "member" + }; + let body = json!({"role": role}); + let request = match show { + Some(show) => client.update_show_member_role(p::UpdateShowMemberRoleParams { + show_slug: show, + user_id: member.clone(), + body: serde_json::from_value(body.clone())?, + }), + None => client.update_team_member_role(p::UpdateTeamMemberRoleParams { + user_id: member.clone(), + body: serde_json::from_value(body.clone())?, + }), + }; + api.json(request, &[204]).await?; + println!( + "Changed {label} {member} to {}.", + body["role"].as_str().unwrap_or("") + ); + } + MemberCommand::Remove { member, yes, show } => { + let label = if show.is_some() { + "show member" + } else { + "member" + }; + ensure!(yes, "members remove: --yes is required"); + let request = match show { + Some(show) => client.remove_show_member(p::RemoveShowMemberParams { + show_slug: show, + user_id: member.clone(), + }), + None => client.remove_team_member(p::RemoveTeamMemberParams { + user_id: member.clone(), + }), + }; + api.json(request, &[204]).await?; + println!("Removed {label} {member}."); + } + } + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b882e3b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,156 @@ +use crate::cliconfig::CLIConfig; +use anyhow::{Context, Result, bail, ensure}; +use serde::Serialize; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; +use url::Url; + +#[derive(Clone)] +pub struct Config { + values: CLIConfig, + pub directory: PathBuf, +} + +impl std::ops::Deref for Config { + type Target = CLIConfig; + fn deref(&self) -> &Self::Target { + &self.values + } +} + +impl Config { + pub fn load(explicit: Option<&Path>) -> Result { + let home = std::env::var_os("HOME").context("resolve user home directory")?; + Self::load_in(explicit, PathBuf::from(home).join(".config/listenbox")) + } + + fn load_in(explicit: Option<&Path>, directory: PathBuf) -> Result { + let default_path = directory.join("config.yaml"); + let path = explicit.unwrap_or(&default_path); + ensure!( + !path.as_os_str().is_empty(), + "explicit config path is empty" + ); + let mut values = match fs::read(path) { + Ok(raw) => serde_yaml::from_slice::(&raw) + .with_context(|| format!("decode CLI config {}", path.display()))?, + Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => { + CLIConfig { + api_origin: "https://v1.listenbox.app".into(), + dashboard_origin: "https://web.listenbox.app".into(), + print_trace_ids: false, + } + } + Err(error) => { + return Err(error).with_context(|| format!("load CLI config {}", path.display())); + } + }; + values.api_origin = origin(&values.api_origin).context("validate api_origin")?; + values.dashboard_origin = + origin(&values.dashboard_origin).context("validate dashboard_origin")?; + Ok(Self { values, directory }) + } + + pub fn show_url(&self, team: &str, show: &str) -> Result { + ensure!( + crate::valid_id(team, "team_") && crate::valid_id(show, "shw_"), + "invalid show management identifiers" + ); + Ok(format!("{}/{team}/shows/{show}", self.dashboard_origin)) + } +} + +fn origin(value: &str) -> Result { + let value = value.trim(); + let (_, authority) = value + .split_once("://") + .context("origin must have an explicit HTTP(S) authority")?; + ensure!( + !authority.is_empty() + && !authority.starts_with('/') + && !authority.contains(['@', '\\', '?', '#']) + && authority + .split_once('/') + .is_none_or(|(_, path)| path.is_empty()), + "origin must contain only a host and optional port" + ); + let parsed = Url::parse(value).context("invalid origin URL")?; + ensure!( + matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), + "origin must be an absolute HTTP(S) URL" + ); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "origin must not include user information" + ); + ensure!( + matches!(parsed.path(), "" | "/") + && parsed.query().is_none() + && parsed.fragment().is_none(), + "origin must not include a path, query or fragment" + ); + Ok(parsed.origin().ascii_serialization()) +} + +pub fn write_private_json(path: &Path, value: &impl Serialize) -> Result<()> { + let directory = path + .parent() + .context("private file requires parent directory")?; + fs::create_dir_all(directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?; + } + let mut temporary = tempfile::NamedTempFile::new_in(directory)?; + serde_json::to_writer_pretty(&mut temporary, value)?; + temporary.write_all(b"\n")?; + temporary.as_file().sync_all()?; + temporary + .persist(path) + .with_context(|| format!("replace {}", path.display()))?; + #[cfg(unix)] + fs::File::open(directory)?.sync_all()?; + Ok(()) +} + +pub fn credential_valid(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|c| (0x21..=0x7e).contains(&c)) +} + +pub fn check_trace(value: &str) -> Result<()> { + if value.len() != 32 + || !value + .bytes() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + || value.bytes().all(|c| c == b'0') + { + bail!("invalid X-Trace-Id {value:?}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_user_config_uses_production_defaults() { + let home = tempfile::tempdir().unwrap(); + let config = Config::load_in(None, home.path().into()).unwrap(); + assert_eq!(config.api_origin, "https://v1.listenbox.app"); + assert_eq!(config.dashboard_origin, "https://web.listenbox.app"); + assert!(!config.print_trace_ids); + } + #[test] + fn generated_config_decodes_yaml_and_normalizes_origins() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join("config.yaml"), "api_origin: http://EXAMPLE.com:80/\ndashboard_origin: https://WEB.example.com:443/\nprint_trace_ids: true\n").unwrap(); + let config = Config::load_in(None, home.path().into()).unwrap(); + assert_eq!(config.api_origin, "http://example.com"); + assert_eq!(config.dashboard_origin, "https://web.example.com"); + assert!(config.print_trace_ids); + } +} diff --git a/src/episodes.rs b/src/episodes.rs new file mode 100644 index 0000000..4a20bf7 --- /dev/null +++ b/src/episodes.rs @@ -0,0 +1,369 @@ +use crate::{ + EpisodeCommand, EpisodeCreate, Publication, + api::{Api, number, string}, + config::write_private_json, + events::Events, + publicapi as p, +}; +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, path::Path}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +#[derive(Serialize, Deserialize)] +struct Resume { + version: u8, + episode_id: String, + upload_session_id: String, + team_id: String, + show_id: String, + management_url: String, + file_path: String, + file_sha256: String, + file_byte_length: u64, + completed_parts: Vec, + expires_at: i64, + phase: String, +} + +pub async fn run(api: &Api, command: EpisodeCommand) -> Result<()> { + match command { + EpisodeCommand::List { show, limit } => { + let mut cursor = None; + let mut seen = HashSet::new(); + loop { + let page = api + .json( + api.client().list_episodes(p::ListEpisodesParams { + show_slug: show.clone(), + limit: Some(i64::from(limit)), + cursor, + }), + &[200], + ) + .await?; + for episode in page["episodes"] + .as_array() + .context("episode page missing episodes")? + { + let id = string(episode, "id")?; + ensure!(crate::valid_id(id, "ep_"), "invalid episode ID in list"); + println!("{id}"); + } + cursor = page["next_cursor"].as_str().map(str::to_owned); + let Some(next) = &cursor else { + return Ok(()); + }; + ensure!( + !next.is_empty() && seen.insert(next.clone()), + "empty or repeated episode list cursor" + ); + } + } + EpisodeCommand::Create(args) => create(api, args).await, + EpisodeCommand::Delete { episode, yes } => { + ensure!(yes, "episodes delete: --yes is required"); + let result = api + .json( + api.client() + .create_episode_deletion(p::CreateEpisodeDeletionParams { + episode_id: episode.clone(), + }), + &[202], + ) + .await?; + crate::commands::delete_events( + api, + api.client() + .episode_deletion_events(p::EpisodeDeletionEventsParams { + episode_deletion_run_id: string(&result, "episode_deletion_run_id")?.into(), + }), + "episode", + "episode_id", + &episode, + ) + .await + } + } +} + +pub async fn file_hash(path: &Path) -> Result<(u64, String)> { + let mut file = tokio::fs::File::open(path) + .await + .with_context(|| format!("open --file {:?}", path))?; + let metadata = file.metadata().await?; + ensure!( + metadata.is_file() && metadata.len() > 0, + "--file must be a non-empty regular file" + ); + let mut buffer = [0; 64 * 1024]; + let mut hash = Sha256::new(); + loop { + let count = file.read(&mut buffer).await?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok((metadata.len(), hex::encode(hash.finalize()))) +} + +async fn create(api: &Api, args: EpisodeCreate) -> Result<()> { + let path = std::path::absolute(&args.file)?; + let (length, hash) = file_hash(&path).await?; + let content_type = match path + .extension() + .and_then(|v| v.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "mp3" => "audio/mpeg", + "m4a" => "audio/mp4", + "wav" => "audio/wav", + "flac" => "audio/flac", + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + _ => bail!( + "unsupported media upload file; accepted extensions: .mp3, .m4a, .wav, .flac, .mp4, .m4v, .mov" + ), + }; + let key = hex::encode(Sha256::digest(format!("{}\0{hash}", args.show))); + let resume_path = api + .config + .directory + .join("episode-resume") + .join(format!("{key}.json")); + let mut record = match std::fs::read(&resume_path) { + Ok(raw) => { + let record: Resume = + serde_json::from_slice(&raw).context("decode episode resume record")?; + ensure!( + record.version == 1 + && record.file_sha256 == hash + && record.file_byte_length == length, + "invalid episode resume record" + ); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as i64; + ensure!( + record.expires_at == 0 + || record.expires_at > now + || ["finalizing", "processing", "completed"].contains(&record.phase.as_str()), + "resumable upload expired; remove {} to restart explicitly", + resume_path.display() + ); + record + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Resume { + version: 1, + episode_id: String::new(), + upload_session_id: String::new(), + team_id: String::new(), + show_id: String::new(), + management_url: String::new(), + file_path: path.to_string_lossy().into(), + file_sha256: hash.clone(), + file_byte_length: length, + completed_parts: Vec::new(), + expires_at: 0, + phase: "prepared".into(), + }, + Err(error) => return Err(error.into()), + }; + if !["finalizing", "processing", "completed"].contains(&record.phase.as_str()) { + let request = api.client().create_episode_upload_session(p::CreateEpisodeUploadSessionParams { body: serde_json::from_value(json!({ + "show_slug": args.show, "title": args.title, "file_name": path.file_name().context("source filename")?.to_string_lossy(), + "content_type": content_type, "byte_length": length, "source_sha256": hash, + "description": args.description, + }))? }); + let response = api.send(request).await?; + api.trace(&response, false)?; + let session = api.accept(response, &[200, 201]).await?; + record.upload_session_id = string(&session, "upload_session_id")?.into(); + record.team_id = string(&session, "team_id")?.into(); + record.show_id = string(&session, "show_id")?.into(); + record.episode_id = session["episode_id"].as_str().unwrap_or("").into(); + record.expires_at = number(&session, "expires_at")?; + record.phase = string(&session, "phase")?.into(); + record.completed_parts = serde_json::from_value(session["completed_parts"].clone())?; + write_private_json(&resume_path, &record)?; + if record.phase != "finalizing" { + let part_size = u64::try_from(number(&session, "part_size")?)?; + ensure!(part_size >= 5 << 20, "invalid upload part size {part_size}"); + let count = length.div_ceil(part_size); + ensure!(count <= 10000, "episode source requires too many parts"); + let accepted = record + .completed_parts + .iter() + .map(|p| p.part_number) + .collect::>(); + let missing: Vec<_> = (1..=count as i64) + .filter(|n| !accepted.contains(n)) + .collect(); + saved_progress(&record); + if !missing.is_empty() { + let response = api + .send(api.client().presign_episode_upload_session_parts( + p::PresignEpisodeUploadSessionPartsParams { + upload_session_id: record.upload_session_id.clone(), + body: p::PresignEpisodeUploadSessionParts { + part_numbers: missing.clone(), + }, + }, + )) + .await?; + api.trace(&response, false)?; + let signed = api.accept(response, &[201]).await?; + let parts = signed["parts"].as_array().context("missing signed parts")?; + let mut pending = missing.into_iter().collect::>(); + for part in parts { + let number = number(part, "part_number")?; + ensure!( + pending.remove(&number), + "unexpected or duplicate signed part {number}" + ); + let offset = (number as u64 - 1) * part_size; + let size = part_size.min(length - offset); + let etag = upload_part( + api, + &path, + offset, + size, + string(part, "method")?, + string(part, "upload_url")?, + ) + .await?; + record.completed_parts.push(p::CompletedEpisodeUploadPart { + part_number: number, + etag, + size: size as i64, + }); + record.completed_parts.sort_by_key(|part| part.part_number); + record.phase = "uploading".into(); + write_private_json(&resume_path, &record)?; + saved_progress(&record); + } + ensure!(pending.is_empty(), "signed response omitted upload parts"); + } + } + record.phase = "finalizing".into(); + write_private_json(&resume_path, &record)?; + } + let response = + api.send(api.client().complete_episode_upload_session( + p::CompleteEpisodeUploadSessionParams { + upload_session_id: record.upload_session_id.clone(), + body: serde_json::from_value(json!({"publication": args.publication}))?, + }, + )) + .await?; + api.trace(&response, false)?; + let episode = api.accept(response, &[200, 201]).await?; + record.episode_id = string(&episode, "id")?.into(); + record.show_id = string(&episode, "show_id")?.into(); + ensure!( + crate::valid_id(&record.episode_id, "ep_"), + "invalid episode management identifier" + ); + record.management_url = format!( + "{}/episodes/{}/edit", + api.config.show_url(&record.team_id, &record.show_id)?, + record.episode_id + ); + record.phase = "processing".into(); + write_private_json(&resume_path, &record)?; + let mut events = Events::open( + api, + api.client() + .episode_upload_session_events(p::EpisodeUploadSessionEventsParams { + upload_session_id: record.upload_session_id.clone(), + }), + true, + ) + .await?; + loop { + let event = events.next(api).await?; + match string(&event, "type")? { + "episode.processing.progress" => { + let label = match string(&event, "current_step")? { + "queued" | "processing_audio" | "listenbox_processed" => "Listenbox processing", + "video_preparation" | "video_prepared" => "Video preparation", + "youtube_upload" => "YouTube upload", + _ => "Episode processing", + }; + let percent = number(&event, "percent")?; + ensure!((0..=100).contains(&percent), "invalid processing progress"); + eprintln!("{label}: {percent}%"); + } + "episode.processing.safe_handoff" => { + record.phase = "completed".into(); + write_private_json(&resume_path, &record)?; + let verb = if args.publication == Publication::Publish { + "Published episode" + } else { + "Created draft episode" + }; + println!( + "{verb} {:?}\nOpen in Listenbox: {}", + args.title, record.management_url + ); + if let Some(id) = event["youtube_video_id"].as_str().filter(|s| !s.is_empty()) { + println!("YouTube (processing): https://www.youtube.com/watch?v={id}"); + } + return Ok(()); + } + "episode.processing.failed" => bail!( + "episode processing failed ({}): {}", + string(&event, "error_code")?, + string(&event, "error_message")? + ), + kind => bail!("unknown episode processing event {kind:?}"), + } + } +} + +fn saved_progress(record: &Resume) { + let saved: i64 = record.completed_parts.iter().map(|part| part.size).sum(); + eprintln!( + "Source upload: {}% saved", + (saved as u64 * 100 / record.file_byte_length).min(100) + ); +} + +pub async fn upload_part( + api: &Api, + path: &Path, + offset: u64, + length: u64, + method: &str, + url: &str, +) -> Result { + let mut file = tokio::fs::File::open(path).await?; + file.seek(std::io::SeekFrom::Start(offset)).await?; + let stream = tokio_util::io::ReaderStream::new(file.take(length)); + let response = api + .send( + api.http + .request(method.parse()?, url) + .header("Content-Length", length) + .body(reqwest::Body::wrap_stream(stream)), + ) + .await?; + ensure!( + response.status().is_success(), + "upload source part returned HTTP {}", + response.status() + ); + let etag = response + .headers() + .get("ETag") + .context("upload part returned no ETag")? + .to_str()? + .to_owned(); + api.bytes(response, 64 << 10).await?; + Ok(etag) +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..cfb9ac9 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,83 @@ +use crate::api::Api; +use anyhow::{Result, bail, ensure}; +use reqwest::{RequestBuilder, Response}; +use serde_json::Value; + +pub struct Events { + response: Response, + buffer: Vec, + data: String, +} + +impl Events { + pub async fn open(api: &Api, request: RequestBuilder, trace: bool) -> Result { + let response = api.send(request).await?; + if !response.status().is_success() { + return Err(api.response_error(response).await); + } + ensure!( + response + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("text/event-stream")), + "expected an event stream" + ); + if trace { + api.trace(&response, false)?; + } + Ok(Self { + response, + buffer: Vec::new(), + data: String::new(), + }) + } + + pub async fn next(&mut self, api: &Api) -> Result { + loop { + while let Some(end) = self.buffer.iter().position(|c| *c == b'\n') { + let line = self.buffer.drain(..=end).collect::>(); + let line = std::str::from_utf8(&line)?.trim_end_matches(['\r', '\n']); + if line.is_empty() && !self.data.is_empty() { + let data = std::mem::take(&mut self.data); + return Ok(serde_json::from_str(&data)?); + } + if let Some(value) = line.strip_prefix("data:") { + if !self.data.is_empty() { + self.data.push('\n'); + } + self.data.push_str(value.strip_prefix(' ').unwrap_or(value)); + } + ensure!(self.data.len() <= 1 << 20, "event exceeds size limit"); + } + let chunk = api.wait(async { Ok(self.response.chunk().await?) }).await?; + match chunk { + Some(chunk) => { + ensure!( + self.buffer.len() + chunk.len() <= 1 << 20, + "event line exceeds size limit" + ); + self.buffer.extend_from_slice(&chunk); + } + None => bail!("event stream ended before a terminal event"), + } + } + } +} + +#[derive(Default)] +pub struct Progress(Option); + +impl Progress { + pub fn update(&mut self, percent: i64) -> Result<()> { + ensure!( + (0..=100).contains(&percent), + "malformed progress percent {percent}" + ); + if self.0.is_none_or(|previous| percent > previous) { + eprintln!("Import progress: {percent}%"); + self.0 = Some(percent); + } + Ok(()) + } +} diff --git a/src/innertube.rs b/src/innertube.rs new file mode 100644 index 0000000..b090485 --- /dev/null +++ b/src/innertube.rs @@ -0,0 +1,278 @@ +//! Application policy over the shared typed youtubei bindings. +use crate::api::{Api, read_bounded}; +use anyhow::{Context, Result, bail, ensure}; +use std::collections::HashSet; +use youtubei::{ + Client, Engine, EngineOptions, FetchRequest, FetchResponse, Format, GetVideoInfoOptions, + Innertube, Player, SessionOptions, UniversalCache, + models::{LockupContentType, PlaylistItem}, +}; + +pub struct YouTube { + client: Innertube, +} + +pub struct Media { + pub title: String, + pub description: String, + pub video: Stream, + pub audio: Option, +} + +pub struct Stream { + pub url: String, + pub user_agent: String, +} + +impl YouTube { + pub async fn new(api: &Api) -> Result { + let engine = Engine::with_options(EngineOptions { + memory_limit: 256 * 1024 * 1024, + stack_size: 4 * 1024 * 1024, + }) + .await?; + let cancel = api.cancel.clone(); + engine + .set_interrupt_handler(move || cancel.is_cancelled()) + .await; + let api = api.clone(); + let fetch = engine + .fetch_with(move |request| { + let api = api.clone(); + async move { + fetch(&api, request) + .await + .map_err(|error| youtubei::Error::new(error.to_string())) + } + }) + .await?; + let cache = UniversalCache::new(&engine, false, None).await?; + let client = Innertube::create_in( + &engine, + SessionOptions { + lang: Some("en".into()), + location: Some("US".into()), + cache: Some(cache.as_cache()), + fetch: Some(fetch), + ..SessionOptions::local() + }, + ) + .await + .context("initialize YouTube")?; + Ok(Self { client }) + } + + pub async fn playlist(&self, api: &Api, id: &str) -> Result<(String, Vec)> { + api.wait(async { + let mut page = self.client.get_playlist(id).await?; + let mut title = None; + let mut videos = Vec::new(); + let mut seen = HashSet::new(); + let mut pages = HashSet::new(); + loop { + let data = page.data().await?; + if title.is_none() { + title = data.info.title; + } + let mut ids = Vec::new(); + for item in data.items { + let id = match item { + PlaylistItem::PlaylistVideo(video) => { + if !video.is_playable + || video.is_live + || video.is_upcoming + || video.upcoming.is_some() + { + continue; + } + video.id + } + PlaylistItem::LockupView(video) + if matches!( + video.content_type, + LockupContentType::Video | LockupContentType::Short + ) => + { + if !video + .metadata + .and_then(|m| m.title) + .is_some_and(|title| !title.as_str().is_empty()) + { + continue; + } + video.content_id + } + _ => bail!("Unsupported playlist item; listing is incomplete"), + }; + ids.push(id.clone()); + if seen.insert(id.clone()) { + videos.push(id); + } + } + ensure!( + pages.len() < 10000 && pages.insert(ids), + "Repeated YouTube playlist page" + ); + if !data.has_continuation { + break; + } + page = page.get_continuation().await?; + } + ensure!(!videos.is_empty(), "YouTube playlist has no public videos"); + Ok(( + title + .filter(|title| !title.is_empty()) + .context("YouTube playlist missing title")?, + videos, + )) + }) + .await + } + + pub async fn media(&self, api: &Api, id: &str) -> Result { + api.wait(async { + let info = self + .client + .get_basic_info( + id, + GetVideoInfoOptions { + client: Some(Client::VisionOs), + ..Default::default() + }, + ) + .await?; + let data = info.data().await?; + ensure!( + data.playability_status + .as_ref() + .is_some_and(|s| s.status == "OK") + && !data.basic_info.is_live.unwrap_or(false) + && !data.basic_info.is_upcoming.unwrap_or(false), + "YouTube playback unavailable: {}", + data.playability_status + .and_then(|s| s.reason) + .unwrap_or_else(|| "not a public recorded video".into()) + ); + let mut formats = info.formats().await?; + formats.extend(info.adaptive_formats().await?); + formats.retain(|format| { + let f = format.info(); + f.drm_families.as_ref().is_none_or(Vec::is_empty) + && !f.is_type_otf + && (f.url.is_some() || f.cipher.is_some() || f.signature_cipher.is_some()) + }); + let video = formats + .iter() + .filter(|format| { + let f = format.info(); + f.has_video + && f.mime_type.contains("avc1") + && f.height + .is_some_and(|height| height > 0.0 && height <= 1080.0) + }) + .max_by(|a, b| { + a.info() + .height + .unwrap() + .total_cmp(&b.info().height.unwrap()) + }) + .context("YouTube video has no AVC rendition at or below 1080p")?; + let audio = if video.info().has_audio { + None + } else { + Some( + formats + .iter() + .filter(|format| format.info().has_audio && !format.info().has_video) + .max_by(|a, b| a.info().bitrate.total_cmp(&b.info().bitrate)) + .context("YouTube video has no audio stream")?, + ) + }; + Ok(Media { + title: data.basic_info.title.unwrap_or_else(|| id.into()), + description: data.basic_info.short_description.unwrap_or_default(), + video: self.stream(video, &data.cpn).await?, + audio: match audio { + Some(format) => Some(self.stream(format, &data.cpn).await?), + None => None, + }, + }) + }) + .await + } + + async fn stream(&self, format: &Format, cpn: &str) -> Result { + let session = self.client.session().await?; + let info = format.info(); + let needs_player = info.cipher.is_some() + || info.signature_cipher.is_some() + || info.url.as_ref().is_some_and(|url| { + url::Url::parse(url).is_ok_and(|url| url.query_pairs().any(|(key, _)| key == "n")) + }); + if needs_player && session.player().await?.is_none() { + let player = Player::create( + self.client.engine(), + session.cache().await?.as_ref(), + Some(&session.fetch().await?), + None, + None, + ) + .await?; + session.set_player(&player).await?; + } + let mut url = url::Url::parse(&format.decipher(session.player().await?.as_ref()).await?)?; + url.query_pairs_mut().append_pair("cpn", cpn); + Ok(Stream { + url: url.into(), + user_agent: Client::VisionOs + .user_agent(self.client.engine()) + .await? + .context("YouTube VISIONOS user agent missing")?, + }) + } +} + +async fn fetch(api: &Api, input: FetchRequest) -> Result { + let url = url::Url::parse(&input.url)?; + let host = url.host_str().unwrap_or(""); + ensure!( + url.scheme() == "https" + && [ + "youtube.com", + "google.com", + "googleapis.com", + "googlevideo.com", + "ytimg.com" + ] + .iter() + .any(|base| host == *base || host.ends_with(&format!(".{base}"))), + "unexpected YouTube API host" + ); + let mut request = api.http.request(input.method.parse()?, url); + for (name, value) in input.headers { + if !["host", "content-length", "cookie"].contains(&name.to_ascii_lowercase().as_str()) { + request = request.header(name, value); + } + } + if let Some(body) = input.body { + request = request.body(body); + } + let response = api.send(request).await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .filter_map(|(key, value)| { + value + .to_str() + .ok() + .map(|value| (key.to_string(), value.to_owned())) + }) + .collect(); + let body = api.wait(read_bounded(response, 32 << 20)).await?; + Ok(FetchResponse { + status, + headers, + body, + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7a10c3a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,282 @@ +mod api; +mod audio; +mod auth; +#[rustfmt::skip] +mod cliconfig; +mod commands; +mod config; +mod episodes; +mod events; +mod innertube; +mod media; +#[rustfmt::skip] +mod publicapi; +mod youtube; + +use anyhow::{Result, bail}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command( + name = "listenbox", + version, + about = "Publish and manage podcasts on Listenbox" +)] +struct Cli { + #[arg(long, global = true, help = "Path to CLI config file")] + config: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Authorize this CLI with Listenbox + Login, + /// Show current CLI authorization + Auth { + #[command(subcommand)] + command: AuthCommand, + }, + /// Import an RSS feed or public YouTube video/playlist + Import { + #[arg(long)] + slug: Option, + source_url: String, + }, + /// Manage shows by slug + Shows { + #[command(subcommand)] + command: ShowCommand, + }, + /// Manage episodes + Episodes { + #[command(subcommand)] + command: EpisodeCommand, + }, + /// Manage team or show members + Members { + #[command(subcommand)] + command: MemberCommand, + }, +} + +#[derive(Subcommand)] +enum AuthCommand { + Status, +} + +#[derive(Subcommand)] +enum ShowCommand { + List, + Create { + #[arg(long, value_parser = nonblank)] + title: String, + #[arg(long, value_parser = slug)] + slug: String, + #[arg(long = "type", value_enum)] + source_kind: ShowKind, + #[arg(long, value_parser = language)] + language: String, + #[arg(long)] + artwork: Option, + }, + Delete { + #[arg(long, value_parser = slug)] + show: String, + #[arg(long, required = true)] + yes: bool, + }, +} + +#[derive(Clone, Copy, ValueEnum, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum ShowKind { + Audio, + Video, +} + +#[derive(Subcommand)] +enum EpisodeCommand { + List { + #[arg(long, value_parser = slug)] + show: String, + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=500))] + limit: u16, + }, + Create(EpisodeCreate), + Delete { + #[arg(long, value_parser = episode_id)] + episode: String, + #[arg(long, required = true)] + yes: bool, + }, +} + +#[derive(Args)] +struct EpisodeCreate { + #[arg(long, value_parser = slug)] + show: String, + #[arg(long, value_parser = nonblank)] + title: String, + #[arg(long, value_parser = nonblank)] + description: Option, + #[arg(long)] + file: std::path::PathBuf, + #[arg(long, value_enum, default_value = "draft")] + publication: Publication, +} + +#[derive(Clone, Copy, PartialEq, ValueEnum, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum Publication { + Draft, + Publish, +} + +#[derive(Subcommand)] +enum MemberCommand { + List { + #[arg(long, value_parser = slug)] + show: Option, + }, + Invite { + #[arg(long, value_parser = nonblank)] + email: String, + #[arg(long, value_enum)] + role: Role, + #[arg(long, value_parser = slug)] + show: Option, + }, + Role { + #[arg(long, value_parser = nonblank)] + member: String, + #[arg(long, value_enum)] + role: Role, + #[arg(long, value_parser = slug)] + show: Option, + }, + Remove { + #[arg(long, value_parser = nonblank)] + member: String, + #[arg(long, required = true)] + yes: bool, + #[arg(long, value_parser = slug)] + show: Option, + }, +} + +#[derive(Clone, Copy, ValueEnum, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum Role { + Read, + Write, +} + +fn nonblank(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("must not be blank".into()); + } + Ok(value.into()) +} + +fn slug(value: &str) -> Result { + let value = nonblank(value)?; + if value.split('-').any(|part| { + part.is_empty() + || !part + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + }) { + return Err("must use lowercase letters, numbers, and single hyphens".into()); + } + Ok(value) +} + +fn language(value: &str) -> Result { + let value = nonblank(value)?; + let bytes = value.as_bytes(); + if !((bytes.len() == 2 || bytes.len() == 5) + && bytes[..2].iter().all(u8::is_ascii_lowercase) + && (bytes.len() == 2 + || (bytes[2] == b'-' && bytes[3..].iter().all(u8::is_ascii_uppercase)))) + { + return Err("must use a language code such as en or en-US".into()); + } + Ok(value) +} + +fn valid_id(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(|suffix| { + suffix.len() == 16 + && suffix + .bytes() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + }) +} + +fn episode_id(value: &str) -> Result { + if !valid_id(value, "ep_") { + return Err(format!("invalid episode ID {value:?}")); + } + Ok(value.into()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> std::process::ExitCode { + let cli = Cli::parse(); + let cancel = CancellationToken::new(); + let signal_cancel = cancel.clone(); + tokio::spawn(async move { + #[cfg(unix)] + { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } + } + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; + signal_cancel.cancel(); + }); + match run(cli, cancel.clone()).await { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error:#}"); + std::process::ExitCode::from(if cancel.is_cancelled() { 130 } else { 1 }) + } + } +} + +async fn run(cli: Cli, cancel: CancellationToken) -> Result<()> { + let config = config::Config::load(cli.config.as_deref())?; + let mut api = api::Api::new(config, cancel)?; + match cli.command { + Command::Login => auth::login(&mut api).await, + command => { + if api.credential.is_none() { + bail!("not logged in; run listenbox login"); + } + match command { + Command::Auth { .. } => auth::status(&api).await, + Command::Import { slug, source_url } => { + if youtube::is_source(&source_url) { + youtube::import(&api, &source_url, slug.as_deref()).await + } else { + commands::import_rss(&api, &source_url, slug.as_deref()).await + } + } + Command::Shows { command } => commands::shows(&api, command).await, + Command::Episodes { command } => episodes::run(&api, command).await, + Command::Members { command } => commands::members(&api, command).await, + Command::Login => unreachable!(), + } + } + } +} diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..e8bd886 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,218 @@ +use anyhow::{Context, Result, ensure}; +use ffmpeg::{Dictionary, Packet, codec, encoder, format, media}; +use std::path::Path; +use tokio_util::sync::CancellationToken; + +pub fn prepare(directory: &Path, separate_audio: bool, cancel: &CancellationToken) -> Result { + ffmpeg::init()?; + ffmpeg::log::set_level(ffmpeg::log::Level::Error); + let video = directory.join("source-video"); + let audio = directory.join(if separate_audio { + "source-audio" + } else { + "source-video" + }); + let prepared_audio = directory.join("audio.m4a"); + crate::audio::prepare_m4a(&audio, &prepared_audio, cancel)?; + let mp4 = directory.join("video.mp4"); + mux(&video, &prepared_audio, &mp4, cancel)?; + let mut input = format::input(&mp4)?; + let stream = input + .streams() + .best(media::Type::Video) + .context("prepared video has no video stream")?; + let decoder = codec::Context::from_parameters(stream.parameters())? + .decoder() + .video()?; + let (width, height) = (decoder.width(), decoder.height()); + ensure!(width > 0 && height > 0, "invalid video dimensions"); + for (name, kind) in [("video", media::Type::Video), ("audio", media::Type::Audio)] { + let root = directory.join("hls").join(name); + std::fs::create_dir_all(&root)?; + let output = root.join("index.m3u8"); + let mut options = Dictionary::new(); + options.set("hls_time", "6"); + options.set("hls_playlist_type", "vod"); + options.set("hls_segment_type", "fmp4"); + options.set("hls_fmp4_init_filename", "init.mp4"); + options.set( + "hls_segment_filename", + root.join("segment-%05d.m4s") + .to_str() + .context("non-UTF-8 media path")?, + ); + remux(&mut input, &mp4, &output, kind, options, cancel)?; + } + std::fs::write( + directory.join("hls/master.m3u8"), + format!( + "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"Audio\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio/index.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION={width}x{height},AUDIO=\"audio\"\nvideo/index.m3u8\n" + ), + )?; + let duration = playlist_duration(&directory.join("hls/video/index.m3u8"))? + .min(playlist_duration(&directory.join("hls/audio/index.m3u8"))?); + Ok(duration.ceil() as i64) +} + +fn add_stream( + input: &format::context::Input, + output: &mut format::context::Output, + kind: media::Type, +) -> Result<(usize, usize, ffmpeg::Rational)> { + let source = input + .streams() + .best(kind) + .context("source missing required media stream")?; + let mut stream = output.add_stream(encoder::find(codec::Id::None))?; + stream.set_parameters(source.parameters()); + stream.set_time_base(source.time_base()); + // SAFETY: the uniquely borrowed output stream owns these codec parameters. + unsafe { + (*stream.parameters_mut().as_mut_ptr()).codec_tag = 0; + } + Ok((source.index(), stream.index(), source.time_base())) +} + +fn packets( + input: &mut format::context::Input, + output: &mut format::context::Output, + mapping: (usize, usize, ffmpeg::Rational), + cancel: &CancellationToken, +) -> Result<()> { + let (source, target, time_base) = mapping; + let output_time = output + .stream(target) + .context("missing output stream")? + .time_base(); + let mut count = 0; + loop { + ensure!(!cancel.is_cancelled(), "media preparation interrupted"); + let mut packet = Packet::empty(); + match packet.read(input) { + Ok(()) => {} + Err(ffmpeg::Error::Eof) => break, + Err(error) => return Err(error.into()), + } + if packet.stream() == source { + packet.rescale_ts(time_base, output_time); + packet.set_stream(target); + packet.set_position(-1); + packet.write_interleaved(output)?; + count += 1; + } + } + ensure!(count > 0, "downloaded media stream was empty"); + Ok(()) +} + +fn mux(video: &Path, audio: &Path, destination: &Path, cancel: &CancellationToken) -> Result<()> { + let mut video = format::input(video)?; + let mut audio = format::input(audio)?; + let mut output = format::output_as(destination, "mp4")?; + let video_mapping = add_stream(&video, &mut output, media::Type::Video)?; + let audio_mapping = add_stream(&audio, &mut output, media::Type::Audio)?; + let mut options = Dictionary::new(); + options.set("movflags", "+faststart"); + output.write_header_with(options)?; + // Keep only one packet per input while muxing in decoding timestamp order. + // Feeding a whole input first can make the interleaver buffer long recordings. + let mut video_packet = next_packet(&mut video, video_mapping.0, cancel)?; + let mut audio_packet = next_packet(&mut audio, audio_mapping.0, cancel)?; + ensure!( + video_packet.is_some() && audio_packet.is_some(), + "downloaded media stream was empty" + ); + while video_packet.is_some() || audio_packet.is_some() { + let take_video = match (&video_packet, &audio_packet) { + (Some(video), Some(audio)) => { + timestamp(video, video_mapping.2) <= timestamp(audio, audio_mapping.2) + } + (Some(_), None) => true, + _ => false, + }; + let (slot, input, mapping) = if take_video { + (&mut video_packet, &mut video, video_mapping) + } else { + (&mut audio_packet, &mut audio, audio_mapping) + }; + let mut packet = slot.take().context("missing mux packet")?; + let output_time = output + .stream(mapping.1) + .context("missing output stream")? + .time_base(); + packet.rescale_ts(mapping.2, output_time); + packet.set_stream(mapping.1); + packet.set_position(-1); + packet.write_interleaved(&mut output)?; + *slot = next_packet(input, mapping.0, cancel)?; + } + output.write_trailer()?; + Ok(()) +} + +fn timestamp(packet: &Packet, time: ffmpeg::Rational) -> i64 { + use ffmpeg::Rescale; + packet + .dts() + .or_else(|| packet.pts()) + .unwrap_or(0) + .rescale(time, (1, 1_000_000)) +} + +fn next_packet( + input: &mut format::context::Input, + stream: usize, + cancel: &CancellationToken, +) -> Result> { + loop { + ensure!(!cancel.is_cancelled(), "media preparation interrupted"); + let mut packet = Packet::empty(); + match packet.read(input) { + Ok(()) if packet.stream() == stream => return Ok(Some(packet)), + Ok(()) => {} + Err(ffmpeg::Error::Eof) => return Ok(None), + Err(error) => return Err(error.into()), + } + } +} + +fn remux( + input: &mut format::context::Input, + source: &Path, + destination: &Path, + kind: media::Type, + options: Dictionary, + cancel: &CancellationToken, +) -> Result<()> { + *input = format::input(source)?; + let mut output = format::output_as(destination, "hls")?; + let mapping = add_stream(input, &mut output, kind)?; + output.write_header_with(options)?; + packets(input, &mut output, mapping, cancel)?; + output.write_trailer()?; + Ok(()) +} + +fn playlist_duration(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + let mut total = 0.0; + for line in text.lines() { + if let Some(value) = line.strip_prefix("#EXTINF:") { + let seconds: f64 = value + .split(',') + .next() + .context("missing HLS duration")? + .parse()?; + ensure!( + seconds.is_finite() && seconds > 0.0, + "invalid HLS segment duration" + ); + total += seconds; + } + } + ensure!( + total.is_finite() && total > 0.0, + "playlist has no valid segments" + ); + Ok(total) +} diff --git a/src/publicapi/mod.rs b/src/publicapi/mod.rs new file mode 100644 index 0000000..49e3376 --- /dev/null +++ b/src/publicapi/mod.rs @@ -0,0 +1,2940 @@ +// Code generated by oasmith. DO NOT EDIT. +#![allow(dead_code, clippy::large_enum_variant, clippy::enum_variant_names)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct APIKeyWhoami { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "id")] + pub id: ApiKeyID, + #[serde(rename = "name", default, skip_serializing_if = "Option::is_none")] + pub name: std::option::Option, + #[serde(rename = "scopes")] + pub scopes: std::vec::Vec, + #[serde(rename = "team_id")] + pub team_id: TeamID, +} + +pub type ApiKeyID = std::string::String; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ApiKeyScope { + #[serde(rename = "team:read")] + TeamRead, + #[serde(rename = "team:manage")] + TeamManage, + #[serde(rename = "show:read")] + ShowRead, + #[serde(rename = "show:create")] + ShowCreate, + #[serde(rename = "show:update")] + ShowUpdate, + #[serde(rename = "episode:create")] + EpisodeCreate, + #[serde(rename = "episode:delete")] + EpisodeDelete, + #[serde(rename = "episode:update")] + EpisodeUpdate, + #[serde(rename = "episode:publish")] + EpisodePublish, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AssignableTeamRole { + #[serde(rename = "write")] + Write, + #[serde(rename = "read")] + Read, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationApprovedEvent { + #[serde(rename = "team_id")] + pub team_id: TeamID, + #[serde(rename = "type")] + pub r#type: CLIAuthorizationApprovedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationApprovedEventType { + #[serde(rename = "cli.authorization.approved")] + CliAuthorizationApproved, +} + +pub type CLIAuthorizationCode = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationDeniedEvent { + #[serde(rename = "type")] + pub r#type: CLIAuthorizationDeniedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationDeniedEventType { + #[serde(rename = "cli.authorization.denied")] + CliAuthorizationDenied, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CLIAuthorizationEvent { + Variant0(CLIAuthorizationEventVariant0), + Variant1(CLIAuthorizationEventVariant1), + Variant2(CLIAuthorizationEventVariant2), + Variant3(CLIAuthorizationEventVariant3), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationEventVariant0 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: CLIAuthorizationEventVariant0Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationEventVariant0Event { + #[serde(rename = "cli.authorization.pending")] + CliAuthorizationPending, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationEventVariant1 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: CLIAuthorizationEventVariant1Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationEventVariant1Event { + #[serde(rename = "cli.authorization.approved")] + CliAuthorizationApproved, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationEventVariant2 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: CLIAuthorizationEventVariant2Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationEventVariant2Event { + #[serde(rename = "cli.authorization.denied")] + CliAuthorizationDenied, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationEventVariant3 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: CLIAuthorizationEventVariant3Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationEventVariant3Event { + #[serde(rename = "cli.authorization.expired")] + CliAuthorizationExpired, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationExpiredEvent { + #[serde(rename = "type")] + pub r#type: CLIAuthorizationExpiredEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationExpiredEventType { + #[serde(rename = "cli.authorization.expired")] + CliAuthorizationExpired, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CLIAuthorizationPendingEvent { + #[serde(rename = "credential")] + pub credential: std::string::String, + #[serde(rename = "type")] + pub r#type: CLIAuthorizationPendingEventType, + #[serde(rename = "verification_url")] + pub verification_url: std::string::String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAuthorizationPendingEventType { + #[serde(rename = "cli.authorization.pending")] + CliAuthorizationPending, +} + +#[derive(Clone, Debug)] +pub struct CancelEpisodePackageParams { + pub upload_session_id: UploadSessionID, +} + +#[derive(Debug)] +pub enum CancelEpisodePackageResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CancelEpisodePackageResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct CancelEpisodeUploadSessionParams { + pub upload_session_id: UploadSessionID, +} + +#[derive(Debug)] +pub enum CancelEpisodeUploadSessionResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status404(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CancelEpisodeUploadSessionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct CliAuthorizationEventsParams { + pub code: CLIAuthorizationCode, +} + +#[derive(Debug)] +pub enum CliAuthorizationEventsResponse { + Status200(reqwest::Response), + Status400(ValidationErr), + Status404(()), + Unexpected(reqwest::Response), +} +impl CliAuthorizationEventsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response), + 400 => Self::Status400(response.json().await?), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct CompleteEpisodePackageParams { + pub upload_session_id: UploadSessionID, +} + +#[derive(Debug)] +pub enum CompleteEpisodePackageResponse { + Status200(EpisodePackage), + Status400(ValidationErr), + Status401(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CompleteEpisodePackageResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompleteEpisodeUploadSession { + #[serde(rename = "publication")] + pub publication: EpisodePublication, +} + +#[derive(Clone, Debug)] +pub struct CompleteEpisodeUploadSessionParams { + pub upload_session_id: UploadSessionID, + pub body: CompleteEpisodeUploadSession, +} + +#[derive(Debug)] +pub enum CompleteEpisodeUploadSessionResponse { + Status200(Episode), + Status201(Episode), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Status409(()), + Status410(()), + Unexpected(reqwest::Response), +} +impl CompleteEpisodeUploadSessionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + 410 => Self::Status410(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompleteImageUpload { + #[serde(rename = "object_key")] + pub object_key: ImageUploadObjectKey, +} + +#[derive(Clone, Debug)] +pub struct CompleteImageUploadParams { + pub image_asset_id: ImageAssetID, + pub body: CompleteImageUpload, +} + +#[derive(Debug)] +pub enum CompleteImageUploadResponse { + Status201(ImageAsset), + Status400(()), + Status401(()), + Status403(()), + Status404(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CompleteImageUploadResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(()), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompletedEpisodeUploadPart { + #[serde(rename = "etag")] + pub etag: NonEmptyString, + #[serde(rename = "part_number")] + pub part_number: MediaUploadPartNumber, + #[serde(rename = "size")] + pub size: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateCLIAuthorization { + #[serde(rename = "scopes")] + pub scopes: std::vec::Vec, +} + +#[derive(Clone, Debug)] +pub struct CreateCLIAuthorizationParams { + pub body: CreateCLIAuthorization, +} + +#[derive(Debug)] +pub enum CreateCLIAuthorizationResponse { + Status201(CreatedCLIAuthorization), + Status400(ValidationErr), + Status401(()), + Unexpected(reqwest::Response), +} +impl CreateCLIAuthorizationResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct CreateEpisodeDeletionParams { + pub episode_id: EpisodeID, +} + +#[derive(Debug)] +pub enum CreateEpisodeDeletionResponse { + Status202(CreatedPublicEpisodeDeletion), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl CreateEpisodeDeletionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 202 => Self::Status202(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateEpisodePackage { + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "duration_seconds")] + pub duration_seconds: i64, + #[serde(rename = "objects")] + pub objects: std::vec::Vec, + #[serde(rename = "show_slug")] + pub show_slug: ShowSlug, + #[serde(rename = "source_url")] + pub source_url: std::string::String, + #[serde(rename = "title")] + pub title: std::string::String, +} + +#[derive(Clone, Debug)] +pub struct CreateEpisodePackageParams { + pub body: CreateEpisodePackage, +} + +#[derive(Debug)] +pub enum CreateEpisodePackageResponse { + Status201(EpisodePackage), + Status400(ValidationErr), + Status401(()), + Status402(VideoAdmissionError), + Status404(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CreateEpisodePackageResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 402 => Self::Status402(response.json().await?), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateEpisodeUploadSession { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "content_type")] + pub content_type: EpisodeUploadContentType, + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "file_name")] + pub file_name: NonEmptyString, + #[serde(rename = "show_slug")] + pub show_slug: ShowSlug, + #[serde(rename = "source_sha256")] + pub source_sha256: SHA256Hex, + #[serde(rename = "thumbnail_url", default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: std::option::Option, + #[serde(rename = "title")] + pub title: NonEmptyString, +} + +#[derive(Clone, Debug)] +pub struct CreateEpisodeUploadSessionParams { + pub body: CreateEpisodeUploadSession, +} + +#[derive(Debug)] +pub enum CreateEpisodeUploadSessionResponse { + Status200(EpisodeUploadSession), + Status201(EpisodeUploadSession), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Status409(()), + Status410(()), + Unexpected(reqwest::Response), +} +impl CreateEpisodeUploadSessionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + 410 => Self::Status410(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateImageUploadPresign { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "content_type")] + pub content_type: CreateImageUploadPresignContentType, + #[serde(rename = "file_name", default, skip_serializing_if = "Option::is_none")] + pub file_name: std::option::Option, + #[serde(rename = "show_id")] + pub show_id: ShowID, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CreateImageUploadPresignContentType { + #[serde(rename = "image/jpeg")] + ImageJpeg, + #[serde(rename = "image/png")] + ImagePng, +} + +#[derive(Clone, Debug)] +pub struct CreateImageUploadPresignParams { + pub body: CreateImageUploadPresign, +} + +#[derive(Debug)] +pub enum CreateImageUploadPresignResponse { + Status201(CreatedImageUploadPresign), + Status400(()), + Status401(()), + Status402(()), + Status403(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CreateImageUploadPresignResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(()), + 401 => Self::Status401(()), + 402 => Self::Status402(()), + 403 => Self::Status403(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateShow { + #[serde(rename = "id")] + pub id: ShowID, + #[serde(rename = "image_asset_id", default, skip_serializing_if = "Option::is_none")] + pub image_asset_id: std::option::Option, + #[serde(rename = "language")] + pub language: ShowLanguage, + #[serde(rename = "slug")] + pub slug: ShowSlug, + #[serde(rename = "source_kind")] + pub source_kind: ShowSourceKind, + #[serde(rename = "title")] + pub title: NonEmptyString, +} + +#[derive(Clone, Debug)] +pub struct CreateShowDeletionParams { + pub show_slug: ShowSlug, +} + +#[derive(Debug)] +pub enum CreateShowDeletionResponse { + Status202(CreatedPublicShowDeletion), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl CreateShowDeletionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 202 => Self::Status202(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateShowInvitation { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug)] +pub struct CreateShowInvitationParams { + pub show_slug: ShowSlug, + pub body: CreateShowInvitation, +} + +#[derive(Debug)] +pub enum CreateShowInvitationResponse { + Status201(PendingShowInvitation), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CreateShowInvitationResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct CreateShowParams { + pub body: CreateShow, +} + +#[derive(Debug)] +pub enum CreateShowResponse { + Status201(Show), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CreateShowResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateTeamInvitation { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug)] +pub struct CreateTeamInvitationParams { + pub body: CreateTeamInvitation, +} + +#[derive(Debug)] +pub enum CreateTeamInvitationResponse { + Status201(PendingTeamInvitation), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl CreateTeamInvitationResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreatedCLIAuthorization { + #[serde(rename = "code")] + pub code: CLIAuthorizationCode, + #[serde(rename = "credential")] + pub credential: std::string::String, + #[serde(rename = "verification_url")] + pub verification_url: std::string::String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreatedImageUploadPresign { + #[serde(rename = "content_type")] + pub content_type: CreatedImageUploadPresignContentType, + #[serde(rename = "expires_at")] + pub expires_at: i64, + #[serde(rename = "image_asset_id")] + pub image_asset_id: ImageAssetID, + #[serde(rename = "method")] + pub method: CreatedImageUploadPresignMethod, + #[serde(rename = "object_key")] + pub object_key: ImageUploadObjectKey, + #[serde(rename = "public_url")] + pub public_url: std::string::String, + #[serde(rename = "upload_url")] + pub upload_url: std::string::String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CreatedImageUploadPresignContentType { + #[serde(rename = "image/jpeg")] + ImageJpeg, + #[serde(rename = "image/png")] + ImagePng, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CreatedImageUploadPresignMethod { + #[serde(rename = "PUT")] + Put, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreatedPublicEpisodeDeletion { + #[serde(rename = "episode_deletion_run_id")] + pub episode_deletion_run_id: EpisodeDeletionRunID, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreatedPublicRSSImport { + #[serde(rename = "import_run_id")] + pub import_run_id: ImportRunID, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreatedPublicShowDeletion { + #[serde(rename = "show_deletion_run_id")] + pub show_deletion_run_id: ShowDeletionRunID, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Episode { + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "enclosures")] + pub enclosures: std::vec::Vec, + #[serde(rename = "id")] + pub id: EpisodeID, + #[serde(rename = "link", default, skip_serializing_if = "Option::is_none")] + pub link: std::option::Option, + #[serde(rename = "processing", default, skip_serializing_if = "Option::is_none")] + pub processing: std::option::Option, + #[serde(rename = "republished_at", default, skip_serializing_if = "Option::is_none")] + pub republished_at: std::option::Option, + #[serde(rename = "show_id")] + pub show_id: ShowID, + #[serde(rename = "slug", default, skip_serializing_if = "Option::is_none")] + pub slug: std::option::Option, + #[serde(rename = "status")] + pub status: EpisodeStatus, + #[serde(rename = "thumbnail_url", default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: std::option::Option, + #[serde(rename = "title")] + pub title: NonEmptyString, + #[serde(rename = "video_hls_url", default, skip_serializing_if = "Option::is_none")] + pub video_hls_url: std::option::Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeDeletionCompletedEvent { + #[serde(rename = "episode_deletion_run_id")] + pub episode_deletion_run_id: EpisodeDeletionRunID, + #[serde(rename = "episode_id")] + pub episode_id: EpisodeID, + #[serde(rename = "status")] + pub status: EpisodeDeletionCompletedEventStatus, + #[serde(rename = "type")] + pub r#type: EpisodeDeletionCompletedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionCompletedEventStatus { + #[serde(rename = "completed")] + Completed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionCompletedEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EpisodeDeletionEvent { + Variant0(EpisodeDeletionEventVariant0), + Variant1(EpisodeDeletionEventVariant1), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeDeletionEventVariant0 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: EpisodeDeletionEventVariant0Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionEventVariant0Event { + #[serde(rename = "progress")] + Progress, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeDeletionEventVariant1 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: EpisodeDeletionEventVariant1Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionEventVariant1Event { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug)] +pub struct EpisodeDeletionEventsParams { + pub episode_deletion_run_id: EpisodeDeletionRunID, +} + +#[derive(Debug)] +pub enum EpisodeDeletionEventsResponse { + Status200(reqwest::Response), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl EpisodeDeletionEventsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeDeletionFailedEvent { + #[serde(rename = "episode_deletion_run_id")] + pub episode_deletion_run_id: EpisodeDeletionRunID, + #[serde(rename = "error_code")] + pub error_code: NonEmptyString, + #[serde(rename = "error_message")] + pub error_message: NonEmptyString, + #[serde(rename = "status")] + pub status: EpisodeDeletionFailedEventStatus, + #[serde(rename = "type")] + pub r#type: EpisodeDeletionFailedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionFailedEventStatus { + #[serde(rename = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionFailedEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeDeletionProgressEvent { + #[serde(rename = "current_step")] + pub current_step: NonEmptyString, + #[serde(rename = "episode_deletion_run_id")] + pub episode_deletion_run_id: EpisodeDeletionRunID, + #[serde(rename = "percent")] + pub percent: i64, + #[serde(rename = "status")] + pub status: EpisodeDeletionProgressEventStatus, + #[serde(rename = "type")] + pub r#type: EpisodeDeletionProgressEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionProgressEventStatus { + #[serde(rename = "queued")] + Queued, + #[serde(rename = "processing")] + Processing, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeDeletionProgressEventType { + #[serde(rename = "progress")] + Progress, +} + +pub type EpisodeDeletionRunID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EpisodeDeletionTerminalEvent { + Variant0(EpisodeDeletionCompletedEvent), + Variant1(EpisodeDeletionFailedEvent), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeEnclosure { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "content_type")] + pub content_type: NonEmptyString, + #[serde(rename = "duration_seconds")] + pub duration_seconds: i64, + #[serde(rename = "feed_id")] + pub feed_id: FeedID, + #[serde(rename = "format")] + pub format: EpisodeEnclosureFormat, + #[serde(rename = "url")] + pub url: std::string::String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeEnclosureFormat { + #[serde(rename = "mp3")] + Mp3, + #[serde(rename = "m4a")] + M4a, + #[serde(rename = "wav")] + Wav, + #[serde(rename = "flac")] + Flac, + #[serde(rename = "mp4")] + Mp4, +} + +pub type EpisodeID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeListItem { + #[serde(rename = "audio_url", default, skip_serializing_if = "Option::is_none")] + pub audio_url: std::option::Option, + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "duration_seconds", default, skip_serializing_if = "Option::is_none")] + pub duration_seconds: std::option::Option, + #[serde(rename = "id")] + pub id: EpisodeID, + #[serde(rename = "processing", default, skip_serializing_if = "Option::is_none")] + pub processing: std::option::Option, + #[serde(rename = "republished_at", default, skip_serializing_if = "Option::is_none")] + pub republished_at: std::option::Option, + #[serde(rename = "show_id")] + pub show_id: ShowID, + #[serde(rename = "slug", default, skip_serializing_if = "Option::is_none")] + pub slug: std::option::Option, + #[serde(rename = "status")] + pub status: EpisodeStatus, + #[serde(rename = "thumbnail_url", default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: std::option::Option, + #[serde(rename = "title")] + pub title: NonEmptyString, + #[serde(rename = "video_hls_url", default, skip_serializing_if = "Option::is_none")] + pub video_hls_url: std::option::Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodePackage { + #[serde(rename = "episode_id")] + pub episode_id: EpisodeID, + #[serde(rename = "object_prefix")] + pub object_prefix: NonEmptyString, + #[serde(rename = "part_size")] + pub part_size: i64, + #[serde(rename = "show_id")] + pub show_id: ShowID, + #[serde(rename = "status")] + pub status: EpisodePackageStatus, + #[serde(rename = "team_id")] + pub team_id: TeamID, + #[serde(rename = "upload_session_id")] + pub upload_session_id: UploadSessionID, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodePackageStatus { + #[serde(rename = "uploading")] + Uploading, + #[serde(rename = "completed")] + Completed, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "expired")] + Expired, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodePage { + #[serde(rename = "episodes")] + pub episodes: std::vec::Vec, + #[serde(rename = "next_cursor", default, skip_serializing_if = "Option::is_none")] + pub next_cursor: std::option::Option, +} + +pub type EpisodePageCursor = std::string::String; + +pub type EpisodePageLimit = i64; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EpisodeProcessingEvent { + Variant0(EpisodeProcessingEventVariant0), + Variant1(EpisodeProcessingEventVariant1), + Variant2(EpisodeProcessingEventVariant2), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingEventVariant0 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: EpisodeProcessingEventVariant0Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingEventVariant0Event { + #[serde(rename = "episode.processing.progress")] + EpisodeProcessingProgress, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingEventVariant1 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: EpisodeProcessingEventVariant1Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingEventVariant1Event { + #[serde(rename = "episode.processing.safe_handoff")] + EpisodeProcessingSafeHandoff, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingEventVariant2 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: EpisodeProcessingEventVariant2Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingEventVariant2Event { + #[serde(rename = "episode.processing.failed")] + EpisodeProcessingFailed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingFailedEvent { + #[serde(rename = "episode_id")] + pub episode_id: EpisodeID, + #[serde(rename = "error_code")] + pub error_code: NonEmptyString, + #[serde(rename = "error_message")] + pub error_message: NonEmptyString, + #[serde(rename = "run_id")] + pub run_id: ProcessingRunID, + #[serde(rename = "status")] + pub status: EpisodeProcessingFailedEventStatus, + #[serde(rename = "type")] + pub r#type: EpisodeProcessingFailedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingFailedEventStatus { + #[serde(rename = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingFailedEventType { + #[serde(rename = "episode.processing.failed")] + EpisodeProcessingFailed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingProgressEvent { + #[serde(rename = "current_step")] + pub current_step: NonEmptyString, + #[serde(rename = "percent")] + pub percent: i64, + #[serde(rename = "run_id")] + pub run_id: ProcessingRunID, + #[serde(rename = "status")] + pub status: EpisodeProcessingStatus, + #[serde(rename = "type")] + pub r#type: EpisodeProcessingProgressEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingProgressEventType { + #[serde(rename = "episode.processing.progress")] + EpisodeProcessingProgress, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingSafeHandoffEvent { + #[serde(rename = "episode_id")] + pub episode_id: EpisodeID, + #[serde(rename = "media_version_id")] + pub media_version_id: MediaVersionID, + #[serde(rename = "run_id")] + pub run_id: ProcessingRunID, + #[serde(rename = "status")] + pub status: EpisodeProcessingSafeHandoffEventStatus, + #[serde(rename = "type")] + pub r#type: EpisodeProcessingSafeHandoffEventType, + #[serde(rename = "youtube_video_id", default, skip_serializing_if = "Option::is_none")] + pub youtube_video_id: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingSafeHandoffEventStatus { + #[serde(rename = "ready")] + Ready, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingSafeHandoffEventType { + #[serde(rename = "episode.processing.safe_handoff")] + EpisodeProcessingSafeHandoff, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeProcessingSnapshot { + #[serde(rename = "current_step")] + pub current_step: NonEmptyString, + #[serde(rename = "error_code", default, skip_serializing_if = "Option::is_none")] + pub error_code: std::option::Option, + #[serde(rename = "error_message", default, skip_serializing_if = "Option::is_none")] + pub error_message: std::option::Option, + #[serde(rename = "percent")] + pub percent: i64, + #[serde(rename = "run_id")] + pub run_id: ProcessingRunID, + #[serde(rename = "status")] + pub status: EpisodeProcessingStatus, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeProcessingStatus { + #[serde(rename = "queued")] + Queued, + #[serde(rename = "processing")] + Processing, + #[serde(rename = "completed")] + Completed, + #[serde(rename = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodePublication { + #[serde(rename = "draft")] + Draft, + #[serde(rename = "publish")] + Publish, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeStatus { + #[serde(rename = "draft")] + Draft, + #[serde(rename = "published")] + Published, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeUploadContentType { + #[serde(rename = "audio/mpeg")] + AudioMpeg, + #[serde(rename = "audio/mp4")] + AudioMp4, + #[serde(rename = "audio/wav")] + AudioWav, + #[serde(rename = "audio/flac")] + AudioFlac, + #[serde(rename = "video/mp4")] + VideoMp4, + #[serde(rename = "video/quicktime")] + VideoQuicktime, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeUploadSession { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "completed_parts")] + pub completed_parts: std::vec::Vec, + #[serde(rename = "content_type")] + pub content_type: EpisodeUploadContentType, + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "episode_id", default, skip_serializing_if = "Option::is_none")] + pub episode_id: std::option::Option, + #[serde(rename = "expires_at")] + pub expires_at: UnixMillis, + #[serde(rename = "file_name")] + pub file_name: NonEmptyString, + #[serde(rename = "max_parallelism")] + pub max_parallelism: i64, + #[serde(rename = "object_key")] + pub object_key: R2ObjectKey, + #[serde(rename = "part_size")] + pub part_size: i64, + #[serde(rename = "phase")] + pub phase: EpisodeUploadSessionPhase, + #[serde(rename = "processing", default, skip_serializing_if = "Option::is_none")] + pub processing: std::option::Option, + #[serde(rename = "show_id")] + pub show_id: ShowID, + #[serde(rename = "source_duration_seconds", default, skip_serializing_if = "Option::is_none")] + pub source_duration_seconds: std::option::Option, + #[serde(rename = "source_sha256")] + pub source_sha256: SHA256Hex, + #[serde(rename = "team_id")] + pub team_id: TeamID, + #[serde(rename = "thumbnail_url", default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: std::option::Option, + #[serde(rename = "title")] + pub title: NonEmptyString, + #[serde(rename = "upload_session_id")] + pub upload_session_id: UploadSessionID, +} + +#[derive(Clone, Debug)] +pub struct EpisodeUploadSessionEventsParams { + pub upload_session_id: UploadSessionID, +} + +#[derive(Debug)] +pub enum EpisodeUploadSessionEventsResponse { + Status200(reqwest::Response), + Status400(ValidationErr), + Status401(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl EpisodeUploadSessionEventsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EpisodeUploadSessionPhase { + #[serde(rename = "created")] + Created, + #[serde(rename = "uploading")] + Uploading, + #[serde(rename = "processing")] + Processing, + #[serde(rename = "completed")] + Completed, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "expired")] + Expired, +} + +pub type FeedID = std::string::String; + +#[derive(Clone, Debug)] +pub struct GetEpisodeUploadSessionParams { + pub upload_session_id: UploadSessionID, +} + +#[derive(Debug)] +pub enum GetEpisodeUploadSessionResponse { + Status200(EpisodeUploadSession), + Status400(ValidationErr), + Status401(()), + Status404(()), + Status410(()), + Unexpected(reqwest::Response), +} +impl GetEpisodeUploadSessionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 404 => Self::Status404(()), + 410 => Self::Status410(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Debug)] +pub enum HeadOpenapiResponse { + Status200(()), + Status304(()), + Status400(()), + Unexpected(reqwest::Response), +} +impl HeadOpenapiResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(()), + 304 => Self::Status304(()), + 400 => Self::Status400(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImageAsset { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "content_type")] + pub content_type: ImageAssetContentType, + #[serde(rename = "has_transparency")] + pub has_transparency: bool, + #[serde(rename = "height_pixels")] + pub height_pixels: i64, + #[serde(rename = "id")] + pub id: ImageAssetID, + #[serde(rename = "public_url")] + pub public_url: std::string::String, + #[serde(rename = "width_pixels")] + pub width_pixels: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImageAssetContentType { + #[serde(rename = "image/jpeg")] + ImageJpeg, + #[serde(rename = "image/png")] + ImagePng, +} + +pub type ImageAssetID = std::string::String; + +pub type ImageUploadObjectKey = std::string::String; + +#[derive(Clone, Debug)] +pub struct ImportRSSParams { + pub body: ImportRSSRequest, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportRSSRequest { + #[serde(rename = "slug", default, skip_serializing_if = "Option::is_none")] + pub slug: std::option::Option, + #[serde(rename = "source_url")] + pub source_url: std::string::String, +} + +#[derive(Debug)] +pub enum ImportRSSResponse { + Status202(CreatedPublicRSSImport), + Status400(ValidationErr), + Status401(()), + Status402(RSSImportEntitlementError), + Status403(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl ImportRSSResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 202 => Self::Status202(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 402 => Self::Status402(response.json().await?), + 403 => Self::Status403(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct ImportRSSRunEventsParams { + pub import_run_id: ImportRunID, +} + +#[derive(Debug)] +pub enum ImportRSSRunEventsResponse { + Status200(reqwest::Response), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl ImportRSSRunEventsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +pub type ImportRunID = std::string::String; + +#[derive(Clone, Debug)] +pub struct ListEpisodesParams { + pub show_slug: ShowSlug, + pub limit: std::option::Option, + pub cursor: std::option::Option, +} + +#[derive(Debug)] +pub enum ListEpisodesResponse { + Status200(EpisodePage), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl ListEpisodesResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct ListShowMembersParams { + pub show_slug: ShowSlug, +} + +#[derive(Debug)] +pub enum ListShowMembersResponse { + Status200(ShowMemberCollection), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl ListShowMembersResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Debug)] +pub enum ListShowsResponse { + Status200(std::vec::Vec), + Status400(ValidationErr), + Status401(()), + Unexpected(reqwest::Response), +} +impl ListShowsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Debug)] +pub enum ListTeamMembersResponse { + Status200(TeamMemberCollection), + Status400(ValidationErr), + Status401(()), + Status403(()), + Unexpected(reqwest::Response), +} +impl ListTeamMembersResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + _ => Self::Unexpected(response), + }) + } +} + +pub type MediaUploadPartNumber = i64; + +pub type MediaVersionID = std::string::String; + +pub type NonEmptyString = std::string::String; + +pub type OpenAPIJSONDocument = serde_json::Value; + +pub type OpenAPIYAMLDocument = std::string::String; + +#[derive(Debug)] +pub enum OpenapiResponse { + Status200(OpenAPIJSONDocument), + Status304(()), + Status400(ValidationErr), + Unexpected(reqwest::Response), +} +impl OpenapiResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 304 => Self::Status304(()), + 400 => Self::Status400(response.json().await?), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PatchEpisodeUploadSession { + #[serde(rename = "description", default, skip_serializing_if = "Option::is_none")] + pub description: std::option::Option, + #[serde(rename = "thumbnail_url", default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: std::option::Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PendingShowInvitation { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "expires_at")] + pub expires_at: UnixMillis, + #[serde(rename = "id")] + pub id: ShowInvitationID, + #[serde(rename = "invited_at")] + pub invited_at: UnixMillis, + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PendingTeamInvitation { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "expires_at")] + pub expires_at: UnixMillis, + #[serde(rename = "id")] + pub id: TeamInvitationID, + #[serde(rename = "invited_at")] + pub invited_at: UnixMillis, + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PreparedMediaObject { + #[serde(rename = "byte_length")] + pub byte_length: i64, + #[serde(rename = "content_type")] + pub content_type: PreparedMediaObjectContentType, + #[serde(rename = "name")] + pub name: std::string::String, + #[serde(rename = "sha256")] + pub sha256: SHA256Hex, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PreparedMediaObjectContentType { + #[serde(rename = "video/mp4")] + VideoMp4, + #[serde(rename = "audio/mp4")] + AudioMp4, + #[serde(rename = "application/vnd.apple.mpegurl")] + ApplicationVndAppleMpegurl, +} + +#[derive(Clone, Debug)] +pub struct PresignEpisodePackagePartsParams { + pub upload_session_id: UploadSessionID, + pub object_index: i64, + pub body: PresignEpisodeUploadSessionParts, +} + +#[derive(Debug)] +pub enum PresignEpisodePackagePartsResponse { + Status200(PresignedEpisodeUploadParts), + Status400(ValidationErr), + Status401(()), + Status409(()), + Unexpected(reqwest::Response), +} +impl PresignEpisodePackagePartsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 409 => Self::Status409(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresignEpisodeUploadSessionParts { + #[serde(rename = "part_numbers")] + pub part_numbers: std::vec::Vec, +} + +#[derive(Clone, Debug)] +pub struct PresignEpisodeUploadSessionPartsParams { + pub upload_session_id: UploadSessionID, + pub body: PresignEpisodeUploadSessionParts, +} + +#[derive(Debug)] +pub enum PresignEpisodeUploadSessionPartsResponse { + Status201(PresignedEpisodeUploadParts), + Status400(ValidationErr), + Status401(()), + Status402(()), + Status404(()), + Status409(()), + Status410(()), + Unexpected(reqwest::Response), +} +impl PresignEpisodeUploadSessionPartsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 201 => Self::Status201(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 402 => Self::Status402(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + 410 => Self::Status410(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresignedEpisodeUploadPart { + #[serde(rename = "expires_at")] + pub expires_at: UnixMillis, + #[serde(rename = "method")] + pub method: PresignedEpisodeUploadPartMethod, + #[serde(rename = "part_number")] + pub part_number: MediaUploadPartNumber, + #[serde(rename = "upload_url")] + pub upload_url: std::string::String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PresignedEpisodeUploadPartMethod { + #[serde(rename = "PUT")] + Put, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresignedEpisodeUploadParts { + #[serde(rename = "parts")] + pub parts: std::vec::Vec, + #[serde(rename = "upload_session_id")] + pub upload_session_id: UploadSessionID, +} + +pub type ProcessingRunID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicRSSImportCompletedTerminalEvent { + #[serde(rename = "feed_id")] + pub feed_id: FeedID, + #[serde(rename = "show_id")] + pub show_id: ShowID, + #[serde(rename = "show_slug")] + pub show_slug: ShowSlug, + #[serde(rename = "status")] + pub status: PublicRSSImportCompletedTerminalEventStatus, + #[serde(rename = "team_id")] + pub team_id: TeamID, + #[serde(rename = "type")] + pub r#type: PublicRSSImportCompletedTerminalEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PublicRSSImportCompletedTerminalEventStatus { + #[serde(rename = "completed")] + Completed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PublicRSSImportCompletedTerminalEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PublicRSSImportEvent { + Variant0(PublicRSSImportEventVariant0), + Variant1(PublicRSSImportEventVariant1), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicRSSImportEventVariant0 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: PublicRSSImportEventVariant0Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PublicRSSImportEventVariant0Event { + #[serde(rename = "progress")] + Progress, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicRSSImportEventVariant1 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: PublicRSSImportEventVariant1Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PublicRSSImportEventVariant1Event { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PublicRSSImportTerminalEvent { + Variant0(PublicRSSImportCompletedTerminalEvent), + Variant1(RSSImportFailedTerminalEvent), + Variant2(RSSImportCancelledTerminalEvent), +} + +pub type R2ObjectKey = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RSSImportCancelledTerminalEvent { + #[serde(rename = "status")] + pub status: RSSImportCancelledTerminalEventStatus, + #[serde(rename = "type")] + pub r#type: RSSImportCancelledTerminalEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportCancelledTerminalEventStatus { + #[serde(rename = "cancelled")] + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportCancelledTerminalEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RSSImportEntitlementError { + #[serde(rename = "code")] + pub code: RSSImportEntitlementErrorCode, + #[serde(rename = "current_entitlement")] + pub current_entitlement: VideoDeliveryEntitlement, + #[serde(rename = "media_kind")] + pub media_kind: RSSImportMediaKind, + #[serde(rename = "pricing_url")] + pub pricing_url: std::string::String, + #[serde(rename = "required_entitlement")] + pub required_entitlement: VideoDeliveryEntitlement, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportEntitlementErrorCode { + #[serde(rename = "rss_import_entitlement_required")] + RssImportEntitlementRequired, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RSSImportFailedTerminalEvent { + #[serde(rename = "error_code")] + pub error_code: NonEmptyString, + #[serde(rename = "error_message")] + pub error_message: NonEmptyString, + #[serde(rename = "http_status", default, skip_serializing_if = "Option::is_none")] + pub http_status: std::option::Option, + #[serde(rename = "limit_seconds", default, skip_serializing_if = "Option::is_none")] + pub limit_seconds: std::option::Option, + #[serde(rename = "pricing_url", default, skip_serializing_if = "Option::is_none")] + pub pricing_url: std::option::Option, + #[serde(rename = "remaining_seconds", default, skip_serializing_if = "Option::is_none")] + pub remaining_seconds: std::option::Option, + #[serde(rename = "requested_seconds", default, skip_serializing_if = "Option::is_none")] + pub requested_seconds: std::option::Option, + #[serde(rename = "required_next_plan", default, skip_serializing_if = "Option::is_none")] + pub required_next_plan: std::option::Option, + #[serde(rename = "reserved_seconds", default, skip_serializing_if = "Option::is_none")] + pub reserved_seconds: std::option::Option, + #[serde(rename = "retained_seconds", default, skip_serializing_if = "Option::is_none")] + pub retained_seconds: std::option::Option, + #[serde(rename = "sales_contact_required", default, skip_serializing_if = "Option::is_none")] + pub sales_contact_required: std::option::Option, + #[serde(rename = "status")] + pub status: RSSImportFailedTerminalEventStatus, + #[serde(rename = "type")] + pub r#type: RSSImportFailedTerminalEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportFailedTerminalEventStatus { + #[serde(rename = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportFailedTerminalEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportMediaKind { + #[serde(rename = "audio")] + Audio, + #[serde(rename = "video")] + Video, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RSSImportProgressEvent { + #[serde(rename = "current_step")] + pub current_step: NonEmptyString, + #[serde(rename = "percent")] + pub percent: i64, + #[serde(rename = "type")] + pub r#type: RSSImportProgressEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RSSImportProgressEventType { + #[serde(rename = "progress")] + Progress, +} + +#[derive(Clone, Debug)] +pub struct RemoveShowMemberParams { + pub show_slug: ShowSlug, + pub user_id: UserID, +} + +#[derive(Debug)] +pub enum RemoveShowMemberResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl RemoveShowMemberResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct RemoveTeamMemberParams { + pub user_id: UserID, +} + +#[derive(Debug)] +pub enum RemoveTeamMemberResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl RemoveTeamMemberResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct RevokeShowInvitationParams { + pub show_slug: ShowSlug, + pub invitation_id: ShowInvitationID, +} + +#[derive(Debug)] +pub enum RevokeShowInvitationResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl RevokeShowInvitationResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct RevokeTeamInvitationParams { + pub invitation_id: TeamInvitationID, +} + +#[derive(Debug)] +pub enum RevokeTeamInvitationResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl RevokeTeamInvitationResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +pub type SHA256Hex = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Show { + #[serde(rename = "id")] + pub id: ShowID, + #[serde(rename = "language")] + pub language: ShowLanguage, + #[serde(rename = "slug")] + pub slug: ShowSlug, + #[serde(rename = "source_kind")] + pub source_kind: ShowSourceKind, + #[serde(rename = "team_id")] + pub team_id: TeamID, + #[serde(rename = "title")] + pub title: NonEmptyString, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowAccessSource { + #[serde(rename = "team")] + Team, + #[serde(rename = "show")] + Show, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowDeletionCompletedEvent { + #[serde(rename = "show_deletion_run_id")] + pub show_deletion_run_id: ShowDeletionRunID, + #[serde(rename = "show_slug")] + pub show_slug: ShowSlug, + #[serde(rename = "status")] + pub status: ShowDeletionCompletedEventStatus, + #[serde(rename = "type")] + pub r#type: ShowDeletionCompletedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionCompletedEventStatus { + #[serde(rename = "completed")] + Completed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionCompletedEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ShowDeletionEvent { + Variant0(ShowDeletionEventVariant0), + Variant1(ShowDeletionEventVariant1), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowDeletionEventVariant0 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: ShowDeletionEventVariant0Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionEventVariant0Event { + #[serde(rename = "progress")] + Progress, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowDeletionEventVariant1 { + #[serde(rename = "data")] + pub data: std::string::String, + #[serde(rename = "event")] + pub event: ShowDeletionEventVariant1Event, + #[serde(rename = "id", default, skip_serializing_if = "Option::is_none")] + pub id: std::option::Option, + #[serde(rename = "retry", default, skip_serializing_if = "Option::is_none")] + pub retry: std::option::Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionEventVariant1Event { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug)] +pub struct ShowDeletionEventsParams { + pub show_deletion_run_id: ShowDeletionRunID, +} + +#[derive(Debug)] +pub enum ShowDeletionEventsResponse { + Status200(reqwest::Response), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl ShowDeletionEventsResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowDeletionFailedEvent { + #[serde(rename = "error_code")] + pub error_code: NonEmptyString, + #[serde(rename = "error_message")] + pub error_message: NonEmptyString, + #[serde(rename = "show_deletion_run_id")] + pub show_deletion_run_id: ShowDeletionRunID, + #[serde(rename = "status")] + pub status: ShowDeletionFailedEventStatus, + #[serde(rename = "type")] + pub r#type: ShowDeletionFailedEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionFailedEventStatus { + #[serde(rename = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionFailedEventType { + #[serde(rename = "terminal")] + Terminal, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowDeletionProgressEvent { + #[serde(rename = "current_step")] + pub current_step: NonEmptyString, + #[serde(rename = "percent")] + pub percent: i64, + #[serde(rename = "show_deletion_run_id")] + pub show_deletion_run_id: ShowDeletionRunID, + #[serde(rename = "status")] + pub status: ShowDeletionProgressEventStatus, + #[serde(rename = "type")] + pub r#type: ShowDeletionProgressEventType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionProgressEventStatus { + #[serde(rename = "queued")] + Queued, + #[serde(rename = "processing")] + Processing, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowDeletionProgressEventType { + #[serde(rename = "progress")] + Progress, +} + +pub type ShowDeletionRunID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ShowDeletionTerminalEvent { + Variant0(ShowDeletionCompletedEvent), + Variant1(ShowDeletionFailedEvent), +} + +pub type ShowID = std::string::String; + +pub type ShowInvitationID = std::string::String; + +pub type ShowLanguage = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowMember { + #[serde(rename = "access_source")] + pub access_source: ShowAccessSource, + #[serde(rename = "joined_at")] + pub joined_at: UnixMillis, + #[serde(rename = "role")] + pub role: TeamRole, + #[serde(rename = "user")] + pub user: User, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShowMemberCollection { + #[serde(rename = "invitations")] + pub invitations: std::vec::Vec, + #[serde(rename = "members")] + pub members: std::vec::Vec, +} + +pub type ShowSlug = std::string::String; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShowSourceKind { + #[serde(rename = "audio")] + Audio, + #[serde(rename = "video")] + Video, +} + +pub type String = std::string::String; + +pub type TeamID = std::string::String; + +pub type TeamInvitationID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamMember { + #[serde(rename = "joined_at")] + pub joined_at: UnixMillis, + #[serde(rename = "role")] + pub role: TeamRole, + #[serde(rename = "user")] + pub user: User, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamMemberCollection { + #[serde(rename = "invitations")] + pub invitations: std::vec::Vec, + #[serde(rename = "members")] + pub members: std::vec::Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TeamRole { + #[serde(rename = "owner")] + Owner, + #[serde(rename = "write")] + Write, + #[serde(rename = "read")] + Read, +} + +pub type UnixMillis = i64; + +#[derive(Clone, Debug)] +pub struct UpdateEpisodeUploadSessionParams { + pub upload_session_id: UploadSessionID, + pub body: PatchEpisodeUploadSession, +} + +#[derive(Debug)] +pub enum UpdateEpisodeUploadSessionResponse { + Status200(EpisodeUploadSession), + Status400(ValidationErr), + Status401(()), + Status404(()), + Status409(()), + Status410(()), + Unexpected(reqwest::Response), +} +impl UpdateEpisodeUploadSessionResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 404 => Self::Status404(()), + 409 => Self::Status409(()), + 410 => Self::Status410(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct UpdateShowInvitationRoleParams { + pub show_slug: ShowSlug, + pub invitation_id: ShowInvitationID, + pub body: UpdateShowMemberRole, +} + +#[derive(Debug)] +pub enum UpdateShowInvitationRoleResponse { + Status200(PendingShowInvitation), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl UpdateShowInvitationRoleResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateShowMemberRole { + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug)] +pub struct UpdateShowMemberRoleParams { + pub show_slug: ShowSlug, + pub user_id: UserID, + pub body: UpdateShowMemberRole, +} + +#[derive(Debug)] +pub enum UpdateShowMemberRoleResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl UpdateShowMemberRoleResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug)] +pub struct UpdateTeamInvitationRoleParams { + pub invitation_id: TeamInvitationID, + pub body: UpdateTeamMemberRole, +} + +#[derive(Debug)] +pub enum UpdateTeamInvitationRoleResponse { + Status200(PendingTeamInvitation), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl UpdateTeamInvitationRoleResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateTeamMemberRole { + #[serde(rename = "role")] + pub role: AssignableTeamRole, +} + +#[derive(Clone, Debug)] +pub struct UpdateTeamMemberRoleParams { + pub user_id: UserID, + pub body: UpdateTeamMemberRole, +} + +#[derive(Debug)] +pub enum UpdateTeamMemberRoleResponse { + Status204(()), + Status400(ValidationErr), + Status401(()), + Status403(()), + Status404(()), + Unexpected(reqwest::Response), +} +impl UpdateTeamMemberRoleResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 204 => Self::Status204(()), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + 403 => Self::Status403(()), + 404 => Self::Status404(()), + _ => Self::Unexpected(response), + }) + } +} + +pub type UploadSessionID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct User { + #[serde(rename = "email")] + pub email: std::string::String, + #[serde(rename = "id")] + pub id: UserID, + #[serde(rename = "name", default, skip_serializing_if = "Option::is_none")] + pub name: std::option::Option, + #[serde(rename = "picture_url", default, skip_serializing_if = "Option::is_none")] + pub picture_url: std::option::Option, +} + +pub type UserID = std::string::String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ValidationErr { + #[serde(rename = "errors")] + pub errors: std::vec::Vec, + #[serde(rename = "message")] + pub message: NonEmptyString, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ValidationIssue { + #[serde(rename = "code")] + pub code: ValidationIssueCode, + #[serde(rename = "field", default, skip_serializing_if = "Option::is_none")] + pub field: std::option::Option, + #[serde(rename = "in")] + pub r#in: ValidationLocation, + #[serde(rename = "message")] + pub message: NonEmptyString, + #[serde(rename = "path", default, skip_serializing_if = "Option::is_none")] + pub path: std::option::Option>, +} + +pub type ValidationIssueCode = std::string::String; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ValidationLocation { + #[serde(rename = "body")] + Body, + #[serde(rename = "query")] + Query, + #[serde(rename = "path")] + Path, + #[serde(rename = "header")] + Header, + #[serde(rename = "cookie")] + Cookie, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VideoAdmissionError { + #[serde(rename = "code")] + pub code: VideoAdmissionErrorCode, + #[serde(rename = "current_plan")] + pub current_plan: VideoPlan, + #[serde(rename = "delivery_entitlement")] + pub delivery_entitlement: VideoDeliveryEntitlement, + #[serde(rename = "limit_seconds")] + pub limit_seconds: i64, + #[serde(rename = "pricing_url")] + pub pricing_url: std::string::String, + #[serde(rename = "remaining_seconds")] + pub remaining_seconds: i64, + #[serde(rename = "requested_seconds")] + pub requested_seconds: i64, + #[serde(rename = "required_next_plan")] + pub required_next_plan: VideoPlan, + #[serde(rename = "reserved_seconds")] + pub reserved_seconds: i64, + #[serde(rename = "retained_seconds")] + pub retained_seconds: i64, + #[serde(rename = "sales_contact_required")] + pub sales_contact_required: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum VideoAdmissionErrorCode { + #[serde(rename = "video_plan_required")] + VideoPlanRequired, + #[serde(rename = "video_hours_exhausted")] + VideoHoursExhausted, + #[serde(rename = "video_hours_exceeded")] + VideoHoursExceeded, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum VideoDeliveryEntitlement { + #[serde(rename = "free")] + Free, + #[serde(rename = "audio")] + Audio, + #[serde(rename = "video_hd")] + VideoHd, + #[serde(rename = "video_4k")] + Video4k, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum VideoPlan { + #[serde(rename = "starter")] + Starter, + #[serde(rename = "professional")] + Professional, + #[serde(rename = "studio")] + Studio, +} + +#[derive(Debug)] +pub enum WhoamiResponse { + Status200(APIKeyWhoami), + Status400(ValidationErr), + Status401(()), + Unexpected(reqwest::Response), +} +impl WhoamiResponse { + pub async fn decode(response: reqwest::Response) -> Result { + Ok(match response.status().as_u16() { + 200 => Self::Status200(response.json().await?), + 400 => Self::Status400(response.json().await?), + 401 => Self::Status401(()), + _ => Self::Unexpected(response), + }) + } +} + + +#[derive(Clone, Debug)] +pub struct Client { + http: reqwest::Client, + base_url: String, + bearer_token: Option, +} +fn encode_path(value: &str) -> String { + value.bytes().map(|byte| { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { + char::from(byte).to_string() + } else { format!("%{byte:02X}") } + }).collect() +} +impl Client { + pub fn new(http: reqwest::Client, base_url: String, bearer_token: Option) -> Self { + Self { http, base_url: base_url.trim_end_matches('/').to_owned(), bearer_token } + } + pub fn openapi(&self) -> reqwest::RequestBuilder { + let path = "/".to_owned(); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn head_openapi(&self) -> reqwest::RequestBuilder { + let path = "/".to_owned(); + let request = self.http.request(reqwest::Method::HEAD, format!("{}{}", self.base_url, path)).header("Accept", "*/*"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn create_cli_authorization(&self, params: CreateCLIAuthorizationParams) -> reqwest::RequestBuilder { + let path = "/cli/authorizations".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn cli_authorization_events(&self, params: CliAuthorizationEventsParams) -> reqwest::RequestBuilder { + let path = "/cli/authorizations/{code}/events".to_owned(); + let path = path.replace("{code}", &encode_path(¶ms.code.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "text/event-stream"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn episode_deletion_events(&self, params: EpisodeDeletionEventsParams) -> reqwest::RequestBuilder { + let path = "/s/episode-deletions/{episode_deletion_run_id}/events".to_owned(); + let path = path.replace("{episode_deletion_run_id}", &encode_path(¶ms.episode_deletion_run_id.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "text/event-stream"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn create_episode_package(&self, params: CreateEpisodePackageParams) -> reqwest::RequestBuilder { + let path = "/s/episode-packages".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn cancel_episode_package(&self, params: CancelEpisodePackageParams) -> reqwest::RequestBuilder { + let path = "/s/episode-packages/{upload_session_id}".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn complete_episode_package(&self, params: CompleteEpisodePackageParams) -> reqwest::RequestBuilder { + let path = "/s/episode-packages/{upload_session_id}/complete".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn presign_episode_package_parts(&self, params: PresignEpisodePackagePartsParams) -> reqwest::RequestBuilder { + let path = "/s/episode-packages/{upload_session_id}/objects/{object_index}/parts".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let path = path.replace("{object_index}", &encode_path(¶ms.object_index.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn create_episode_upload_session(&self, params: CreateEpisodeUploadSessionParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn get_episode_upload_session(&self, params: GetEpisodeUploadSessionParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn update_episode_upload_session(&self, params: UpdateEpisodeUploadSessionParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::PUT, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn cancel_episode_upload_session(&self, params: CancelEpisodeUploadSessionParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn complete_episode_upload_session(&self, params: CompleteEpisodeUploadSessionParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}/complete".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn episode_upload_session_events(&self, params: EpisodeUploadSessionEventsParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}/events".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "text/event-stream"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn presign_episode_upload_session_parts(&self, params: PresignEpisodeUploadSessionPartsParams) -> reqwest::RequestBuilder { + let path = "/s/episode-upload-sessions/{upload_session_id}/parts/presign".to_owned(); + let path = path.replace("{upload_session_id}", &encode_path(¶ms.upload_session_id.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn create_episode_deletion(&self, params: CreateEpisodeDeletionParams) -> reqwest::RequestBuilder { + let path = "/s/episodes/{episode_id}/deletions".to_owned(); + let path = path.replace("{episode_id}", &encode_path(¶ms.episode_id.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn create_image_upload_presign(&self, params: CreateImageUploadPresignParams) -> reqwest::RequestBuilder { + let path = "/s/image-uploads/presign".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn complete_image_upload(&self, params: CompleteImageUploadParams) -> reqwest::RequestBuilder { + let path = "/s/image-uploads/{image_asset_id}/complete".to_owned(); + let path = path.replace("{image_asset_id}", &encode_path(¶ms.image_asset_id.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn import_rss(&self, params: ImportRSSParams) -> reqwest::RequestBuilder { + let path = "/s/rss-imports".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn import_rss_run_events(&self, params: ImportRSSRunEventsParams) -> reqwest::RequestBuilder { + let path = "/s/rss-imports/{import_run_id}/events".to_owned(); + let path = path.replace("{import_run_id}", &encode_path(¶ms.import_run_id.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "text/event-stream"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn show_deletion_events(&self, params: ShowDeletionEventsParams) -> reqwest::RequestBuilder { + let path = "/s/show-deletions/{show_deletion_run_id}/events".to_owned(); + let path = path.replace("{show_deletion_run_id}", &encode_path(¶ms.show_deletion_run_id.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "text/event-stream"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn list_shows(&self) -> reqwest::RequestBuilder { + let path = "/s/shows".to_owned(); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn create_show(&self, params: CreateShowParams) -> reqwest::RequestBuilder { + let path = "/s/shows".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn create_show_deletion(&self, params: CreateShowDeletionParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/deletions".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn list_episodes(&self, params: ListEpisodesParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/episodes".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + let request = match params.limit { Some(value) => request.query(&[("limit", value)]), None => request }; + match params.cursor { Some(value) => request.query(&[("cursor", value)]), None => request } + } + pub fn create_show_invitation(&self, params: CreateShowInvitationParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/invitations".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn update_show_invitation_role(&self, params: UpdateShowInvitationRoleParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/invitations/{invitation_id}".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let path = path.replace("{invitation_id}", &encode_path(¶ms.invitation_id.to_string())); + let request = self.http.request(reqwest::Method::PUT, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn revoke_show_invitation(&self, params: RevokeShowInvitationParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/invitations/{invitation_id}".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let path = path.replace("{invitation_id}", &encode_path(¶ms.invitation_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn list_show_members(&self, params: ListShowMembersParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/members".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn update_show_member_role(&self, params: UpdateShowMemberRoleParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/members/{user_id}".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let path = path.replace("{user_id}", &encode_path(¶ms.user_id.to_string())); + let request = self.http.request(reqwest::Method::PUT, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn remove_show_member(&self, params: RemoveShowMemberParams) -> reqwest::RequestBuilder { + let path = "/s/shows/{show_slug}/members/{user_id}".to_owned(); + let path = path.replace("{show_slug}", &encode_path(¶ms.show_slug.to_string())); + let path = path.replace("{user_id}", &encode_path(¶ms.user_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn create_team_invitation(&self, params: CreateTeamInvitationParams) -> reqwest::RequestBuilder { + let path = "/s/team/invitations".to_owned(); + let request = self.http.request(reqwest::Method::POST, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn update_team_invitation_role(&self, params: UpdateTeamInvitationRoleParams) -> reqwest::RequestBuilder { + let path = "/s/team/invitations/{invitation_id}".to_owned(); + let path = path.replace("{invitation_id}", &encode_path(¶ms.invitation_id.to_string())); + let request = self.http.request(reqwest::Method::PUT, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn revoke_team_invitation(&self, params: RevokeTeamInvitationParams) -> reqwest::RequestBuilder { + let path = "/s/team/invitations/{invitation_id}".to_owned(); + let path = path.replace("{invitation_id}", &encode_path(¶ms.invitation_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn list_team_members(&self) -> reqwest::RequestBuilder { + let path = "/s/team/members".to_owned(); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn update_team_member_role(&self, params: UpdateTeamMemberRoleParams) -> reqwest::RequestBuilder { + let path = "/s/team/members/{user_id}".to_owned(); + let path = path.replace("{user_id}", &encode_path(¶ms.user_id.to_string())); + let request = self.http.request(reqwest::Method::PUT, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + let request = match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request }; + request.json(¶ms.body) + } + pub fn remove_team_member(&self, params: RemoveTeamMemberParams) -> reqwest::RequestBuilder { + let path = "/s/team/members/{user_id}".to_owned(); + let path = path.replace("{user_id}", &encode_path(¶ms.user_id.to_string())); + let request = self.http.request(reqwest::Method::DELETE, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } + pub fn whoami(&self) -> reqwest::RequestBuilder { + let path = "/s/whoami".to_owned(); + let request = self.http.request(reqwest::Method::GET, format!("{}{}", self.base_url, path)).header("Accept", "application/json"); + match &self.bearer_token { Some(token) => request.bearer_auth(token), None => request } + } +} diff --git a/src/youtube.rs b/src/youtube.rs new file mode 100644 index 0000000..6472ef1 --- /dev/null +++ b/src/youtube.rs @@ -0,0 +1,320 @@ +use crate::{ + api::{Api, string}, + events::Progress, + innertube::{Stream, YouTube}, + publicapi as p, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, path::Path, time::Duration}; +use tokio::io::AsyncWriteExt; +use url::Url; + +pub fn is_source(source: &str) -> bool { + Url::parse(source).ok().is_some_and(|url| { + matches!( + url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "youtu.be") + ) + }) +} + +pub async fn import(api: &Api, source: &str, requested_slug: Option<&str>) -> Result<()> { + let url = Url::parse(source)?; + ensure!( + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none(), + "invalid YouTube source URL" + ); + let youtube = YouTube::new(api).await?; + let (title, videos) = + if let Some((_, playlist)) = url.query_pairs().find(|(key, _)| key == "list") { + ensure!(valid_youtube_id(&playlist), "invalid playlist ID"); + youtube.playlist(api, &playlist).await? + } else { + let id = if url.host_str() == Some("youtu.be") { + url.path().trim_matches('/').to_owned() + } else if let Some((_, id)) = url.query_pairs().find(|(key, _)| key == "v") { + id.into_owned() + } else { + url.path() + .strip_prefix("/shorts/") + .or_else(|| url.path().strip_prefix("/embed/")) + .unwrap_or("") + .into() + }; + ensure!( + id.len() == 11 && valid_youtube_id(&id), + "invalid YouTube video ID" + ); + let media = youtube.media(api, &id).await?; + (media.title, vec![id]) + }; + ensure!(!videos.is_empty(), "YouTube playlist has no public videos"); + let slug = requested_slug + .map(str::to_owned) + .unwrap_or_else(|| format!("youtube-{}", &hex::encode(Sha256::digest(source))[..16])); + let slug = crate::slug(&slug).map_err(anyhow::Error::msg)?; + let shows: Vec = + serde_json::from_value(api.json(api.client().list_shows(), &[200]).await?)?; + let show = match shows.into_iter().find(|show| show.slug == slug) { + Some(show) => { + ensure!( + show.source_kind == p::ShowSourceKind::Video, + "show {slug:?} is not a video show" + ); + show + } + None => { + let response = api + .json( + api.client().create_show(p::CreateShowParams { + body: p::CreateShow { + id: format!("shw_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]), + title, + slug: slug.clone(), + source_kind: p::ShowSourceKind::Video, + language: "en".into(), + image_asset_id: None, + }, + }), + &[201], + ) + .await?; + serde_json::from_value(response)? + } + }; + let mut progress = Progress::default(); + let mut seen = HashSet::new(); + for (index, id) in videos.iter().enumerate() { + ensure!( + id.len() == 11 && valid_youtube_id(id), + "invalid YouTube playlist video ID" + ); + if seen.insert(id) { + import_video(api, &youtube, &slug, id) + .await + .with_context(|| format!("import YouTube video {id:?}"))?; + } + progress.update(((index + 1) * 100 / videos.len()) as i64)?; + } + progress.update(100)?; + println!("{slug}"); + eprintln!( + "Open in Listenbox: {}", + api.config.show_url(&show.team_id, &show.id)? + ); + Ok(()) +} + +fn valid_youtube_id(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') +} + +async fn import_video(api: &Api, youtube: &YouTube, slug: &str, id: &str) -> Result<()> { + let media = youtube.media(api, id).await?; + let directory = tempfile::tempdir()?; + download(api, &media.video, &directory.path().join("source-video")).await?; + if let Some(audio) = &media.audio { + download(api, audio, &directory.path().join("source-audio")).await?; + } + let root = directory.path().to_owned(); + let separate_audio = media.audio.is_some(); + let cancel = api.cancel.clone(); + // The worker checks cancellation between packets; joining owns closure before temp files are removed. + let duration = + tokio::task::spawn_blocking(move || crate::media::prepare(&root, separate_audio, &cancel)) + .await??; + let objects = inventory(directory.path()).await?; + let response = api + .send( + api.client() + .create_episode_package(p::CreateEpisodePackageParams { + body: p::CreateEpisodePackage { + show_slug: slug.into(), + source_url: format!("https://www.youtube.com/watch?v={id}"), + title: media.title, + description: Some(media.description), + duration_seconds: duration, + objects: objects.clone(), + }, + }), + ) + .await?; + if response.status() != reqwest::StatusCode::CREATED { + return Err(api.response_error(response).await); + } + // Keep the trace until after reading admission, so every admitted session has a cleanup owner. + let trace = response + .headers() + .get("X-Trace-Id") + .and_then(|h| h.to_str().ok()) + .map(str::to_owned); + let session: p::EpisodePackage = api.decode(response).await?; + if session.status == p::EpisodePackageStatus::Completed { + return Ok(()); + } + let result: Result<()> = async { + if api.config.print_trace_ids { + let trace = trace.context("response missing X-Trace-Id")?; + crate::config::check_trace(&trace)?; + println!("Trace ID: {trace}"); + } + ensure!(session.part_size >= 5 << 20, "invalid package part size"); + for (ordinal, object) in objects.iter().enumerate() { + let mut offset = 0; + let mut number = 1; + while offset < object.byte_length { + let signed = api + .json( + api.client().presign_episode_package_parts( + p::PresignEpisodePackagePartsParams { + upload_session_id: session.upload_session_id.clone(), + object_index: ordinal as i64, + body: p::PresignEpisodeUploadSessionParts { + part_numbers: vec![number], + }, + }, + ), + &[200], + ) + .await?; + let parts = signed["parts"] + .as_array() + .context("missing package signed parts")?; + if parts.is_empty() { + break; + } + ensure!( + parts.len() == 1 && parts[0]["part_number"] == number, + "invalid signed package part" + ); + let length = session.part_size.min(object.byte_length - offset); + crate::episodes::upload_part( + api, + &directory.path().join(&object.name), + offset as u64, + length as u64, + "PUT", + string(&parts[0], "upload_url")?, + ) + .await?; + offset += length; + number += 1; + } + } + let completed = api + .json( + api.client() + .complete_episode_package(p::CompleteEpisodePackageParams { + upload_session_id: session.upload_session_id.clone(), + }), + &[200], + ) + .await?; + ensure!( + string(&completed, "status")? == "completed", + "prepared media did not complete" + ); + Ok(()) + } + .await; + if let Err(error) = result { + let mut cleanup = api.clone(); + cleanup.cancel = tokio_util::sync::CancellationToken::new(); + let request = cleanup + .client() + .cancel_episode_package(p::CancelEpisodePackageParams { + upload_session_id: session.upload_session_id, + }); + let cancelled = tokio::time::timeout(Duration::from_secs(10), cleanup.send(request)).await; + match cancelled { + Ok(Ok(response)) if [204, 409].contains(&response.status().as_u16()) => {} + Ok(Ok(response)) => { + return Err(error).context(format!( + "cancel pending package returned HTTP {}", + response.status() + )); + } + Ok(Err(cleanup_error)) => { + return Err(error).context(format!("cancel pending package: {cleanup_error}")); + } + Err(_) => return Err(error).context("cancel pending package timed out"), + } + return Err(error); + } + Ok(()) +} + +async fn download(api: &Api, stream: &Stream, path: &Path) -> Result<()> { + let mut response = api + .send( + api.http + .get(&stream.url) + .header("Origin", "https://youtube.com") + .header("User-Agent", &stream.user_agent), + ) + .await?; + ensure!( + response.status() == reqwest::StatusCode::OK, + "download YouTube media returned HTTP {}", + response.status() + ); + let mut file = tokio::fs::File::create(path).await?; + while let Some(chunk) = api.wait(async { Ok(response.chunk().await?) }).await? { + file.write_all(&chunk).await?; + } + file.flush().await?; + Ok(()) +} + +async fn inventory(root: &Path) -> Result> { + let mut directories = vec![root.to_owned()]; + let mut names = Vec::new(); + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + directories.push(entry.path()); + continue; + } + ensure!( + entry.file_type()?.is_file(), + "media package contains a non-regular object" + ); + let path = entry.path(); + let name = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + if name == "video.mp4" || name.starts_with("hls/") { + names.push(name); + } + } + } + names.sort(); + let mut objects = Vec::new(); + for name in names { + let content_type = if name.ends_with(".m3u8") { + "application/vnd.apple.mpegurl" + } else if name.starts_with("hls/audio/") { + "audio/mp4" + } else { + "video/mp4" + }; + let (length, sha256) = crate::episodes::file_hash(&root.join(&name)).await?; + objects.push(p::PreparedMediaObject { + name, + content_type: serde_json::from_value(json!(content_type))?, + byte_length: length as i64, + sha256, + }); + } + Ok(objects) +}