├── .envrc ├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── .versions └── shiori ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── bin ├── minyami │ ├── Cargo.toml │ ├── build.rs │ └── src │ │ └── main.rs ├── shiori │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── Readme.md │ ├── build.rs │ ├── i18n.toml │ ├── i18n │ │ ├── en-US │ │ │ └── shiori.ftl │ │ └── zh-CN │ │ │ └── shiori.ftl │ ├── src │ │ ├── commands.rs │ │ ├── commands │ │ │ ├── download.rs │ │ │ ├── inspect.rs │ │ │ ├── merge.rs │ │ │ └── update.rs │ │ ├── i18n.rs │ │ ├── inspect │ │ │ ├── inspectors.rs │ │ │ ├── inspectors │ │ │ │ ├── dash.rs │ │ │ │ ├── external.rs │ │ │ │ ├── hls.rs │ │ │ │ └── redirect.rs │ │ │ └── mod.rs │ │ ├── lib.rs │ │ └── main.rs │ └── windows.manifest.xml ├── srr │ ├── .gitignore │ ├── Cargo.toml │ ├── Readme.md │ └── src │ │ ├── config.rs │ │ └── main.rs └── ssadecrypt │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── build.rs │ └── src │ └── main.rs ├── clippy.toml ├── crates ├── iori │ ├── Cargo.toml │ ├── examples │ │ ├── dash_archive.rs │ │ ├── dash_live.rs │ │ └── pipe.rs │ ├── src │ │ ├── cache.rs │ │ ├── cache │ │ │ ├── file.rs │ │ │ ├── memory.rs │ │ │ └── opendal.rs │ │ ├── dash │ │ │ ├── archive.rs │ │ │ ├── live.rs │ │ │ ├── live │ │ │ │ ├── clock.rs │ │ │ │ ├── selector.rs │ │ │ │ └── timeline.rs │ │ │ ├── mod.rs │ │ │ ├── segment.rs │ │ │ ├── template.rs │ │ │ └── url.rs │ │ ├── decrypt.rs │ │ ├── download │ │ │ ├── mod.rs │ │ │ ├── parallel.rs │ │ │ └── sequencial.rs │ │ ├── error.rs │ │ ├── fetch.rs │ │ ├── hls │ │ │ ├── archive.rs │ │ │ ├── live.rs │ │ │ ├── mod.rs │ │ │ ├── segment.rs │ │ │ ├── source.rs │ │ │ └── utils.rs │ │ ├── lib.rs │ │ ├── merge.rs │ │ ├── merge │ │ │ ├── auto.rs │ │ │ ├── concat.rs │ │ │ ├── pipe.rs │ │ │ └── skip.rs │ │ ├── raw │ │ │ ├── http.rs │ │ │ └── mod.rs │ │ ├── segment.rs │ │ └── util │ │ │ ├── http.rs │ │ │ ├── mix.rs │ │ │ ├── mod.rs │ │ │ ├── ordered_stream.rs │ │ │ ├── path.rs │ │ │ └── range.rs │ └── tests │ │ ├── dash │ │ ├── dash_mpd_rs.rs │ │ ├── mod.rs │ │ └── static.rs │ │ ├── downloader │ │ ├── mod.rs │ │ └── parallel.rs │ │ ├── fixtures │ │ ├── dash │ │ │ ├── dash-mpd-rs │ │ │ │ ├── a2d-tv.mpd │ │ │ │ └── dash-testcases-5b-1-thomson.mpd │ │ │ └── static │ │ │ │ └── lemino-sokosaku-235.mpd │ │ └── hls │ │ │ ├── m3u8-rs │ │ │ ├── Readme.md │ │ │ ├── media-playlist-with-byterange.m3u8 │ │ │ └── mediaplaylist-byterange.m3u8 │ │ │ └── rfc8216 │ │ │ ├── 8-1-simple-media-playlist.m3u8 │ │ │ ├── 8-2-live-media-playlist-using-https.m3u8 │ │ │ ├── 8-3-playlist-with-encrypted-media-segments.m3u8 │ │ │ ├── 8-4-master-playlist.m3u8 │ │ │ ├── 8-6-master-playlist-with-alternative-audio.m3u8 │ │ │ └── 8-7-master-playlist-with-alternative-video.m3u8 │ │ ├── hls │ │ ├── m3u8_rs.rs │ │ ├── mod.rs │ │ └── rfc8216.rs │ │ ├── lib.rs │ │ ├── source.rs │ │ └── streaming.rs └── ssa │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── Readme.md │ ├── benches │ └── decrypt.rs │ ├── src │ ├── constant.rs │ ├── error.rs │ └── lib.rs │ ├── test │ └── fixtures │ │ └── eac3 │ │ └── script.sh │ └── tests │ ├── decrypt.rs │ └── fixtures │ ├── .gitignore │ ├── ac3 │ ├── script.sh │ ├── segment-0.ts │ └── segment-0.ts.dec │ └── eac3 │ ├── segment-0.ts │ └── segment-0.ts.dec ├── flake.lock ├── flake.nix ├── platforms ├── gigafile │ ├── Cargo.toml │ └── src │ │ ├── client.rs │ │ ├── inspect.rs │ │ └── lib.rs ├── nicolive │ ├── Cargo.toml │ ├── build.rs │ ├── src │ │ ├── danmaku.rs │ │ ├── inspect.rs │ │ ├── lib.rs │ │ ├── model.rs │ │ ├── program.rs │ │ ├── proto │ │ │ └── dwango │ │ │ │ └── nicolive │ │ │ │ └── chat │ │ │ │ ├── data │ │ │ │ ├── atoms.proto │ │ │ │ ├── atoms │ │ │ │ │ ├── moderator.proto │ │ │ │ │ └── sensitive.proto │ │ │ │ ├── message.proto │ │ │ │ ├── origin.proto │ │ │ │ └── state.proto │ │ │ │ └── edge │ │ │ │ └── payload.proto │ │ ├── source.rs │ │ ├── watch.rs │ │ └── xml2ass.rs │ └── test │ │ └── danmaku │ │ ├── ass │ │ ├── 548omake.ass │ │ └── 548本篇.ass │ │ └── json │ │ ├── 548omake.json │ │ └── 548本篇.json └── showroom │ ├── Cargo.toml │ └── src │ ├── constants.rs │ ├── inspect.rs │ ├── lib.rs │ └── model.rs └── plugins └── shiori-plugin ├── Cargo.toml └── src └── lib.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - target: x86_64-unknown-linux-gnu 11 | os: ubuntu-latest 12 | name: iori-x86_64-unknown-linux-gnu.tar.gz 13 | # - target: x86_64-unknown-linux-musl 14 | # os: ubuntu-latest 15 | # name: iori-x86_64-unknown-linux-musl.tar.gz 16 | - target: x86_64-pc-windows-msvc 17 | os: windows-latest 18 | name: iori-x86_64-pc-windows-msvc.zip 19 | - target: x86_64-apple-darwin 20 | os: macOS-latest 21 | name: iori-x86_64-apple-darwin.tar.gz 22 | - target: aarch64-apple-darwin 23 | os: macOS-latest 24 | name: iori-arm64-apple-darwin.tar.gz 25 | 26 | runs-on: ${{ matrix.os }} 27 | continue-on-error: true 28 | steps: 29 | - name: Setup | Checkout 30 | uses: actions/checkout@v2.4.0 31 | 32 | - name: Setup | Cache Cargo 33 | uses: actions/cache@v4.2.0 34 | with: 35 | path: | 36 | ~/.cargo/registry 37 | ~/.cargo/git 38 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 39 | 40 | - name: Setup | Cache Cargo Target 41 | uses: actions/cache@v4.2.0 42 | with: 43 | path: target 44 | key: ${{ matrix.target }}-cargo-target 45 | 46 | - name: Setup | Rust 47 | uses: actions-rs/toolchain@v1 48 | with: 49 | toolchain: stable 50 | override: true 51 | profile: minimal 52 | target: ${{ matrix.target }} 53 | 54 | - name: Setup | Protoc 55 | uses: arduino/setup-protoc@v3 56 | with: 57 | repo-token: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Build | Build 60 | uses: actions-rs/cargo@v1 61 | with: 62 | command: build 63 | args: --release --locked --all-features --target ${{ matrix.target }}" --workspace --exclude srr 64 | 65 | - name: PostBuild | Prepare artifacts [Windows] 66 | if: matrix.os == 'windows-latest' 67 | run: | 68 | cd target/${{ matrix.target }}/release 69 | strip minyami.exe 70 | strip ssadecrypt.exe 71 | strip shiori.exe 72 | 7z a ../../../${{ matrix.name }} minyami.exe ssadecrypt.exe shiori.exe 73 | cd - 74 | - name: PostBuild | Prepare artifacts [-nix] 75 | if: matrix.os != 'windows-latest' 76 | run: | 77 | cd target/${{ matrix.target }}/release 78 | strip minyami || true 79 | strip ssadecrypt || true 80 | strip shiori || true 81 | tar czvf ../../../${{ matrix.name }} minyami ssadecrypt shiori 82 | cd - 83 | 84 | - name: Deploy | Upload artifacts 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: ${{ matrix.name }} 88 | path: ${{ matrix.name }} 89 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | binary_name: 10 | type: choice 11 | description: "Binary name for release" 12 | required: true 13 | options: 14 | - ssadecrypt 15 | - shiori 16 | - minyami 17 | default: ssadecrypt 18 | 19 | jobs: 20 | create-release: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: taiki-e/create-gh-release-action@v1 25 | with: 26 | prefix: ${{ inputs.binary_name }} 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | upload-assets: 30 | needs: create-release 31 | strategy: 32 | matrix: 33 | include: 34 | - target: x86_64-unknown-linux-gnu 35 | os: ubuntu-latest 36 | - target: x86_64-apple-darwin 37 | os: macos-latest 38 | - target: aarch64-apple-darwin 39 | os: macos-latest 40 | - target: x86_64-pc-windows-msvc 41 | os: windows-latest 42 | runs-on: ${{ matrix.os }} 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Setup | Protoc 46 | uses: arduino/setup-protoc@v3 47 | with: 48 | repo-token: ${{ secrets.GITHUB_TOKEN }} 49 | - uses: taiki-e/upload-rust-binary-action@v1 50 | with: 51 | bin: ${{ inputs.binary_name }} 52 | target: ${{ matrix.target }} 53 | tar: unix 54 | zip: windows 55 | token: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # Intellij files 13 | .idea/ 14 | 15 | # Video files 16 | *.ts 17 | *.mkv 18 | *.mp4 19 | shiori_* 20 | 21 | # nix 22 | .direnv -------------------------------------------------------------------------------- /.versions/shiori: -------------------------------------------------------------------------------- 1 | shiori-v0.2.2 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.check.command": "clippy" 3 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["bin/*", "crates/*", "plugins/*", "platforms/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | edition = "2021" 7 | authors = ["Yesterday17 "] 8 | repository = "https://github.com/Yesterday17/iori" 9 | license = "Apache-2.0" 10 | 11 | [workspace.dependencies] 12 | iori = { path = "crates/iori" } 13 | iori-ssa = { path = "crates/ssa" } 14 | 15 | iori-nicolive = { path = "platforms/nicolive" } 16 | iori-showroom = { path = "platforms/showroom" } 17 | iori-gigafile = { path = "platforms/gigafile" } 18 | shiori-plugin = { path = "plugins/shiori-plugin" } 19 | 20 | regex = "1.9.3" 21 | base64 = "0.22.1" 22 | tokio = { version = "1", features = ["signal", "process", "net"] } 23 | 24 | fake_user_agent = "0.2.1" 25 | anyhow = "1.0" 26 | log = "0.4" 27 | tracing = "0.1" 28 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 29 | 30 | serde = { version = "1.0", features = ["derive"] } 31 | serde_json = "1.0" 32 | rmp-serde = "1.3.0" 33 | prost = "0.13" 34 | prost-types = "0.13" 35 | prost-build = "0.13" 36 | 37 | aes = "0.8.4" 38 | cbc = { version = "0.1.2", features = ["std"] } 39 | 40 | reqwest = { version = "^0.12.15", default-features = false, features = [ 41 | "rustls-tls", 42 | "stream", 43 | "json", 44 | "socks", 45 | "cookies", 46 | ] } 47 | 48 | clap = { version = "4.5.34", features = ["derive", "env"] } 49 | 50 | async-recursion = "1.1.1" 51 | 52 | rand = "0.8.5" 53 | getrandom = { version = "0.2", features = ["js"] } 54 | 55 | dash-mpd = { version = "0.18", default-features = false, features = ["scte35"] } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iori 2 | 3 | A brand new HLS / MPEG-Dash stream downloader, with support for both VoD and Live streaming. 4 | 5 | ## Download 6 | 7 | You can get the pre-compiled executable files from `artifacts`. (Like [this](https://github.com/Yesterday17/iori/actions/runs/11423831843)) 8 | 9 | ## Project Structure 10 | 11 | - `bin`: Contains the main executable crates, like `minyami` and `shiori`. 12 | - `crates`: Core library crates, such as `iori` (the downloader core) and `iori-ssa` (Sample-AES decryption). 13 | - `plugins`: Plugin system related crates, like `shiori-plugin`. 14 | - `platforms`: Video platform-specific implementations, such as `iori-nicolive` and `iori-showroom`. 15 | 16 | ## Road to 1.0 17 | 18 | - [ ] Separate decrypt and download -------------------------------------------------------------------------------- /bin/minyami/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-minyami" 3 | version = "0.1.1" 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | iori.workspace = true 8 | iori-nicolive.workspace = true 9 | 10 | tokio = { workspace = true, features = ["full"] } 11 | reqwest.workspace = true 12 | fake_user_agent.workspace = true 13 | anyhow.workspace = true 14 | log.workspace = true 15 | 16 | clap.workspace = true 17 | pretty_env_logger = "0.5.0" 18 | 19 | [[bin]] 20 | name = "minyami" 21 | path = "src/main.rs" 22 | -------------------------------------------------------------------------------- /bin/minyami/build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::process::Command; 3 | 4 | fn get_commit_hash() -> Result> { 5 | let output = Command::new("git") 6 | .args(["rev-parse", "--short", "HEAD"]) 7 | .output()?; 8 | let hash = String::from_utf8(output.stdout)?; 9 | Ok(hash.trim().to_string()) 10 | } 11 | 12 | fn main() { 13 | let version = env!("CARGO_PKG_VERSION"); 14 | let hash = get_commit_hash().unwrap_or_else(|_| "unknown".to_string()); 15 | println!("cargo:rustc-env=IORI_MINYAMI_VERSION={version} ({hash})"); 16 | } 17 | -------------------------------------------------------------------------------- /bin/shiori/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.2] - 2025-06-05 9 | 10 | ### New Features 11 | 12 | - Supported Niconico video download. 13 | 14 | ## [0.2.1] - 2025-06-05 15 | 16 | ### New Features 17 | 18 | - Supported decryption of Sample-AES Elementary Audio Stream Setup. 19 | - Supported concat merge for `aac` format. 20 | - Added `DashInspector` to match `.mpd` manifests. 21 | - **Experimental**: Supported download for MPEG-DASH live stream. 22 | 23 | ### Fixed 24 | 25 | - Fixed a crash caused by i18n bundle on windows. ([#16](https://github.com/Yesterday17/iori/issues/16)) 26 | - CacheSource failure can be retried correctly now. 27 | - Fixed potential segment duplication when using `-m` for in-memory cache. 28 | 29 | ## [0.2.0] - 2025-05-11 30 | 31 | ### Breaking Changes 32 | 33 | - Changed the environment variable to control `temp_dir` form `TEMP` to `TEMP_DIR`. 34 | - Updated inspector argument input. Now you should use the following arguments directly instead of using `-e/--args`: 35 | - `nico-user-session` 36 | - `nico-download-danmaku` 37 | - `nico-chase-play` 38 | - `nico-reserve-timeshift` 39 | - `nico-danmaku-only` 40 | - `showroom-user-session` 41 | 42 | ### New Features 43 | 44 | - **Nicolive**: Added `--nico-chase-play` to download nico live from start. 45 | - **Nicolive**: Added `--nico-reserve-timeshift` to reserve timeshift automatically. 46 | - **Nicolive**: Added `--nico-danmaku-only` to control whether to skip video download. 47 | - **Gigafile**: Experimental support for downloading file from [Gigafile](https://gigafile.nu/). 48 | - Added `--http1` flag to force the http client to connect with `HTTP 1.1`. 49 | 50 | ### Updated 51 | 52 | - Changed default temp dir to `current_dir` instead of `temp_dir`. 53 | - Added some i18n for command line options. 54 | - **iori**: Segments from different streams will be mixed before download. This makes `--pipe-mux` available to play vods. 55 | - **Nicolive**: Added `frontend_id` to `webSocketUrl` to match the behavior of web. 56 | - **Nicolive**: Supported reconnection for Nicolive `WebSocket` client. 57 | - **Nicolive**: Optimized `xml2ass` logic. 58 | - Supported experimental `opendal` cache source in `iori` with `--opendal` flag. 59 | 60 | ### Fixed 61 | 62 | - Fixed panic on error occurs when using `--wait` in `shiori download`. 63 | - Fixed issue where the `--pipe` argument was not working. 64 | - **Nicolive**: Fixed panic when operator is not found in `xml2ass`. 65 | - **iori**: Fixed an issue with m3u8 retrieval intervals caused by precision problems. 66 | 67 | ## [0.1.4] - 2025-04-16 68 | 69 | ### Updated 70 | 71 | - **NicoLive**: Supported danmaku download. 72 | - **Showroom**: Supported timeshift download. 73 | - File extension would be appended to the output file automatically. 74 | - Improved help messages for inspectors. 75 | 76 | ### Fixed 77 | 78 | - **NicoLive**: `NicoLiveInspector` now extracts the best quality stream. 79 | - **NicoLive**: `NicoLiveInspector` now always uses `http1` for `WebSocket` connection. 80 | 81 | 82 | ## [0.1.3] - 2025-03-28 83 | 84 | ### Fixed 85 | 86 | - Hotfix for download command. 87 | 88 | ### Updated 89 | 90 | - Increased timeout for update check to 5 seconds. 91 | - Upgraded clap to 4.5.34. 92 | 93 | ## 0.1.2 - 2025-03-28 94 | 95 | ### Added 96 | 97 | - Added auto update check after download. 98 | - Added `--skip-update` option to skip update check. 99 | - Added `update` subcommand to upgrade shiori to the latest version. 100 | 101 | ### Fixed 102 | 103 | - Downloaded `cmfv` and `cmfa` segments will have correct extension. 104 | 105 | ## [0.1.1] - 2025-03-28 106 | 107 | ### Added 108 | 109 | - Declare [`longPathAware`](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry#application-manifest-updates-to-declare-long-path-capability) to support long path on Windows. 110 | 111 | ### Fixed 112 | 113 | - Merge failure with `mkvmerge` when there are too many segments. 114 | 115 | ## [0.1.0] - 2025-03-27 116 | 117 | ### Added 118 | 119 | - `Nico Timeshift` support. 120 | 121 | [0.1.0]: https://github.com/Yesterday17/iori/tree/shiori-v0.1.0 122 | [0.1.1]: https://github.com/Yesterday17/iori/tree/shiori-v0.1.1 123 | [0.1.3]: https://github.com/Yesterday17/iori/tree/shiori-v0.1.3 124 | [0.1.4]: https://github.com/Yesterday17/iori/tree/shiori-v0.1.4 125 | [0.2.0]: https://github.com/Yesterday17/iori/tree/shiori-v0.2.0 126 | [0.2.1]: https://github.com/Yesterday17/iori/tree/shiori-v0.2.1 127 | [0.2.2]: https://github.com/Yesterday17/iori/tree/shiori-v0.2.2 128 | -------------------------------------------------------------------------------- /bin/shiori/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori" 3 | description = "A brand new video stream downloader" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | authors.workspace = true 7 | repository.workspace = true 8 | license.workspace = true 9 | 10 | [dependencies] 11 | iori = { workspace = true, features = ["opendal-fs"] } 12 | iori-nicolive.workspace = true 13 | iori-showroom.workspace = true 14 | iori-gigafile.workspace = true 15 | 16 | tokio = { workspace = true, features = ["full"] } 17 | reqwest.workspace = true 18 | fake_user_agent.workspace = true 19 | anyhow.workspace = true 20 | log.workspace = true 21 | serde.workspace = true 22 | 23 | clap.workspace = true 24 | clap-handler = { version = "0.1.2", features = ["async"] } 25 | rand = "0.9.0" 26 | regex.workspace = true 27 | async-recursion.workspace = true 28 | shlex = "1.3.0" 29 | rmp-serde.workspace = true 30 | base64.workspace = true 31 | chrono = "0.4.39" 32 | ratatui = "0.29.0" 33 | crossterm = "0.28.1" 34 | 35 | shiori-plugin.workspace = true 36 | tracing-subscriber.workspace = true 37 | self_update = { version = "0.42.0", default-features = false, features = [ 38 | "rustls", 39 | "compression-zip-deflate", 40 | "compression-flate2", 41 | ] } 42 | 43 | i18n-embed = { version = "0.15.4", features = [ 44 | "fluent-system", 45 | "desktop-requester", 46 | "filesystem-assets", 47 | ] } 48 | i18n-embed-fl = "0.9.4" 49 | rust-embed = "8.7.0" 50 | 51 | [[bin]] 52 | name = "shiori" 53 | path = "src/main.rs" 54 | -------------------------------------------------------------------------------- /bin/shiori/Readme.md: -------------------------------------------------------------------------------- 1 | # Shiori 2 | 3 | Shiori(`sh-iori`) is a WIP video downloader which supports multiple platforms. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | shi -d https://live.nicovideo.jp/watch/lv346791885 9 | ``` -------------------------------------------------------------------------------- /bin/shiori/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::error::Error; 3 | use std::process::Command; 4 | 5 | fn get_commit_hash() -> Result> { 6 | let output = Command::new("git") 7 | .args(["rev-parse", "--short", "HEAD"]) 8 | .output()?; 9 | let hash = String::from_utf8(output.stdout)?; 10 | Ok(hash.trim().to_string()) 11 | } 12 | 13 | fn main() { 14 | let version = env!("CARGO_PKG_VERSION"); 15 | let hash = get_commit_hash().unwrap_or_else(|_| "unknown".to_string()); 16 | println!("cargo:rustc-env=SHIORI_VERSION={version} ({hash})"); 17 | 18 | let target_os = env::var("CARGO_CFG_TARGET_OS"); 19 | let target_env = env::var("CARGO_CFG_TARGET_ENV"); 20 | if Ok("windows") == target_os.as_deref() && Ok("msvc") == target_env.as_deref() { 21 | set_windows_exe_options(); 22 | } else { 23 | // Avoid rerunning the build script every time. 24 | println!("cargo:rerun-if-changed=build.rs"); 25 | } 26 | } 27 | 28 | // Add a manifest file to rustc.exe. 29 | fn set_windows_exe_options() { 30 | static WINDOWS_MANIFEST_FILE: &str = "windows.manifest.xml"; 31 | 32 | let mut manifest = env::current_dir().unwrap(); 33 | manifest.push(WINDOWS_MANIFEST_FILE); 34 | 35 | println!("cargo:rerun-if-changed={WINDOWS_MANIFEST_FILE}"); 36 | // Embed the Windows application manifest file. 37 | println!("cargo:rustc-link-arg-bin=shiori=/MANIFEST:EMBED"); 38 | println!( 39 | "cargo:rustc-link-arg-bin=shiori=/MANIFESTINPUT:{}", 40 | manifest.to_str().unwrap() 41 | ); 42 | // Turn linker warnings into errors. 43 | println!("cargo:rustc-link-arg-bin=shiori=/WX"); 44 | } 45 | -------------------------------------------------------------------------------- /bin/shiori/i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en-US" 2 | 3 | [fluent] 4 | assets_dir = "i18n" 5 | -------------------------------------------------------------------------------- /bin/shiori/i18n/en-US/shiori.ftl: -------------------------------------------------------------------------------- 1 | shiori-about = Yet another m3u8 downloader 2 | 3 | download-wait = Wait for stream to start when no stream is detected 4 | download-url = URL to download 5 | 6 | download-http-headers = Additional HTTP headers for all HTTP requests, format is key: value 7 | download-http-cookies = 8 | {"["}Advanced] Additional HTTP cookies 9 | 10 | Will not take effect if `Cookies` is set in `headers`. 11 | Do not use this option unless you know what you are doing. 12 | download-http-timeout = HTTP timeout, in seconds 13 | download-http-http1-only = Force to use HTTP/1.1 for requests 14 | 15 | download-concurrency = Threads limit 16 | download-segment-retries = Segment retry limit 17 | # download-segment-retry-delay = Set retry delay after download fails in seconds 18 | download-manifest-retries = Manifest retry limit 19 | 20 | download-cache-in-menory-cache = Use in-memory cache and do not write cache to disk while downloading 21 | download-cache-temp-dir = 22 | Temporary directory 23 | 24 | The default temp dir is the current directory or the system temp dir. 25 | Will not take effect if `cache_dir` is set. 26 | download-cache-cache-dir = 27 | {"["}Advanced] Cache directory 28 | 29 | Speficy a directory to store cache files. 30 | 31 | If specified, the cache will be stored in this directory directly without creating a subdirectory. 32 | 33 | download-output-no-merge = Do not merge stream 34 | download-output-concat = Merge files using concat 35 | download-output-output = Output filename 36 | download-output-pipe = Pipe to stdout 37 | download-output-pipe-mux = Mux with ffmpeg. Only works when `--pipe` is set. 38 | download-output-pipe-to = Pipe to a file 39 | -------------------------------------------------------------------------------- /bin/shiori/i18n/zh-CN/shiori.ftl: -------------------------------------------------------------------------------- 1 | shiori-about = 又一个直播下载器 2 | 3 | download-wait = 当未检测到直播流时,是否等待直播流开始 4 | download-url = 视频地址 5 | 6 | download-http-headers = 设置 HTTP header,格式为 key: value 7 | download-http-cookies = 8 | {"["}高级选项] 设置 Cookie 9 | 10 | 当 headers 中有 Cookie 时,该选项不会生效。 11 | 如果你不知道这个字段要如何使用,请不要设置它。 12 | download-http-timeout = 下载超时时间,单位为秒 13 | download-http-http1-only = 强制使用 HTTP/1.1 进行 http 请求 14 | 15 | download-concurrency = 并发数 16 | download-segment-retries = 分块下载重试次数 17 | # download-segment-retry-delay = 设置下载失败后重试的延迟,单位为秒 18 | download-manifest-retries = manifest 下载重试次数 19 | 20 | download-cache-in-menory-cache = 使用内存缓存,下载时不将缓存写入磁盘 21 | download-cache-temp-dir = 22 | 临时目录 23 | 24 | 默认临时目录是当前目录或系统临时目录。 25 | 如果设置了 `cache_dir`,则此选项无效。 26 | download-cache-cache-dir = 27 | {"["}高级选项] 缓存目录 28 | 29 | 存储分块及下载时产生的临时文件的目录。 30 | 文件会直接存储在该目录下,而不会创建子目录。为安全起见,请自行创建子目录。 31 | 32 | download-output-no-merge = 跳过合并 33 | download-output-concat = 使用 Concat 合并文件 34 | download-output-output = 输出文件名 35 | download-output-pipe = 输出到标准输出 36 | download-output-pipe-mux = 使用 FFmpeg 混流,仅在 `--pipe` 生效时有效 37 | download-output-pipe-to = 使用 Pipe 输出到指定路径 38 | -------------------------------------------------------------------------------- /bin/shiori/src/commands.rs: -------------------------------------------------------------------------------- 1 | use clap::{builder::styling, ArgAction, Parser, Subcommand}; 2 | use clap_handler::Handler; 3 | 4 | use crate::ll; 5 | 6 | pub mod download; 7 | pub mod inspect; 8 | pub mod merge; 9 | pub mod update; 10 | 11 | pub const STYLES: styling::Styles = styling::Styles::styled() 12 | .header(styling::AnsiColor::Green.on_default().bold().underline()) 13 | .usage(styling::AnsiColor::Green.on_default().bold()) 14 | .literal(styling::AnsiColor::Blue.on_default().bold()) 15 | .placeholder(styling::AnsiColor::Cyan.on_default()); 16 | 17 | #[derive(Parser, Handler, Clone)] 18 | #[clap(name = "shiori", version = env!("SHIORI_VERSION"), author, styles = STYLES)] 19 | #[clap(about = ll!("shiori-about"))] 20 | pub struct ShioriArgs { 21 | /// Whether to skip update check 22 | #[clap(long = "skip-update", action = ArgAction::SetFalse)] 23 | update_check: bool, 24 | 25 | #[clap(subcommand)] 26 | command: ShioriCommand, 27 | } 28 | 29 | #[derive(Subcommand, Handler, Clone)] 30 | pub enum ShioriCommand { 31 | #[clap(after_help = inspect::get_default_external_inspector().help())] 32 | Download(download::DownloadCommand), 33 | #[clap(after_help = inspect::get_default_external_inspector().help())] 34 | Inspect(inspect::InspectCommand), 35 | Merge(merge::MergeCommand), 36 | Update(update::UpdateCommand), 37 | } 38 | -------------------------------------------------------------------------------- /bin/shiori/src/commands/inspect.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{ 2 | inspectors::{DashInspector, ExternalInspector, HlsInspector, ShortLinkInspector}, 3 | Inspectors, 4 | }; 5 | use clap::Parser; 6 | use clap_handler::handler; 7 | use iori_gigafile::GigafileInspector; 8 | use iori_nicolive::inspect::{NicoLiveInspector, NicoVideoInspector}; 9 | use iori_showroom::inspect::ShowroomInspector; 10 | use shiori_plugin::{InspectorArguments, InspectorCommand}; 11 | 12 | #[derive(Parser, Clone, Default)] 13 | #[clap(name = "inspect", short_flag = 'S')] 14 | pub struct InspectCommand { 15 | #[clap(short, long)] 16 | wait: bool, 17 | 18 | #[clap(flatten)] 19 | inspector_options: InspectorOptions, 20 | 21 | url: String, 22 | } 23 | 24 | pub(crate) fn get_default_external_inspector() -> Inspectors { 25 | let mut inspector = Inspectors::new(); 26 | inspector 27 | .add(ShortLinkInspector) 28 | .add(ShowroomInspector) 29 | .add(NicoLiveInspector) 30 | .add(NicoVideoInspector) 31 | .add(GigafileInspector) 32 | .add(HlsInspector) 33 | .add(DashInspector) 34 | .add(ExternalInspector); 35 | 36 | inspector 37 | } 38 | 39 | #[handler(InspectCommand)] 40 | async fn handle_inspect(this: InspectCommand) -> anyhow::Result<()> { 41 | let (matched_inspector, data) = get_default_external_inspector() 42 | .wait(this.wait) 43 | .inspect(&this.url, &this.inspector_options, |c| { 44 | c.into_iter().next().unwrap() 45 | }) 46 | .await?; 47 | 48 | eprintln!("{matched_inspector}: {data:?}"); 49 | 50 | Ok(()) 51 | } 52 | 53 | #[derive(Clone, Debug, Default)] 54 | pub struct InspectorOptions { 55 | arg_matches: clap::ArgMatches, 56 | } 57 | 58 | impl InspectorOptions { 59 | pub fn new(arg_matches: clap::ArgMatches) -> Self { 60 | Self { arg_matches } 61 | } 62 | } 63 | 64 | impl InspectorArguments for InspectorOptions { 65 | fn get_string(&self, argument: &'static str) -> Option { 66 | self.arg_matches.get_one::(argument).cloned() 67 | } 68 | 69 | fn get_boolean(&self, argument: &'static str) -> bool { 70 | self.arg_matches 71 | .get_one::(argument) 72 | .copied() 73 | .unwrap_or(false) 74 | } 75 | } 76 | 77 | impl clap::FromArgMatches for InspectorOptions { 78 | fn from_arg_matches(arg_matches: &clap::ArgMatches) -> Result { 79 | Ok(Self::new(arg_matches.clone())) 80 | } 81 | 82 | fn from_arg_matches_mut(arg_matches: &mut clap::ArgMatches) -> Result { 83 | Ok(Self::new(arg_matches.clone())) 84 | } 85 | 86 | fn update_from_arg_matches( 87 | &mut self, 88 | arg_matches: &clap::ArgMatches, 89 | ) -> Result<(), clap::Error> { 90 | self.update_from_arg_matches_mut(&mut arg_matches.clone()) 91 | } 92 | 93 | fn update_from_arg_matches_mut( 94 | &mut self, 95 | arg_matches: &mut clap::ArgMatches, 96 | ) -> Result<(), clap::Error> { 97 | self.arg_matches = arg_matches.clone(); 98 | Result::Ok(()) 99 | } 100 | } 101 | 102 | impl clap::Args for InspectorOptions { 103 | fn group_id() -> Option { 104 | Some(clap::Id::from("InspectorOptions")) 105 | } 106 | 107 | fn augment_args<'b>(command: clap::Command) -> clap::Command { 108 | InspectorOptions::augment_args_for_update(command) 109 | } 110 | 111 | fn augment_args_for_update<'b>(command: clap::Command) -> clap::Command { 112 | let inspectors = get_default_external_inspector(); 113 | let mut wrapper = InspectorCommandWrapper::new(command); 114 | inspectors.add_arguments(&mut wrapper); 115 | 116 | wrapper.into_inner() 117 | } 118 | } 119 | 120 | struct InspectorCommandWrapper(Option); 121 | 122 | impl InspectorCommandWrapper { 123 | fn new(command: clap::Command) -> Self { 124 | Self(Some(command)) 125 | } 126 | 127 | fn into_inner(self) -> clap::Command { 128 | self.0.unwrap() 129 | } 130 | } 131 | 132 | impl InspectorCommand for InspectorCommandWrapper { 133 | fn add_argument( 134 | &mut self, 135 | long: &'static str, 136 | value_name: Option<&'static str>, 137 | help: &'static str, 138 | ) { 139 | let command = self.0.take().unwrap(); 140 | self.0 = Some( 141 | command.arg( 142 | clap::Arg::new(long) 143 | .value_name(value_name.unwrap_or(long)) 144 | .value_parser(clap::value_parser!(String)) 145 | .action(clap::ArgAction::Set) 146 | .long(long) 147 | .help(help), 148 | ), 149 | ); 150 | } 151 | 152 | fn add_boolean_argument(&mut self, long: &'static str, help: &'static str) { 153 | let command = self.0.take().unwrap(); 154 | self.0 = Some( 155 | command.arg( 156 | clap::Arg::new(long) 157 | .value_parser(clap::value_parser!(bool)) 158 | .action(clap::ArgAction::SetTrue) 159 | .long(long) 160 | .help(help), 161 | ), 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /bin/shiori/src/commands/merge.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::PathBuf, sync::Arc}; 2 | 3 | use clap::Parser; 4 | use clap_handler::handler; 5 | use iori::{ 6 | cache::CacheSource, 7 | merge::{IoriMerger, Merger}, 8 | SegmentFormat, SegmentInfo, 9 | }; 10 | use tokio::{ 11 | fs::{read_dir, File}, 12 | io::BufReader, 13 | sync::Mutex, 14 | }; 15 | 16 | struct ExistingLocalCache { 17 | files: Mutex>, 18 | } 19 | 20 | impl ExistingLocalCache { 21 | fn new() -> Self { 22 | Self { 23 | files: Mutex::new(HashMap::new()), 24 | } 25 | } 26 | 27 | async fn add_file(&self, segment: &SegmentInfo, file: PathBuf) { 28 | self.files.lock().await.insert(segment.sequence, file); 29 | } 30 | } 31 | 32 | impl CacheSource for ExistingLocalCache { 33 | async fn open_writer( 34 | &self, 35 | _segment: &iori::SegmentInfo, 36 | ) -> iori::IoriResult> { 37 | unreachable!() 38 | } 39 | 40 | async fn open_reader( 41 | &self, 42 | segment: &iori::SegmentInfo, 43 | ) -> iori::IoriResult { 44 | let lock = self.files.lock().await; 45 | let file = lock.get(&segment.sequence).unwrap(); 46 | let file = File::open(file).await?; 47 | let file = BufReader::new(file); 48 | Ok(Box::new(file)) 49 | } 50 | 51 | async fn segment_path(&self, segment: &SegmentInfo) -> Option { 52 | self.files.lock().await.get(&segment.sequence).cloned() 53 | } 54 | 55 | async fn invalidate(&self, _segment: &iori::SegmentInfo) -> iori::IoriResult<()> { 56 | todo!() 57 | } 58 | 59 | async fn clear(&self) -> iori::IoriResult<()> { 60 | todo!() 61 | } 62 | } 63 | 64 | #[derive(Parser, Clone, Default, Debug)] 65 | #[clap(name = "merge", short_flag = 'm')] 66 | pub struct MergeCommand { 67 | #[clap(long)] 68 | pub concat: bool, 69 | 70 | #[clap(long, default_value = "ts")] 71 | pub format: SegmentFormat, 72 | 73 | #[clap(short, long)] 74 | pub output: PathBuf, 75 | 76 | pub inputs: Vec, 77 | } 78 | 79 | #[handler(MergeCommand)] 80 | pub async fn merge_command(me: MergeCommand) -> anyhow::Result<()> { 81 | eprintln!("{:#?}", me); 82 | 83 | let cache = Arc::new(ExistingLocalCache::new()); 84 | let mut merger = if me.concat { 85 | IoriMerger::concat(me.output, true) 86 | } else { 87 | IoriMerger::auto(me.output, true) 88 | }; 89 | 90 | let files = if me.inputs.len() == 1 && me.inputs[0].is_dir() { 91 | // read all files in directory and merge 92 | let mut dir = read_dir(&me.inputs[0]).await?; 93 | let mut files = Vec::new(); 94 | while let Some(entry) = dir.next_entry().await? { 95 | let path = entry.path(); 96 | if path.ends_with(".DS_Store") { 97 | continue; 98 | } 99 | 100 | if path.is_file() { 101 | files.push(path); 102 | } 103 | } 104 | files.sort(); 105 | files 106 | } else { 107 | me.inputs 108 | }; 109 | 110 | eprintln!("{:#?}", files); 111 | for (sequence, input) in files.into_iter().enumerate() { 112 | let segment = iori::SegmentInfo { 113 | sequence: sequence as u64, 114 | format: me.format.clone(), 115 | ..Default::default() 116 | }; 117 | cache.add_file(&segment, input).await; 118 | merger.update(segment, cache.clone()).await?; 119 | } 120 | 121 | merger.finish(cache).await?; 122 | 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /bin/shiori/src/commands/update.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use clap_handler::handler; 3 | use self_update::{cargo_crate_version, get_target}; 4 | 5 | #[derive(Parser, Clone, Default, Debug)] 6 | #[clap(name = "update")] 7 | /// Update the shiori binary 8 | pub struct UpdateCommand { 9 | /// Custom URL for the versions file 10 | #[clap( 11 | long, 12 | default_value = "https://raw.githubusercontent.com/Yesterday17/iori/refs/heads/master/.versions/shiori" 13 | )] 14 | versions_url: String, 15 | 16 | /// Target version to update to 17 | #[clap(short = 'v', long)] 18 | version: Option, 19 | 20 | /// Target platform 21 | #[clap(long)] 22 | target: Option, 23 | 24 | #[clap(short = 'y', long = "yes")] 25 | skip_confirm: bool, 26 | } 27 | 28 | #[handler(UpdateCommand)] 29 | pub async fn update_command(me: UpdateCommand) -> anyhow::Result<()> { 30 | let target = me.target.unwrap_or_else(|| get_target().to_string()); 31 | let target_version_tag = if let Some(version) = me.version { 32 | format!("shiori-v{version}") 33 | } else { 34 | reqwest::get(me.versions_url).await?.text().await? 35 | }; 36 | 37 | let status = self_update::backends::github::Update::configure() 38 | .repo_owner("Yesterday17") 39 | .repo_name("iori") 40 | .bin_name("shiori") 41 | .target(&target) 42 | .target_version_tag(&target_version_tag) 43 | .show_download_progress(true) 44 | .current_version(cargo_crate_version!()) 45 | .no_confirm(me.skip_confirm) 46 | .build()? 47 | .update()?; 48 | 49 | println!("Update status: `{}`!", status.updated()); 50 | 51 | Ok(()) 52 | } 53 | 54 | pub(crate) async fn check_update() -> anyhow::Result<()> { 55 | let current_version = format!("shiori-v{}", cargo_crate_version!()); 56 | 57 | let latest = reqwest::Client::new() 58 | .get( 59 | "https://raw.githubusercontent.com/Yesterday17/iori/refs/heads/master/.versions/shiori", 60 | ) 61 | .timeout(std::time::Duration::from_secs(5)) 62 | .send() 63 | .await? 64 | .text() 65 | .await?; 66 | 67 | if current_version == latest { 68 | return Ok(()); 69 | } 70 | log::info!( 71 | "Update available: {}. Please run `shiori update` to update.", 72 | latest 73 | ); 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /bin/shiori/src/i18n.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use i18n_embed::{ 4 | fluent::{fluent_language_loader, FluentLanguageLoader}, 5 | DesktopLanguageRequester, LanguageLoader, 6 | }; 7 | use rust_embed::RustEmbed; 8 | 9 | #[derive(RustEmbed)] 10 | #[folder = "i18n"] 11 | struct Localizations; 12 | 13 | fn init_i18n() -> FluentLanguageLoader { 14 | let loader: FluentLanguageLoader = fluent_language_loader!(); 15 | let mut references = DesktopLanguageRequester::requested_languages(); 16 | references.push(loader.fallback_language().clone()); 17 | 18 | let languages = i18n_embed::select(&loader, &Localizations, &references).expect("msg"); 19 | loader 20 | .load_languages(&Localizations, &languages) 21 | .expect("Failed to load localization."); 22 | loader 23 | } 24 | 25 | pub static LOCALIZATION_LOADER: LazyLock = LazyLock::new(init_i18n); 26 | 27 | #[macro_export] 28 | macro_rules! fl { 29 | ($message_id: literal) => { 30 | i18n_embed_fl::fl!($crate::i18n::LOCALIZATION_LOADER, $message_id) 31 | }; 32 | 33 | ($message_id: literal, $($args: expr),*) => { 34 | i18n_embed_fl::fl!($crate::i18n::LOCALIZATION_LOADER, $message_id, $($args), *) 35 | }; 36 | } 37 | 38 | #[macro_export] 39 | macro_rules! ll { 40 | ($message_id: literal) => { 41 | Box::leak($crate::fl!($message_id).into_boxed_str()) as &'static str 42 | }; 43 | } 44 | 45 | #[macro_export] 46 | macro_rules! ball { 47 | ($message_id: literal) => { 48 | bail!($crate::fl!($message_id)) 49 | }; 50 | 51 | ($message_id: literal, $($args: expr),*) => { 52 | bail!($crate::fl!($message_id, $($args), *)) 53 | }; 54 | } 55 | 56 | pub trait ClapI18n { 57 | fn about_ll(self, key: &'static str) -> Self; 58 | } 59 | 60 | impl ClapI18n for clap::Command { 61 | fn about_ll(self, key: &'static str) -> Self { 62 | self.about(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 63 | } 64 | } 65 | 66 | impl ClapI18n for clap::Arg { 67 | fn about_ll(self, key: &'static str) -> Self { 68 | self.help(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors.rs: -------------------------------------------------------------------------------- 1 | mod redirect; 2 | pub use redirect::ShortLinkInspector; 3 | 4 | mod external; 5 | pub use external::ExternalInspector; 6 | 7 | mod hls; 8 | pub use hls::HlsInspector; 9 | 10 | mod dash; 11 | pub use dash::DashInspector; 12 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/dash.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{Inspect, InspectResult}; 2 | use clap_handler::async_trait; 3 | use shiori_plugin::{InspectPlaylist, InspectorArguments, InspectorBuilder, PlaylistType}; 4 | 5 | pub struct DashInspector; 6 | 7 | impl InspectorBuilder for DashInspector { 8 | fn name(&self) -> String { 9 | "dash".to_string() 10 | } 11 | 12 | fn help(&self) -> Vec { 13 | [ 14 | "Downloads MPEG-DASH manifests from the given URL.", 15 | "", 16 | "Requires the URL to contain '.mpd'.", 17 | ] 18 | .iter() 19 | .map(|s| s.to_string()) 20 | .collect() 21 | } 22 | 23 | fn build(&self, _args: &dyn InspectorArguments) -> anyhow::Result> { 24 | Ok(Box::new(Self)) 25 | } 26 | } 27 | 28 | #[async_trait] 29 | impl Inspect for DashInspector { 30 | async fn matches(&self, url: &str) -> bool { 31 | url.contains(".mpd") 32 | } 33 | 34 | async fn inspect(&self, url: &str) -> anyhow::Result { 35 | Ok(InspectResult::Playlist(InspectPlaylist { 36 | playlist_url: url.to_string(), 37 | playlist_type: PlaylistType::DASH, 38 | ..Default::default() 39 | })) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/external.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{Inspect, InspectCandidate, InspectResult}; 2 | use base64::{prelude::BASE64_STANDARD, Engine}; 3 | use clap_handler::async_trait; 4 | use shiori_plugin::{InspectorArguments, InspectorBuilder}; 5 | use std::process::{Command, Stdio}; 6 | 7 | pub struct ExternalInspector; 8 | 9 | impl InspectorBuilder for ExternalInspector { 10 | fn name(&self) -> String { 11 | "external".to_string() 12 | } 13 | 14 | fn arguments(&self, command: &mut dyn shiori_plugin::InspectorCommand) { 15 | command.add_argument("plugin-command", Some("command"), ""); 16 | } 17 | 18 | fn build(&self, args: &dyn InspectorArguments) -> anyhow::Result> { 19 | let command = args.get_string("plugin-command"); 20 | Ok(Box::new(ExternalInspectorImpl::new(command)?)) 21 | } 22 | } 23 | 24 | struct ExternalInspectorImpl { 25 | program: Option, 26 | args: Vec, 27 | } 28 | 29 | impl ExternalInspectorImpl { 30 | pub fn new(command: Option) -> anyhow::Result { 31 | let Some(command) = command else { 32 | return Ok(Self { 33 | program: None, 34 | args: Vec::new(), 35 | }); 36 | }; 37 | 38 | let result = shlex::split(&command).unwrap_or_default(); 39 | let program = result 40 | .first() 41 | .ok_or_else(|| anyhow::anyhow!("Invalid command"))? 42 | .to_string(); 43 | let args = result.into_iter().skip(1).map(|s| s.to_string()).collect(); 44 | 45 | Ok(Self { 46 | program: Some(program), 47 | args, 48 | }) 49 | } 50 | 51 | fn program(&self) -> &str { 52 | self.program.as_deref().unwrap() 53 | } 54 | } 55 | 56 | #[async_trait] 57 | impl Inspect for ExternalInspectorImpl { 58 | async fn matches(&self, _url: &str) -> bool { 59 | self.program.is_some() 60 | } 61 | 62 | async fn inspect(&self, url: &str) -> anyhow::Result { 63 | let mut child = Command::new(self.program()) 64 | .args(self.args.as_slice()) 65 | .arg("inspect") 66 | .arg(url) 67 | .stdout(Stdio::piped()) 68 | .stderr(Stdio::null()) 69 | .spawn()?; 70 | 71 | let Some(stdout) = child.stdout.take() else { 72 | return Err(anyhow::anyhow!("Failed to get external output")); 73 | }; 74 | let data: InspectResult = rmp_serde::from_read(stdout)?; 75 | Ok(data) 76 | } 77 | 78 | async fn inspect_candidate( 79 | &self, 80 | candidate: InspectCandidate, 81 | ) -> anyhow::Result { 82 | let mut child = Command::new(self.program()) 83 | .args(self.args.as_slice()) 84 | .arg("inspect-candidate") 85 | .arg({ 86 | let candidate = rmp_serde::to_vec(&candidate)?; 87 | BASE64_STANDARD.encode(candidate) 88 | }) 89 | .stdout(Stdio::piped()) 90 | .stderr(Stdio::null()) 91 | .spawn()?; 92 | 93 | let Some(stdout) = child.stdout.take() else { 94 | return Err(anyhow::anyhow!("Failed to get stdout")); 95 | }; 96 | let data: InspectResult = rmp_serde::from_read(stdout)?; 97 | Ok(data) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/hls.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{Inspect, InspectResult}; 2 | use clap_handler::async_trait; 3 | use shiori_plugin::{InspectPlaylist, InspectorArguments, InspectorBuilder, PlaylistType}; 4 | 5 | pub struct HlsInspector; 6 | 7 | impl InspectorBuilder for HlsInspector { 8 | fn name(&self) -> String { 9 | "hls".to_string() 10 | } 11 | 12 | fn help(&self) -> Vec { 13 | [ 14 | "Downloads HLS playlists from the given URL.", 15 | "", 16 | "Requires the URL to contain '.m3u8'.", 17 | ] 18 | .iter() 19 | .map(|s| s.to_string()) 20 | .collect() 21 | } 22 | 23 | fn build(&self, _args: &dyn InspectorArguments) -> anyhow::Result> { 24 | Ok(Box::new(Self)) 25 | } 26 | } 27 | 28 | #[async_trait] 29 | impl Inspect for HlsInspector { 30 | async fn matches(&self, url: &str) -> bool { 31 | url.contains(".m3u8") 32 | } 33 | 34 | async fn inspect(&self, url: &str) -> anyhow::Result { 35 | Ok(InspectResult::Playlist(InspectPlaylist { 36 | playlist_url: url.to_string(), 37 | playlist_type: PlaylistType::HLS, 38 | ..Default::default() 39 | })) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/redirect.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{Inspect, InspectResult}; 2 | use clap_handler::async_trait; 3 | use regex::Regex; 4 | use reqwest::redirect::Policy; 5 | use shiori_plugin::{InspectorArguments, InspectorBuilder}; 6 | use std::sync::LazyLock; 7 | 8 | pub struct ShortLinkInspector; 9 | 10 | impl InspectorBuilder for ShortLinkInspector { 11 | fn name(&self) -> String { 12 | "redirect".to_string() 13 | } 14 | 15 | fn help(&self) -> Vec { 16 | [ 17 | "Redirects shortlinks to the original URL.", 18 | "", 19 | "Available services:", 20 | "- X/Twitter: https://t.co/*", 21 | ] 22 | .iter() 23 | .map(|s| s.to_string()) 24 | .collect() 25 | } 26 | 27 | fn build(&self, _args: &dyn InspectorArguments) -> anyhow::Result> { 28 | Ok(Box::new(Self)) 29 | } 30 | } 31 | 32 | static TWITTER_SHORT_LINK_REGEX: LazyLock = 33 | LazyLock::new(|| regex::Regex::new(r"https://t.co/\w+").unwrap()); 34 | 35 | #[async_trait] 36 | impl Inspect for ShortLinkInspector { 37 | async fn matches(&self, url: &str) -> bool { 38 | TWITTER_SHORT_LINK_REGEX.is_match(url) 39 | } 40 | 41 | async fn inspect(&self, url: &str) -> anyhow::Result { 42 | let client = reqwest::Client::builder() 43 | .danger_accept_invalid_certs(true) 44 | .redirect(Policy::none()) 45 | .build()?; 46 | let response = client.head(url).send().await?; 47 | let location = response 48 | .headers() 49 | .get("location") 50 | .and_then(|l| l.to_str().ok()); 51 | 52 | if let Some(location) = location { 53 | Ok(InspectResult::Redirect(location.to_string())) 54 | } else { 55 | Ok(InspectResult::None) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod inspectors; 2 | 3 | pub use shiori_plugin::*; 4 | use std::{borrow::Cow, time::Duration}; 5 | use tokio::time::sleep; 6 | 7 | use crate::commands::STYLES; 8 | 9 | #[derive(Default)] 10 | pub struct Inspectors { 11 | /// Whether to wait on found 12 | wait: Option, 13 | 14 | front: Vec>, 15 | tail: Vec>, 16 | } 17 | 18 | impl Inspectors { 19 | pub fn new() -> Self { 20 | Self::default() 21 | } 22 | 23 | /// Add inspector to front queue 24 | pub fn add(&mut self, builder: impl InspectorBuilder + Send + Sync + 'static) -> &mut Self { 25 | self.front.push(Box::new(builder)); 26 | self 27 | } 28 | 29 | pub fn push(&mut self, builder: impl InspectorBuilder + Send + Sync + 'static) -> &mut Self { 30 | self.tail.push(Box::new(builder)); 31 | self 32 | } 33 | 34 | pub fn wait(mut self, value: bool) -> Self { 35 | self.wait = if value { Some(5) } else { None }; 36 | self 37 | } 38 | 39 | pub fn wait_for(mut self, value: u64) -> Self { 40 | self.wait = Some(value); 41 | self 42 | } 43 | 44 | pub fn help(self) -> String { 45 | let mut is_first = true; 46 | 47 | let mut result = format!("{style}Inspectors:{style:#}\n", style = STYLES.get_header()); 48 | 49 | let inspectors = self.front.iter().chain(self.tail.iter()); 50 | for inspector in inspectors { 51 | if !is_first { 52 | result.push('\n'); 53 | } 54 | is_first = false; 55 | 56 | result.push_str(&format!( 57 | " {style}{}:{style:#}\n", 58 | inspector.name(), 59 | style = STYLES.get_literal() 60 | )); 61 | for line in inspector.help() { 62 | result.push_str(&" ".repeat(10)); 63 | result.push_str(&line); 64 | result.push('\n'); 65 | } 66 | } 67 | result 68 | } 69 | 70 | pub fn add_arguments(&self, command: &mut impl InspectorCommand) { 71 | for inspector in self.front.iter().chain(self.tail.iter()) { 72 | inspector.arguments(command); 73 | } 74 | } 75 | 76 | pub async fn inspect( 77 | self, 78 | url: &str, 79 | args: &dyn InspectorArguments, 80 | choose_candidate: fn(Vec) -> InspectCandidate, 81 | ) -> anyhow::Result<(String, Vec)> { 82 | let inspectors = self 83 | .front 84 | .iter() 85 | .chain(self.tail.iter()) 86 | .map(|b| b.build(args).map(|i| (b, i))) 87 | .collect::>>()?; 88 | 89 | let mut url = Cow::Borrowed(url); 90 | 91 | for (builder, inspector) in inspectors { 92 | if inspector.matches(&url).await { 93 | loop { 94 | let result = inspector 95 | .inspect(&url) 96 | .await 97 | .inspect_err(|e| log::error!("Failed to inspect {url}: {:?}", e)) 98 | .ok(); 99 | let result = 100 | handle_inspect_result(inspector.as_ref(), result, choose_candidate).await; 101 | match result { 102 | InspectBranch::Continue => break, 103 | InspectBranch::Redirect(redirect_url) => { 104 | url = Cow::Owned(redirect_url); 105 | break; 106 | } 107 | InspectBranch::Found(data) => return Ok((builder.name(), data)), 108 | InspectBranch::NotFound => { 109 | if let Some(wait_time) = self.wait { 110 | sleep(Duration::from_secs(wait_time)).await; 111 | } else { 112 | anyhow::bail!("Not found") 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | anyhow::bail!("No inspector matched") 121 | } 122 | } 123 | 124 | enum InspectBranch { 125 | Continue, 126 | Redirect(String), 127 | Found(Vec), 128 | NotFound, 129 | } 130 | 131 | #[async_recursion::async_recursion] 132 | async fn handle_inspect_result( 133 | inspector: &dyn Inspect, 134 | result: Option, 135 | choose_candidate: fn(Vec) -> InspectCandidate, 136 | ) -> InspectBranch { 137 | match result { 138 | Some(InspectResult::NotMatch) => InspectBranch::Continue, 139 | Some(InspectResult::Candidates(candidates)) => { 140 | let candidate = choose_candidate(candidates); 141 | let result = inspector 142 | .inspect_candidate(candidate) 143 | .await 144 | .inspect_err(|e| log::error!("Failed to inspect candidate: {:?}", e)) 145 | .ok(); 146 | handle_inspect_result(inspector, result, choose_candidate).await 147 | } 148 | Some(InspectResult::Playlist(data)) => InspectBranch::Found(vec![data]), 149 | Some(InspectResult::Playlists(data)) => InspectBranch::Found(data), 150 | Some(InspectResult::Redirect(redirect_url)) => InspectBranch::Redirect(redirect_url), 151 | Some(InspectResult::None) | None => InspectBranch::NotFound, 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /bin/shiori/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | mod i18n; 3 | pub mod inspect; 4 | 5 | pub use shiori_plugin::async_trait; 6 | -------------------------------------------------------------------------------- /bin/shiori/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use clap_handler::Handler; 3 | use shiori::commands::ShioriArgs; 4 | use tracing_subscriber::filter::LevelFilter; 5 | 6 | #[tokio::main] 7 | async fn main() -> anyhow::Result<()> { 8 | tracing_subscriber::fmt() 9 | .with_env_filter( 10 | tracing_subscriber::EnvFilter::builder() 11 | .with_default_directive(LevelFilter::INFO.into()) 12 | .try_from_env() 13 | .unwrap_or_else(|_| "info,i18n_embed=off".into()), 14 | ) 15 | .with_writer(std::io::stderr) 16 | .init(); 17 | 18 | ShioriArgs::parse().run().await 19 | } 20 | -------------------------------------------------------------------------------- /bin/shiori/windows.manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | UTF-8 19 | 20 | 21 | 22 | 23 | 24 | true 25 | 26 | 27 | -------------------------------------------------------------------------------- /bin/srr/.gitignore: -------------------------------------------------------------------------------- 1 | config.toml 2 | -------------------------------------------------------------------------------- /bin/srr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "srr" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | iori = { workspace = true, features = ["opendal-s3"] } 11 | iori-showroom.workspace = true 12 | tokio = { workspace = true, features = ["full"] } 13 | anyhow.workspace = true 14 | 15 | tracing-subscriber.workspace = true 16 | log.workspace = true 17 | tokio-cron-scheduler = "0.14.0" 18 | uuid = "1.16.0" 19 | serde = { workspace = true, features = ["derive"] } 20 | toml = "0.8.22" 21 | chrono = "0.4.41" 22 | chrono-tz = "0.10.3" 23 | -------------------------------------------------------------------------------- /bin/srr/Readme.md: -------------------------------------------------------------------------------- 1 | # showroom-recorder 2 | 3 | Record showroom live and uploads the segments to s3. 4 | 5 | ## Script to extract all members from campaign page 6 | 7 | ```js 8 | Array.from(document.querySelectorAll(".room-card")) 9 | .map((r) => { 10 | const roomSlug = r.querySelector("a").href.split("/")[3].split("?")[0]; 11 | const roomName = r.querySelector(".room-card__text--main").innerText; 12 | return `"${roomSlug}", # ${roomName}`; 13 | }) 14 | .join("\n"); 15 | ``` 16 | -------------------------------------------------------------------------------- /bin/srr/src/config.rs: -------------------------------------------------------------------------------- 1 | use iori::cache::opendal::services::S3Config; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize)] 5 | pub struct Config { 6 | pub s3: S3Config, 7 | pub showroom: ShowroomConfig, 8 | } 9 | 10 | #[derive(Serialize, Deserialize)] 11 | pub struct ShowroomConfig { 12 | pub rooms: Vec, 13 | } 14 | 15 | impl Config { 16 | pub fn load() -> anyhow::Result { 17 | let file = "config.toml"; 18 | let data = std::fs::read_to_string(file)?; 19 | let config = toml::from_str(&data)?; 20 | Ok(config) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bin/ssadecrypt/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.2] - 2025-06-04 9 | 10 | - Upgrade `iori-ssa` to `0.2.1`. 11 | 12 | ## [0.2.1] - 2025-05-05 13 | 14 | - Improve preformance. 15 | 16 | ## [0.2.0] - 2025-02-21 17 | 18 | - Upgrade `iori-ssa` to `0.2.0`. 19 | 20 | [0.2.1]: https://github.com/Yesterday17/iori/releases/tag/ssadecrypt-v0.2.1 21 | [0.2.0]: https://github.com/Yesterday17/iori/releases/tag/ssadecrypt-v0.2.0 -------------------------------------------------------------------------------- /bin/ssadecrypt/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ssadecrypt" 3 | version = "0.2.2" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | iori-ssa.workspace = true 11 | clap.workspace = true 12 | hex = "0.4.3" 13 | -------------------------------------------------------------------------------- /bin/ssadecrypt/build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::process::Command; 3 | 4 | fn get_hash() -> Result> { 5 | let output = Command::new("git") 6 | .args(["rev-parse", "--short", "HEAD"]) 7 | .output()?; 8 | let hash = String::from_utf8(output.stdout)?; 9 | Ok(hash.trim().to_string()) 10 | } 11 | 12 | fn main() { 13 | let version = env!("CARGO_PKG_VERSION"); 14 | let hash = get_hash().unwrap_or_else(|_| "unknown".to_string()); 15 | println!("cargo:rustc-env=BUILD_VERSION={version} ({hash})"); 16 | } 17 | -------------------------------------------------------------------------------- /bin/ssadecrypt/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::{ 3 | fs::File, 4 | io::{BufReader, BufWriter, Read, Write}, 5 | path::PathBuf, 6 | }; 7 | 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(version = env!("BUILD_VERSION"), author)] 10 | /// Decrypts an Sample-AES encrypted MPEG-TS file. 11 | pub struct SsaDecryptArgs { 12 | /// The key to use for decryption. 13 | #[clap(short, long)] 14 | pub key: String, 15 | 16 | /// The initialization vector to use for decryption. Usually specified in M3U8 playlist. 17 | #[clap(short, long)] 18 | pub iv: String, 19 | 20 | /// The input file to decrypt. 21 | pub input: Option, 22 | 23 | /// The output file to write the decrypted data to. If not specified, the decrypted data will be written to stdout. 24 | pub output: Option, 25 | } 26 | 27 | fn main() -> Result<(), iori_ssa::Error> { 28 | let args = SsaDecryptArgs::parse(); 29 | let key = hex::decode(args.key).expect("Invalid key"); 30 | let iv = hex::decode(args.iv).expect("Invalid iv"); 31 | 32 | let input = args.input.map_or_else( 33 | || Box::new(BufReader::new(std::io::stdin())) as Box, 34 | |input| { 35 | Box::new(BufReader::new( 36 | File::open(input).expect("Failed to open input file"), 37 | )) 38 | }, 39 | ); 40 | let output = args.output.map_or_else( 41 | || Box::new(BufWriter::new(std::io::stdout())) as Box, 42 | |output| { 43 | Box::new(BufWriter::new( 44 | File::create(output).expect("Failed to create output file"), 45 | )) 46 | }, 47 | ); 48 | 49 | iori_ssa::decrypt( 50 | input, 51 | output, 52 | key.try_into().expect("Invalid key length"), 53 | iv.try_into().expect("Invalid iv length"), 54 | )?; 55 | 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crates/iori/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori" 3 | version = "0.0.1" 4 | description = "A brand new m3u8 stream downloader" 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | iori-ssa.workspace = true 13 | 14 | async-recursion.workspace = true 15 | log.workspace = true 16 | tracing.workspace = true 17 | m3u8-rs = { git = "https://github.com/Yesterday17/m3u8-rs.git" } 18 | reqwest.workspace = true 19 | tokio.workspace = true 20 | 21 | aes.workspace = true 22 | cbc.workspace = true 23 | block-buffer = "0.10.4" 24 | hex = "0.4.3" 25 | mp4decrypt = "0.4.2" 26 | tempfile = "3" 27 | rand = "0.8.5" 28 | thiserror = "1.0" 29 | url = { version = "2.5.0", features = ["serde"] } 30 | dash-mpd.workspace = true 31 | regex.workspace = true 32 | bytes = "1.6.0" 33 | serde = { workspace = true, features = ["derive"] } 34 | chrono = "0.4" 35 | shlex = "1.3.0" 36 | which = "7.0.2" 37 | reqwest_cookie_store = "0.8.0" 38 | serde_json.workspace = true 39 | tokio-util = { version = "0.7.15", features = ["io"] } 40 | futures = "0.3.31" 41 | opendal = { version = "0.53.1", optional = true } 42 | 43 | [target.'cfg(not(target_os = "windows"))'.dependencies] 44 | command-fds = { version = "0.3.0", features = ["tokio"] } 45 | 46 | [features] 47 | default = [] 48 | opendal = ["dep:opendal", "tokio-util/compat"] 49 | opendal-fs = ["opendal/services-fs"] 50 | opendal-s3 = ["opendal/services-s3"] 51 | 52 | [dev-dependencies] 53 | anyhow.workspace = true 54 | pretty_env_logger = "0.5.0" 55 | tokio = { workspace = true, features = ["full"] } 56 | tracing-subscriber.workspace = true 57 | wiremock = "0.6.3" 58 | 59 | [[example]] 60 | name = "pipe" 61 | required-features = ["tokio/full"] 62 | -------------------------------------------------------------------------------- /crates/iori/examples/dash_archive.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | use iori::{ 4 | cache::file::FileCacheSource, dash::archive::CommonDashArchiveSource, 5 | download::SequencialDownloader, merge::SkipMerger, 6 | }; 7 | 8 | #[tokio::main] 9 | async fn main() -> anyhow::Result<()> { 10 | pretty_env_logger::formatted_builder() 11 | .filter_level(log::LevelFilter::Info) 12 | .init(); 13 | 14 | let url = std::env::args().nth(1).unwrap_or_else(|| { 15 | eprintln!("Usage: {} ", std::env::args().next().unwrap()); 16 | std::process::exit(1); 17 | }); 18 | let key = std::env::args().nth(2); 19 | 20 | let started_at = SystemTime::now(); 21 | let started_at = started_at.duration_since(UNIX_EPOCH).unwrap().as_millis(); 22 | let output_dir = std::env::temp_dir().join(format!("iori_save_{}", started_at)); 23 | 24 | let source = CommonDashArchiveSource::new(Default::default(), url, key.as_deref(), None)?; 25 | let merger = SkipMerger; 26 | let cache = FileCacheSource::new(output_dir)?; 27 | 28 | let mut downloader = SequencialDownloader::new(source, merger, cache); 29 | downloader.download().await?; 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /crates/iori/examples/dash_live.rs: -------------------------------------------------------------------------------- 1 | use iori::{ 2 | cache::file::FileCacheSource, dash::live::CommonDashLiveSource, download::ParallelDownloader, 3 | merge::SkipMerger, HttpClient, 4 | }; 5 | use tracing::level_filters::LevelFilter; 6 | 7 | #[tokio::main] 8 | async fn main() -> anyhow::Result<()> { 9 | tracing_subscriber::fmt() 10 | .with_env_filter( 11 | tracing_subscriber::EnvFilter::builder() 12 | .with_default_directive(LevelFilter::INFO.into()) 13 | .try_from_env() 14 | .unwrap_or_else(|_| "info,i18n_embed::requester=off".into()), 15 | ) 16 | .with_writer(std::io::stderr) 17 | .init(); 18 | 19 | let mpd_url = "https://livesim.dashif.org/livesim2/segtimelinenr_1/WAVE/vectors/cfhd_sets/14.985_29.97_59.94/t1/2022-10-17/stream.mpd"; //"https://livesim.dashif.org/livesim2/testpic_2s/Manifest.mpd"; 20 | 21 | let key_str = None; 22 | 23 | let client = HttpClient::default(); 24 | 25 | let source = CommonDashLiveSource::new(client.clone(), mpd_url.parse()?, key_str)?; 26 | 27 | let cache_dir = std::env::temp_dir().join("iori_live_dash_example"); 28 | tracing::info!("Using cache directory: {}", cache_dir.display()); 29 | 30 | let cache = FileCacheSource::new(cache_dir)?; 31 | let merger = SkipMerger; 32 | 33 | let downloader = ParallelDownloader::builder().cache(cache).merger(merger); 34 | 35 | tracing::info!("Starting download for live stream: {}", mpd_url); 36 | match downloader.download(source).await { 37 | Ok(_) => { 38 | tracing::info!("Live stream download finished or stopped (e.g., MPD became static or updater task ended)."); 39 | } 40 | Err(e) => { 41 | tracing::error!("Download error: {:?}", e); 42 | anyhow::bail!("Download failed: {}", e); 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /crates/iori/examples/pipe.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | num::NonZeroU32, 3 | time::{SystemTime, UNIX_EPOCH}, 4 | }; 5 | 6 | use iori::{ 7 | cache::file::FileCacheSource, download::ParallelDownloader, hls::HlsLiveSource, 8 | merge::PipeMerger, 9 | }; 10 | 11 | #[tokio::main] 12 | async fn main() -> anyhow::Result<()> { 13 | pretty_env_logger::formatted_builder() 14 | .filter_level(log::LevelFilter::Info) 15 | .init(); 16 | 17 | let url = std::env::args().nth(1).unwrap_or_else(|| { 18 | eprintln!("Usage: {} ", std::env::args().next().unwrap()); 19 | std::process::exit(1); 20 | }); 21 | let key = std::env::args().nth(2); 22 | 23 | let started_at = SystemTime::now(); 24 | let started_at = started_at.duration_since(UNIX_EPOCH).unwrap().as_millis(); 25 | let output_dir = std::env::temp_dir().join(format!("iori_pipe_{}", started_at)); 26 | 27 | let source = HlsLiveSource::new(Default::default(), url, key.as_deref(), None); 28 | let merger = PipeMerger::stdout(true); 29 | let cache = FileCacheSource::new(output_dir)?; 30 | 31 | ParallelDownloader::builder() 32 | .cache(cache) 33 | .merger(merger) 34 | .concurrency(NonZeroU32::new(8).unwrap()) 35 | .retries(8) 36 | .download(source) 37 | .await?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /crates/iori/src/cache/file.rs: -------------------------------------------------------------------------------- 1 | use super::{CacheSource, CacheSourceReader, CacheSourceWriter}; 2 | use crate::{error::IoriResult, IoriError}; 3 | use std::path::PathBuf; 4 | use tokio::fs::File; 5 | 6 | pub struct FileCacheSource { 7 | cache_dir: PathBuf, 8 | } 9 | 10 | impl FileCacheSource { 11 | pub fn new(cache_dir: PathBuf) -> IoriResult { 12 | if cache_dir.exists() { 13 | return Err(IoriError::CacheDirExists(cache_dir)); 14 | } 15 | 16 | Ok(Self { cache_dir }) 17 | } 18 | 19 | async fn ensure_cache_dir(&self) -> IoriResult<()> { 20 | if !self.cache_dir.exists() { 21 | tokio::fs::create_dir_all(&self.cache_dir).await?; 22 | } 23 | 24 | Ok(()) 25 | } 26 | 27 | fn segment_path(&self, segment: &crate::SegmentInfo) -> PathBuf { 28 | let filename = segment.file_name.replace('/', "__"); 29 | let stream_id = segment.stream_id; 30 | let sequence = segment.sequence; 31 | let filename = format!("{stream_id:02}_{sequence:06}_{filename}"); 32 | self.cache_dir.join(filename) 33 | } 34 | } 35 | 36 | impl CacheSource for FileCacheSource { 37 | async fn open_writer( 38 | &self, 39 | segment: &crate::SegmentInfo, 40 | ) -> IoriResult> { 41 | self.ensure_cache_dir().await?; 42 | 43 | let path = self.segment_path(segment); 44 | if path 45 | .metadata() 46 | .map(|p| p.is_file() && p.len() > 0) 47 | .unwrap_or_default() 48 | { 49 | tracing::warn!("File {} already exists, ignoring.", path.display()); 50 | return Ok(None); 51 | } 52 | 53 | let tmp_file: File = File::create(path).await?; 54 | Ok(Some(Box::new(tmp_file))) 55 | } 56 | 57 | async fn open_reader(&self, segment: &crate::SegmentInfo) -> IoriResult { 58 | let path = self.segment_path(segment); 59 | let file = File::open(path).await?; 60 | Ok(Box::new(file)) 61 | } 62 | 63 | async fn segment_path(&self, segment: &crate::SegmentInfo) -> Option { 64 | Some(self.segment_path(segment)) 65 | } 66 | 67 | async fn invalidate(&self, segment: &crate::SegmentInfo) -> IoriResult<()> { 68 | let path = self.segment_path(segment); 69 | if path.exists() { 70 | tokio::fs::remove_file(path).await?; 71 | } 72 | Ok(()) 73 | } 74 | 75 | async fn clear(&self) -> IoriResult<()> { 76 | let mut entries = tokio::fs::read_dir(&self.cache_dir).await?; 77 | while let Some(entry) = entries.next_entry().await? { 78 | if entry.file_type().await?.is_dir() { 79 | tracing::warn!( 80 | "Subdirectory {} detected in cache directory. Skipping cleanup. You can remove it manually at {}", 81 | entry.path().display(), 82 | self.cache_dir.display() 83 | ); 84 | return Ok(()); 85 | } 86 | } 87 | 88 | tokio::fs::remove_dir_all(&self.cache_dir).await?; 89 | Ok(()) 90 | } 91 | 92 | fn location_hint(&self) -> Option { 93 | Some(self.cache_dir.display().to_string()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /crates/iori/src/cache/memory.rs: -------------------------------------------------------------------------------- 1 | use super::{CacheSource, CacheSourceReader, CacheSourceWriter}; 2 | use crate::{error::IoriResult, IoriError}; 3 | use std::{ 4 | collections::HashMap, 5 | io::{self, Cursor}, 6 | mem, 7 | pin::Pin, 8 | sync::{Arc, Mutex}, 9 | task::Poll, 10 | }; 11 | 12 | #[derive(Default)] 13 | pub struct MemoryCacheSource { 14 | cache: Arc>>, 15 | } 16 | 17 | impl MemoryCacheSource { 18 | pub fn new() -> Self { 19 | Self::default() 20 | } 21 | 22 | #[doc(hidden)] 23 | pub fn into_inner(self: Arc) -> Arc>> { 24 | self.cache.clone() 25 | } 26 | } 27 | 28 | impl CacheSource for MemoryCacheSource { 29 | async fn open_writer( 30 | &self, 31 | segment: &crate::SegmentInfo, 32 | ) -> IoriResult> { 33 | let key = (segment.sequence, segment.stream_id); 34 | let mut cache = self.cache.lock().unwrap(); 35 | if cache.contains_key(&key) { 36 | tracing::warn!("Cache for {:?} already exists, ignoring.", key); 37 | return Ok(None); 38 | } 39 | cache.insert(key, MemoryEntry::Pending); 40 | 41 | let writer = MemoryWriter { 42 | key, 43 | cache: self.cache.clone(), 44 | inner: Cursor::new(Vec::new()), 45 | }; 46 | Ok(Some(Box::new(writer))) 47 | } 48 | 49 | async fn open_reader(&self, segment: &crate::SegmentInfo) -> IoriResult { 50 | let data = self 51 | .cache 52 | .lock() 53 | .unwrap() 54 | .remove(&(segment.sequence, segment.stream_id)) 55 | .unwrap_or_default(); 56 | match data { 57 | MemoryEntry::Pending => Err(IoriError::IOError(std::io::Error::new( 58 | std::io::ErrorKind::NotFound, 59 | format!( 60 | "Cache for {:?} not found", 61 | (segment.sequence, segment.stream_id) 62 | ), 63 | ))), 64 | MemoryEntry::Data(data) => { 65 | let reader = Cursor::new(data); 66 | Ok(Box::new(reader)) 67 | } 68 | } 69 | } 70 | 71 | async fn invalidate(&self, segment: &crate::SegmentInfo) -> IoriResult<()> { 72 | self.cache 73 | .lock() 74 | .unwrap() 75 | .remove(&(segment.sequence, segment.stream_id)); 76 | Ok(()) 77 | } 78 | 79 | async fn clear(&self) -> IoriResult<()> { 80 | let mut cache = self.cache.lock().unwrap(); 81 | cache.clear(); 82 | Ok(()) 83 | } 84 | } 85 | 86 | #[derive(Debug, Default)] 87 | pub enum MemoryEntry { 88 | #[default] 89 | Pending, 90 | Data(Vec), 91 | } 92 | 93 | struct MemoryWriter { 94 | key: (u64, u64), 95 | cache: Arc>>, 96 | inner: Cursor>, // data: Vec, 97 | } 98 | 99 | impl tokio::io::AsyncWrite for MemoryWriter { 100 | fn poll_write( 101 | self: Pin<&mut Self>, 102 | _cx: &mut std::task::Context<'_>, 103 | buf: &[u8], 104 | ) -> Poll> { 105 | let this = self.get_mut(); 106 | Poll::Ready(io::Write::write(&mut this.inner, buf)) 107 | } 108 | 109 | fn poll_flush(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll> { 110 | let this = self.get_mut(); 111 | Poll::Ready(io::Write::flush(&mut this.inner)) 112 | } 113 | 114 | fn poll_shutdown( 115 | self: Pin<&mut Self>, 116 | cx: &mut std::task::Context<'_>, 117 | ) -> Poll> { 118 | self.poll_flush(cx) 119 | } 120 | } 121 | 122 | impl Drop for MemoryWriter { 123 | fn drop(&mut self) { 124 | let cursor = mem::take(&mut self.inner); 125 | self.cache.lock().unwrap().entry(self.key).and_modify(|e| { 126 | if matches!(e, MemoryEntry::Pending) { 127 | *e = MemoryEntry::Data(cursor.into_inner()); 128 | } 129 | }); 130 | } 131 | } 132 | 133 | #[cfg(test)] 134 | mod tests { 135 | use crate::{raw::RawSegment, SegmentInfo}; 136 | 137 | use super::*; 138 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 139 | 140 | #[tokio::test] 141 | async fn test_memory_cache() -> IoriResult<()> { 142 | let cache = MemoryCacheSource::new(); 143 | let segment: RawSegment = RawSegment::new("".to_string(), "ts".to_string()); 144 | let segment_info = SegmentInfo::from(&segment); 145 | 146 | let mut writer = cache.open_writer(&segment_info).await?.unwrap(); 147 | writer.write_all(b"hello").await?; 148 | writer.shutdown().await?; 149 | drop(writer); 150 | 151 | let mut reader = cache.open_reader(&segment_info).await?; 152 | let mut data = Vec::new(); 153 | reader.read_to_end(&mut data).await?; 154 | assert_eq!(data, b"hello"); 155 | 156 | cache.invalidate(&segment_info).await?; 157 | let result = cache.open_reader(&segment_info).await; 158 | assert!(result.is_err()); 159 | 160 | Ok(()) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /crates/iori/src/cache/opendal.rs: -------------------------------------------------------------------------------- 1 | use super::{CacheSource, CacheSourceReader, CacheSourceWriter}; 2 | use crate::error::IoriResult; 3 | use std::path::PathBuf; 4 | use tokio_util::compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt}; 5 | 6 | pub use opendal::*; 7 | 8 | pub struct OpendalCacheSource { 9 | operator: Operator, 10 | prefix: String, 11 | content_type: Option, 12 | 13 | with_internal_prefix: bool, 14 | } 15 | 16 | impl OpendalCacheSource { 17 | pub fn new( 18 | operator: Operator, 19 | prefix: impl Into, 20 | with_internal_prefix: bool, 21 | content_type: Option, 22 | ) -> Self { 23 | Self { 24 | operator, 25 | prefix: prefix.into(), 26 | content_type, 27 | with_internal_prefix, 28 | } 29 | } 30 | 31 | fn segment_key(&self, segment: &crate::SegmentInfo) -> String { 32 | let prefix = &self.prefix; 33 | let filename = segment.file_name.replace('/', "__"); 34 | if self.with_internal_prefix { 35 | let stream_id = segment.stream_id; 36 | let sequence = segment.sequence; 37 | format!("{prefix}/{stream_id:02}_{sequence:06}_{filename}") 38 | } else { 39 | format!("{prefix}/{filename}") 40 | } 41 | } 42 | } 43 | 44 | impl CacheSource for OpendalCacheSource { 45 | async fn open_writer( 46 | &self, 47 | segment: &crate::SegmentInfo, 48 | ) -> IoriResult> { 49 | let key = self.segment_key(segment); 50 | 51 | if self.operator.exists(&key).await? { 52 | tracing::warn!("File {} already exists, ignoring.", key); 53 | return Ok(None); 54 | } 55 | 56 | let mut writer = self.operator.writer_with(&key); 57 | if let Some(content_type) = &self.content_type { 58 | writer = writer.content_type(content_type); 59 | } 60 | let writer = writer 61 | .chunk(5 * 1024 * 1024) 62 | .await? 63 | .into_futures_async_write() 64 | .compat_write(); 65 | Ok(Some(Box::new(writer))) 66 | } 67 | 68 | async fn open_reader(&self, segment: &crate::SegmentInfo) -> IoriResult { 69 | let key = self.segment_key(segment); 70 | let stat = self.operator.stat(&key).await?; 71 | let length = stat.content_length(); 72 | let reader = self 73 | .operator 74 | .reader(&key) 75 | .await? 76 | .into_futures_async_read(0..length) 77 | .await? 78 | .compat(); 79 | 80 | Ok(Box::new(reader)) 81 | } 82 | 83 | async fn segment_path(&self, segment: &crate::SegmentInfo) -> Option { 84 | Some(PathBuf::from(self.segment_key(segment))) 85 | } 86 | 87 | async fn invalidate(&self, segment: &crate::SegmentInfo) -> IoriResult<()> { 88 | let key = self.segment_key(segment); 89 | self.operator.delete(&key).await?; 90 | Ok(()) 91 | } 92 | 93 | async fn clear(&self) -> IoriResult<()> { 94 | self.operator.remove_all(&self.prefix).await?; 95 | Ok(()) 96 | } 97 | 98 | fn location_hint(&self) -> Option { 99 | Some(self.prefix.clone()) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /crates/iori/src/dash/live.rs: -------------------------------------------------------------------------------- 1 | mod clock; 2 | mod selector; 3 | mod timeline; 4 | 5 | use super::segment::DashSegment; 6 | use crate::{decrypt::IoriKey, fetch::fetch_segment, HttpClient, IoriResult, StreamingSource}; 7 | use std::{ 8 | sync::{ 9 | atomic::{AtomicU64, Ordering}, 10 | Arc, 11 | }, 12 | time::Duration, 13 | }; 14 | use timeline::MPDTimeline; 15 | use tokio::sync::{mpsc, Mutex}; 16 | use url::Url; 17 | 18 | pub struct CommonDashLiveSource { 19 | client: HttpClient, 20 | mpd_url: Url, 21 | key: Option>, 22 | timeline: Arc>>, 23 | } 24 | 25 | impl CommonDashLiveSource { 26 | pub fn new(client: HttpClient, mpd_url: Url, key: Option<&str>) -> IoriResult { 27 | let key = key.map(IoriKey::clear_key).transpose()?.map(Arc::new); 28 | 29 | Ok(Self { 30 | client, 31 | mpd_url, 32 | key, 33 | timeline: Arc::new(Mutex::new(None)), 34 | }) 35 | } 36 | } 37 | 38 | impl StreamingSource for CommonDashLiveSource { 39 | type Segment = DashSegment; 40 | 41 | async fn fetch_info( 42 | &self, 43 | ) -> IoriResult>>> { 44 | let (sender, receiver) = mpsc::unbounded_channel(); 45 | 46 | let mpd = self 47 | .client 48 | .get(self.mpd_url.as_ref()) 49 | .send() 50 | .await? 51 | .text() 52 | .await?; 53 | let mpd = dash_mpd::parse(&mpd)?; 54 | 55 | let sequence_number = Arc::new(AtomicU64::new(0)); 56 | 57 | let minimum_update_period = mpd.minimumUpdatePeriod.unwrap_or(Duration::from_secs(2)); 58 | let timeline = MPDTimeline::from_mpd(mpd, Some(&self.mpd_url), self.client.clone()).await?; 59 | 60 | let (mut segments, mut last_update) = 61 | timeline.segments_since(None, self.key.clone()).await?; 62 | for segment in segments.iter_mut() { 63 | segment.sequence = sequence_number.fetch_add(1, Ordering::Relaxed); 64 | } 65 | sender.send(Ok(segments)).unwrap(); 66 | 67 | if timeline.is_dynamic() { 68 | self.timeline.lock().await.replace(timeline); 69 | 70 | let mpd_url = self.mpd_url.clone(); 71 | let client = self.client.clone(); 72 | let timeline = self.timeline.clone(); 73 | let key = self.key.clone(); 74 | tokio::spawn(async move { 75 | loop { 76 | tokio::time::sleep(minimum_update_period).await; 77 | 78 | let mpd = client 79 | .get(mpd_url.as_ref()) 80 | .send() 81 | .await 82 | .unwrap() 83 | .text() 84 | .await 85 | .unwrap(); 86 | let mpd = dash_mpd::parse(&mpd).unwrap(); 87 | 88 | let mut timeline = timeline.lock().await; 89 | let timeline = timeline.as_mut().unwrap(); 90 | timeline.update_mpd(mpd, &mpd_url).await.unwrap(); 91 | 92 | let (segments, _last_update) = timeline 93 | .segments_since(last_update, key.clone()) 94 | .await 95 | .unwrap(); 96 | sender.send(Ok(segments)).unwrap(); 97 | 98 | if let Some(_last_update) = _last_update { 99 | last_update = Some(_last_update); 100 | } 101 | 102 | if timeline.is_static() { 103 | break; 104 | } 105 | } 106 | }); 107 | } 108 | 109 | Ok(receiver) 110 | } 111 | 112 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 113 | where 114 | W: tokio::io::AsyncWrite + Unpin + Send + Sync + 'static, 115 | { 116 | fetch_segment(self.client.clone(), segment, writer, None).await 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /crates/iori/src/dash/live/selector.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use dash_mpd::Representation; 4 | 5 | pub fn best_representation(representation: &Representation) -> impl Ord { 6 | BestRepresentationSelector { 7 | width: representation.width, 8 | height: representation.height, 9 | bandwidth: representation.bandwidth, 10 | } 11 | } 12 | 13 | #[derive(PartialEq, Eq)] 14 | struct BestRepresentationSelector { 15 | width: Option, 16 | height: Option, 17 | bandwidth: Option, 18 | } 19 | 20 | impl PartialOrd for BestRepresentationSelector { 21 | fn partial_cmp(&self, other: &Self) -> Option { 22 | Some(self.cmp(other)) 23 | } 24 | } 25 | 26 | impl Ord for BestRepresentationSelector { 27 | fn cmp(&self, other: &Self) -> Ordering { 28 | self.width 29 | .cmp(&other.width) 30 | .then(self.height.cmp(&other.height)) 31 | .then(self.bandwidth.cmp(&other.bandwidth)) 32 | } 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | 39 | #[test] 40 | fn test_best_representation() { 41 | let representations = [ 42 | BestRepresentationSelector { 43 | width: Some(1920), 44 | height: Some(1080), 45 | bandwidth: Some(1000000), 46 | }, 47 | BestRepresentationSelector { 48 | width: Some(1280), 49 | height: Some(720), 50 | bandwidth: Some(500000), 51 | }, 52 | BestRepresentationSelector { 53 | width: Some(640), 54 | height: Some(360), 55 | bandwidth: Some(250000), 56 | }, 57 | ]; 58 | 59 | let best = representations.iter().max().unwrap(); 60 | assert_eq!(best.width, Some(1920)); 61 | assert_eq!(best.height, Some(1080)); 62 | assert_eq!(best.bandwidth, Some(1000000)); 63 | } 64 | 65 | #[test] 66 | fn test_resolution_first() { 67 | let representations = [ 68 | BestRepresentationSelector { 69 | width: Some(1920), 70 | height: Some(1080), 71 | bandwidth: Some(500000), 72 | }, 73 | BestRepresentationSelector { 74 | width: Some(1280), 75 | height: Some(720), 76 | bandwidth: Some(1000000), 77 | }, 78 | ]; 79 | 80 | let best = representations.iter().max().unwrap(); 81 | assert_eq!(best.width, Some(1920)); 82 | assert_eq!(best.height, Some(1080)); 83 | assert_eq!(best.bandwidth, Some(500000)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /crates/iori/src/dash/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod archive; 2 | pub mod live; 3 | pub mod segment; 4 | pub mod template; 5 | pub(crate) mod url; 6 | -------------------------------------------------------------------------------- /crates/iori/src/dash/segment.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | decrypt::IoriKey, ByteRange, InitialSegment, RemoteStreamingSegment, SegmentFormat, 3 | SegmentType, StreamingSegment, 4 | }; 5 | use std::sync::Arc; 6 | 7 | #[derive(Clone)] 8 | pub struct DashSegment { 9 | pub url: reqwest::Url, 10 | pub filename: String, 11 | 12 | pub key: Option>, 13 | pub initial_segment: InitialSegment, 14 | 15 | pub byte_range: Option, 16 | 17 | pub sequence: u64, 18 | pub stream_id: u64, 19 | 20 | pub r#type: SegmentType, 21 | 22 | /// $Time$ 23 | pub time: Option, 24 | } 25 | 26 | impl StreamingSegment for DashSegment { 27 | fn stream_id(&self) -> u64 { 28 | self.stream_id 29 | } 30 | 31 | fn sequence(&self) -> u64 { 32 | self.time.unwrap_or(self.sequence) 33 | } 34 | 35 | fn file_name(&self) -> &str { 36 | self.filename.as_str() 37 | } 38 | 39 | fn initial_segment(&self) -> InitialSegment { 40 | self.initial_segment.clone() 41 | } 42 | 43 | fn key(&self) -> Option> { 44 | self.key.clone() 45 | } 46 | 47 | fn r#type(&self) -> SegmentType { 48 | self.r#type 49 | } 50 | 51 | fn format(&self) -> SegmentFormat { 52 | SegmentFormat::Mp4 53 | } 54 | } 55 | 56 | impl RemoteStreamingSegment for DashSegment { 57 | fn url(&self) -> reqwest::Url { 58 | self.url.clone() 59 | } 60 | 61 | fn byte_range(&self) -> Option { 62 | self.byte_range.clone() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/iori/src/dash/url.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::{ByteRange, IoriError, IoriResult}; 4 | 5 | pub(crate) fn is_absolute_url(s: &str) -> bool { 6 | s.starts_with("http://") 7 | || s.starts_with("https://") 8 | || s.starts_with("file://") 9 | || s.starts_with("ftp://") 10 | } 11 | 12 | pub(crate) fn merge_baseurls(current: &Url, new: &str) -> IoriResult { 13 | if is_absolute_url(new) { 14 | Ok(Url::parse(new)?) 15 | } else { 16 | // We are careful to merge the query portion of the current URL (which is either the 17 | // original manifest URL, or the URL that it redirected to, or the value of a BaseURL 18 | // element in the manifest) with the new URL. But if the new URL already has a query string, 19 | // it takes precedence. 20 | // 21 | // Examples 22 | // 23 | // merge_baseurls(https://example.com/manifest.mpd?auth=secret, /video42.mp4) => 24 | // https://example.com/video42.mp4?auth=secret 25 | // 26 | // merge_baseurls(https://example.com/manifest.mpd?auth=old, /video42.mp4?auth=new) => 27 | // https://example.com/video42.mp4?auth=new 28 | let mut merged = current.join(new)?; 29 | if merged.query().is_none() { 30 | merged.set_query(current.query()); 31 | } 32 | Ok(merged) 33 | } 34 | } 35 | 36 | /// The byte range shall be expressed and formatted as a byte-range-spec as defined in 37 | /// IETF RFC 7233:2014, subclause 2.1. It is restricted to a single expression identifying 38 | /// a contiguous range of bytes. 39 | pub(crate) fn parse_media_range(s: S) -> IoriResult 40 | where 41 | S: AsRef, 42 | { 43 | let (start, end) = s 44 | .as_ref() 45 | .split_once('-') 46 | .ok_or_else(|| IoriError::MpdParsing("Invalid media range".to_string()))?; 47 | 48 | let first_byte_pos = start 49 | .parse::() 50 | .map_err(|_| IoriError::MpdParsing("Invalid media range".to_string()))?; 51 | let last_byte_pos = end.parse::().ok(); 52 | 53 | Ok(ByteRange { 54 | offset: first_byte_pos, 55 | // 0 - 500 means 501 bytes 56 | // So length = end - start + 1 57 | length: last_byte_pos.map(|last_byte_pos| last_byte_pos - first_byte_pos + 1), 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /crates/iori/src/download/mod.rs: -------------------------------------------------------------------------------- 1 | mod sequencial; 2 | pub use sequencial::SequencialDownloader; 3 | 4 | mod parallel; 5 | pub use parallel::ParallelDownloader; 6 | -------------------------------------------------------------------------------- /crates/iori/src/download/sequencial.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::io::AsyncWriteExt; 4 | 5 | use crate::{ 6 | cache::CacheSource, error::IoriResult, merge::Merger, IoriError, SegmentInfo, StreamingSource, 7 | }; 8 | 9 | pub struct SequencialDownloader 10 | where 11 | S: StreamingSource, 12 | M: Merger, 13 | C: CacheSource, 14 | { 15 | source: S, 16 | merger: M, 17 | cache: Arc, 18 | } 19 | 20 | impl SequencialDownloader 21 | where 22 | S: StreamingSource, 23 | M: Merger, 24 | C: CacheSource, 25 | { 26 | pub fn new(source: S, merger: M, cache: C) -> Self { 27 | Self { 28 | source, 29 | merger, 30 | cache: Arc::new(cache), 31 | } 32 | } 33 | 34 | pub async fn download(&mut self) -> IoriResult<()> { 35 | let mut receiver = self.source.fetch_info().await?; 36 | 37 | while let Some(segment) = receiver.recv().await { 38 | for segment in segment? { 39 | let segment_info = SegmentInfo::from(&segment); 40 | let writer = self.cache.open_writer(&segment_info).await?; 41 | let Some(mut writer) = writer else { 42 | continue; 43 | }; 44 | 45 | let fetch_result = self.source.fetch_segment(&segment, &mut writer).await; 46 | let fetch_result = match fetch_result { 47 | // graceful shutdown 48 | Ok(_) => writer.shutdown().await.map_err(IoriError::IOError), 49 | Err(e) => Err(e), 50 | }; 51 | drop(writer); 52 | 53 | match fetch_result { 54 | Ok(_) => self.merger.update(segment_info, self.cache.clone()).await?, 55 | Err(_) => self.merger.fail(segment_info, self.cache.clone()).await?, 56 | } 57 | } 58 | } 59 | 60 | self.merger.finish(self.cache.clone()).await?; 61 | Ok(()) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/iori/src/error.rs: -------------------------------------------------------------------------------- 1 | use aes::cipher::block_padding::UnpadError; 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum IoriError { 6 | #[error("HTTP error: {0}")] 7 | HttpError(reqwest::StatusCode), 8 | 9 | #[error("Manifest fetch error")] 10 | ManifestFetchError, 11 | 12 | #[error("Decryption key required")] 13 | DecryptionKeyRequired, 14 | 15 | #[error("Invalid hex key: {0}")] 16 | InvalidHexKey(String), 17 | 18 | #[error("Invalid binary key: {0:?}")] 19 | InvalidBinaryKey(Vec), 20 | 21 | #[error("mp4decrypt error: {0}")] 22 | Mp4DecryptError(String), 23 | 24 | #[error("iori-ssa error: {0:?}")] 25 | IoriSsaError(#[from] iori_ssa::Error), 26 | 27 | #[error("Pkcs7 unpad error")] 28 | UnpadError(#[from] UnpadError), 29 | 30 | #[error("Invalid m3u8 file: {0}")] 31 | M3u8ParseError(String), 32 | 33 | #[error(transparent)] 34 | IOError(#[from] std::io::Error), 35 | 36 | #[error(transparent)] 37 | UrlParseError(#[from] url::ParseError), 38 | 39 | #[error(transparent)] 40 | HexDecodeError(#[from] hex::FromHexError), 41 | 42 | #[error(transparent)] 43 | RequestError(#[from] reqwest::Error), 44 | 45 | // MPEG-DASH errors 46 | #[error(transparent)] 47 | MpdParseError(#[from] dash_mpd::DashMpdError), 48 | 49 | #[error("invalid mpd: {0}")] 50 | MpdParsing(String), 51 | 52 | #[error(transparent)] 53 | TimeDeltaOutOfRange(#[from] chrono::OutOfRangeError), 54 | 55 | #[error("Invalid timing schema: {0:?}")] 56 | InvalidTimingSchema(String), 57 | 58 | #[error(transparent)] 59 | MissingExecutable(#[from] which::Error), 60 | 61 | #[error("Can not set cache directory to an existing path: {0}")] 62 | CacheDirExists(std::path::PathBuf), 63 | 64 | #[error(transparent)] 65 | JsonError(#[from] serde_json::Error), 66 | 67 | #[cfg(feature = "opendal")] 68 | #[error(transparent)] 69 | OpendalError(#[from] opendal::Error), 70 | 71 | #[error("No period found")] 72 | NoPeriodFound, 73 | 74 | #[error("No adaption set found")] 75 | NoAdaptationSetFound, 76 | 77 | #[error("No representation found")] 78 | NoRepresentationFound, 79 | 80 | #[error(transparent)] 81 | ChronoParseError(#[from] chrono::ParseError), 82 | 83 | #[error("Invalid date time: {0}")] 84 | DateTimeParsing(String), 85 | } 86 | 87 | pub type IoriResult = Result; 88 | -------------------------------------------------------------------------------- /crates/iori/src/fetch.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use reqwest::header::RANGE; 4 | use tokio::io::{AsyncWrite, AsyncWriteExt}; 5 | 6 | use crate::{ 7 | error::{IoriError, IoriResult}, 8 | util::http::HttpClient, 9 | InitialSegment, RemoteStreamingSegment, StreamingSegment, ToSegmentData, 10 | }; 11 | 12 | pub async fn fetch_segment( 13 | client: HttpClient, 14 | segment: &S, 15 | tmp_file: &mut W, 16 | shaka_packager_command: Option, 17 | ) -> IoriResult<()> 18 | where 19 | S: StreamingSegment + ToSegmentData, 20 | W: AsyncWrite + Unpin + Send + Sync + 'static, 21 | { 22 | let bytes = segment.to_segment_data(client).await?; 23 | 24 | // TODO: use bytes_stream to improve performance 25 | // .bytes_stream(); 26 | let decryptor = segment 27 | .key() 28 | .map(|key| key.to_decryptor(shaka_packager_command)); 29 | if let Some(decryptor) = decryptor { 30 | let bytes = match segment.initial_segment() { 31 | crate::InitialSegment::Encrypted(data) => { 32 | let mut result = data.to_vec(); 33 | result.extend_from_slice(&bytes); 34 | decryptor.decrypt(&result).await? 35 | } 36 | crate::InitialSegment::Clear(data) => { 37 | tmp_file.write_all(&data).await?; 38 | decryptor.decrypt(&bytes).await? 39 | } 40 | crate::InitialSegment::None => decryptor.decrypt(&bytes).await?, 41 | }; 42 | tmp_file.write_all(&bytes).await?; 43 | } else { 44 | if let InitialSegment::Clear(initial_segment) = segment.initial_segment() { 45 | tmp_file.write_all(&initial_segment).await?; 46 | } 47 | tmp_file.write_all(&bytes).await?; 48 | } 49 | tmp_file.flush().await?; 50 | 51 | Ok(()) 52 | } 53 | 54 | impl ToSegmentData for T 55 | where 56 | T: RemoteStreamingSegment, 57 | { 58 | fn to_segment_data( 59 | &self, 60 | client: HttpClient, 61 | ) -> impl std::future::Future> + Send { 62 | let url = self.url(); 63 | let byte_range = self.byte_range(); 64 | let headers = self.headers(); 65 | async move { 66 | let mut request = client.get(url); 67 | if let Some(headers) = headers { 68 | request = request.headers(headers); 69 | } 70 | if let Some(byte_range) = byte_range { 71 | request = request.header(RANGE, byte_range.to_http_range()); 72 | } 73 | let response = request.send().await?; 74 | if !response.status().is_success() { 75 | let status = response.status(); 76 | if let Ok(body) = response.text().await { 77 | tracing::warn!("Error body: {body}"); 78 | } 79 | return Err(IoriError::HttpError(status)); 80 | } 81 | 82 | let bytes = response.bytes().await?; 83 | Ok(bytes) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /crates/iori/src/hls/archive.rs: -------------------------------------------------------------------------------- 1 | use std::{num::ParseIntError, path::PathBuf, str::FromStr, sync::Arc}; 2 | 3 | use tokio::{ 4 | io::AsyncWrite, 5 | sync::{mpsc, Mutex}, 6 | }; 7 | use url::Url; 8 | 9 | use crate::{ 10 | error::IoriResult, 11 | fetch::fetch_segment, 12 | hls::{segment::M3u8Segment, source::HlsPlaylistSource}, 13 | util::http::HttpClient, 14 | StreamingSource, 15 | }; 16 | 17 | pub struct CommonM3u8ArchiveSource { 18 | client: HttpClient, 19 | playlist: Arc>, 20 | range: SegmentRange, 21 | retry: u32, 22 | shaka_packager_command: Option, 23 | } 24 | 25 | /// A subrange for m3u8 archive sources to choose which segment to use 26 | #[derive(Debug, Clone, Copy)] 27 | pub struct SegmentRange { 28 | /// Start offset to use. Default to 1 29 | pub start: u64, 30 | /// End offset to use. Default to None 31 | pub end: Option, 32 | } 33 | 34 | impl Default for SegmentRange { 35 | fn default() -> Self { 36 | Self { 37 | start: 1, 38 | end: None, 39 | } 40 | } 41 | } 42 | 43 | impl SegmentRange { 44 | pub fn new(start: u64, end: Option) -> Self { 45 | Self { start, end } 46 | } 47 | 48 | pub fn end(&self) -> u64 { 49 | self.end.unwrap_or(u64::MAX) 50 | } 51 | } 52 | 53 | impl FromStr for SegmentRange { 54 | type Err = ParseIntError; 55 | 56 | fn from_str(s: &str) -> Result { 57 | let (start, end) = s.split_once('-').unwrap_or((s, "")); 58 | let start = if start.is_empty() { 1 } else { start.parse()? }; 59 | let end = if end.is_empty() { 60 | None 61 | } else { 62 | Some(end.parse()?) 63 | }; 64 | Ok(Self { start, end }) 65 | } 66 | } 67 | 68 | impl CommonM3u8ArchiveSource { 69 | pub fn new( 70 | client: HttpClient, 71 | playlist_url: String, 72 | key: Option<&str>, 73 | range: SegmentRange, 74 | shaka_packager_command: Option, 75 | ) -> Self { 76 | Self { 77 | client: client.clone(), 78 | playlist: Arc::new(Mutex::new(HlsPlaylistSource::new( 79 | client, 80 | Url::parse(&playlist_url).unwrap(), 81 | key, 82 | ))), 83 | shaka_packager_command, 84 | range, 85 | retry: 3, 86 | } 87 | } 88 | 89 | pub fn with_retry(mut self, retry: u32) -> Self { 90 | self.retry = retry; 91 | self 92 | } 93 | } 94 | 95 | impl StreamingSource for CommonM3u8ArchiveSource { 96 | type Segment = M3u8Segment; 97 | 98 | async fn fetch_info( 99 | &self, 100 | ) -> IoriResult>>> { 101 | let latest_media_sequences = self.playlist.lock().await.load_streams(self.retry).await?; 102 | 103 | let (sender, receiver) = mpsc::unbounded_channel(); 104 | 105 | let (segments, _) = self 106 | .playlist 107 | .lock() 108 | .await 109 | .load_segments(&latest_media_sequences, self.retry) 110 | .await?; 111 | let mut segments: Vec<_> = segments 112 | .into_iter() 113 | .flatten() 114 | .filter_map(|segment| { 115 | let seq = segment.sequence + 1; 116 | if seq >= self.range.start && seq <= self.range.end() { 117 | return Some(segment); 118 | } 119 | None 120 | }) 121 | .collect(); 122 | 123 | // make sequence start form 1 again 124 | for (seq, segment) in segments.iter_mut().enumerate() { 125 | segment.sequence = seq as u64; 126 | } 127 | 128 | let _ = sender.send(Ok(segments)); 129 | 130 | Ok(receiver) 131 | } 132 | 133 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 134 | where 135 | W: AsyncWrite + Unpin + Send + Sync + 'static, 136 | { 137 | fetch_segment( 138 | self.client.clone(), 139 | segment, 140 | writer, 141 | self.shaka_packager_command.clone(), 142 | ) 143 | .await?; 144 | Ok(()) 145 | } 146 | } 147 | 148 | #[cfg(test)] 149 | mod tests { 150 | use super::*; 151 | 152 | #[test] 153 | fn test_parse_range() { 154 | let range = "1-10".parse::().unwrap(); 155 | assert_eq!(range.start, 1); 156 | assert_eq!(range.end, Some(10)); 157 | 158 | let range = "1-".parse::().unwrap(); 159 | assert_eq!(range.start, 1); 160 | assert_eq!(range.end, None); 161 | 162 | let range = "-10".parse::().unwrap(); 163 | assert_eq!(range.start, 1); 164 | assert_eq!(range.end, Some(10)); 165 | 166 | let range = "1".parse::().unwrap(); 167 | assert_eq!(range.start, 1); 168 | assert_eq!(range.end, None); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /crates/iori/src/hls/live.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::Arc, time::Duration}; 2 | 3 | use tokio::{ 4 | io::AsyncWrite, 5 | sync::{mpsc, Mutex}, 6 | }; 7 | use url::Url; 8 | 9 | use crate::{ 10 | error::{IoriError, IoriResult}, 11 | fetch::fetch_segment, 12 | hls::{segment::M3u8Segment, source::HlsPlaylistSource}, 13 | util::{http::HttpClient, mix::VecMix}, 14 | StreamingSource, 15 | }; 16 | 17 | pub struct HlsLiveSource { 18 | client: HttpClient, 19 | playlist: Arc>, 20 | retry: u32, 21 | shaka_packager_command: Option, 22 | } 23 | 24 | impl HlsLiveSource { 25 | pub fn new( 26 | client: HttpClient, 27 | m3u8_url: String, 28 | key: Option<&str>, 29 | shaka_packager_command: Option, 30 | ) -> Self { 31 | Self { 32 | client: client.clone(), 33 | playlist: Arc::new(Mutex::new(HlsPlaylistSource::new( 34 | client, 35 | Url::parse(&m3u8_url).unwrap(), 36 | key, 37 | ))), 38 | shaka_packager_command, 39 | retry: 3, 40 | } 41 | } 42 | 43 | pub fn with_retry(mut self, retry: u32) -> Self { 44 | self.retry = retry; 45 | self 46 | } 47 | } 48 | 49 | impl StreamingSource for HlsLiveSource { 50 | type Segment = M3u8Segment; 51 | 52 | async fn fetch_info( 53 | &self, 54 | ) -> IoriResult>>> { 55 | let mut latest_media_sequences = 56 | self.playlist.lock().await.load_streams(self.retry).await?; 57 | 58 | let (sender, receiver) = mpsc::unbounded_channel(); 59 | 60 | let retry = self.retry; 61 | let playlist = self.playlist.clone(); 62 | tokio::spawn(async move { 63 | loop { 64 | if sender.is_closed() { 65 | break; 66 | } 67 | 68 | let before_load = tokio::time::Instant::now(); 69 | let (segments, is_end) = match playlist 70 | .lock() 71 | .await 72 | .load_segments(&latest_media_sequences, retry) 73 | .await 74 | { 75 | Ok(v) => v, 76 | Err(IoriError::ManifestFetchError) => { 77 | tracing::error!("Exceeded retry limit for fetching segments, exiting..."); 78 | break; 79 | } 80 | Err(e) => { 81 | tracing::error!("Failed to fetch segments: {e}"); 82 | break; 83 | } 84 | }; 85 | 86 | let segments_average_duration = segments 87 | .iter() 88 | .map(|ss| { 89 | let total_seconds = ss.iter().map(|s| s.duration).sum::(); 90 | let segments_count = ss.len() as f32; 91 | 92 | if segments_count == 0. { 93 | 0 94 | } else { 95 | (total_seconds * 1000. / segments_count) as u64 96 | } 97 | }) 98 | .min() 99 | .unwrap_or(5); 100 | 101 | for (segments, latest_media_sequence) in 102 | segments.iter().zip(latest_media_sequences.iter_mut()) 103 | { 104 | *latest_media_sequence = segments 105 | .last() 106 | .map(|r| r.media_sequence) 107 | .or(*latest_media_sequence); 108 | } 109 | 110 | let mixed_segments = segments.mix(); 111 | if !mixed_segments.is_empty() { 112 | if let Err(e) = sender.send(Ok(mixed_segments)) { 113 | tracing::error!("Failed to send mixed segments: {e}"); 114 | break; 115 | } 116 | } 117 | 118 | if is_end { 119 | break; 120 | } 121 | 122 | // playlist does not end, wait for a while and fetch again 123 | let seconds_to_wait = segments_average_duration.clamp(1000, 5000); 124 | tokio::time::sleep_until(before_load + Duration::from_millis(seconds_to_wait)) 125 | .await; 126 | } 127 | }); 128 | 129 | Ok(receiver) 130 | } 131 | 132 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 133 | where 134 | W: AsyncWrite + Unpin + Send + Sync + 'static, 135 | { 136 | fetch_segment( 137 | self.client.clone(), 138 | segment, 139 | writer, 140 | self.shaka_packager_command.clone(), 141 | ) 142 | .await?; 143 | Ok(()) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /crates/iori/src/hls/mod.rs: -------------------------------------------------------------------------------- 1 | mod archive; 2 | mod live; 3 | pub mod segment; 4 | mod source; 5 | pub mod utils; 6 | 7 | pub use archive::*; 8 | pub use live::HlsLiveSource; 9 | pub use m3u8_rs; 10 | pub use source::*; 11 | -------------------------------------------------------------------------------- /crates/iori/src/hls/segment.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | decrypt::IoriKey, ByteRange, InitialSegment, RemoteStreamingSegment, SegmentFormat, 3 | SegmentType, StreamingSegment, 4 | }; 5 | use std::sync::Arc; 6 | 7 | #[derive(Debug)] 8 | pub struct M3u8Segment { 9 | pub url: reqwest::Url, 10 | pub filename: String, 11 | 12 | pub key: Option>, 13 | pub initial_segment: InitialSegment, 14 | 15 | pub byte_range: Option, 16 | 17 | /// Stream id 18 | pub stream_id: u64, 19 | /// Sequence id allocated by the downloader, starts from 0 20 | pub sequence: u64, 21 | /// Media sequence id from the m3u8 file 22 | pub media_sequence: u64, 23 | 24 | pub segment_type: Option, 25 | pub duration: f32, 26 | pub format: SegmentFormat, 27 | } 28 | 29 | impl StreamingSegment for M3u8Segment { 30 | fn stream_id(&self) -> u64 { 31 | self.stream_id 32 | } 33 | 34 | fn sequence(&self) -> u64 { 35 | self.sequence 36 | } 37 | 38 | fn file_name(&self) -> &str { 39 | self.filename.as_str() 40 | } 41 | 42 | fn initial_segment(&self) -> InitialSegment { 43 | self.initial_segment.clone() 44 | } 45 | 46 | fn key(&self) -> Option> { 47 | self.key.clone() 48 | } 49 | 50 | fn r#type(&self) -> SegmentType { 51 | self.segment_type.unwrap_or(SegmentType::Video) 52 | } 53 | 54 | fn format(&self) -> SegmentFormat { 55 | self.format.clone() 56 | } 57 | } 58 | 59 | impl RemoteStreamingSegment for M3u8Segment { 60 | fn url(&self) -> reqwest::Url { 61 | self.url.clone() 62 | } 63 | 64 | fn byte_range(&self) -> Option { 65 | self.byte_range.clone() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/iori/src/hls/utils.rs: -------------------------------------------------------------------------------- 1 | use m3u8_rs::{MediaPlaylist, Playlist}; 2 | use reqwest::{Client, Url}; 3 | 4 | use crate::{ 5 | error::{IoriError, IoriResult}, 6 | util::http::HttpClient, 7 | }; 8 | 9 | pub async fn load_playlist_with_retry( 10 | client: &Client, 11 | url: &Url, 12 | total_retry: u32, 13 | ) -> IoriResult { 14 | let mut retry = total_retry; 15 | let m3u8_parsed = loop { 16 | if retry == 0 { 17 | return Err(IoriError::ManifestFetchError); 18 | } 19 | 20 | match client.get(url.clone()).send().await { 21 | Ok(resp) => match resp.bytes().await { 22 | Ok(m3u8_bytes) => match m3u8_rs::parse_playlist_res(&m3u8_bytes) { 23 | Ok(parsed) => break parsed, 24 | Err(error) => { 25 | tracing::warn!("Failed to parse M3U8 file: {error}"); 26 | retry -= 1; 27 | } 28 | }, 29 | Err(error) => { 30 | tracing::warn!("Failed to fetch M3U8 file: {error}"); 31 | retry -= 1; 32 | } 33 | }, 34 | Err(error) => { 35 | tracing::warn!("Failed to fetch M3U8 file: {error}"); 36 | retry -= 1; 37 | } 38 | } 39 | }; 40 | 41 | Ok(m3u8_parsed) 42 | } 43 | 44 | #[async_recursion::async_recursion] 45 | pub async fn load_m3u8( 46 | client: &HttpClient, 47 | url: Url, 48 | total_retry: u32, 49 | ) -> IoriResult<(Url, MediaPlaylist)> { 50 | tracing::info!("Start fetching M3U8 file."); 51 | 52 | let mut retry = total_retry; 53 | let m3u8_parsed = loop { 54 | if retry == 0 { 55 | return Err(IoriError::ManifestFetchError); 56 | } 57 | 58 | match client.get(url.clone()).send().await { 59 | Ok(resp) => match resp.bytes().await { 60 | Ok(m3u8_bytes) => match m3u8_rs::parse_playlist_res(&m3u8_bytes) { 61 | Ok(parsed) => break parsed, 62 | Err(error) => { 63 | tracing::warn!("Failed to parse M3U8 file: {error}"); 64 | retry -= 1; 65 | } 66 | }, 67 | Err(error) => { 68 | tracing::warn!("Failed to fetch M3U8 file: {error}"); 69 | retry -= 1; 70 | } 71 | }, 72 | Err(error) => { 73 | tracing::warn!("Failed to fetch M3U8 file: {error}"); 74 | retry -= 1; 75 | } 76 | } 77 | }; 78 | tracing::info!("M3U8 file fetched."); 79 | 80 | match m3u8_parsed { 81 | Playlist::MasterPlaylist(pl) => { 82 | tracing::info!("Master playlist input detected. Auto selecting best quality streams."); 83 | let mut variants = pl.variants; 84 | variants.sort_by(|a, b| { 85 | // compare resolution first 86 | if let (Some(a), Some(b)) = (a.resolution, b.resolution) { 87 | if a.width != b.width { 88 | return b.width.cmp(&a.width); 89 | } 90 | } 91 | 92 | // compare framerate then 93 | if let (Some(a), Some(b)) = (a.frame_rate, b.frame_rate) { 94 | let a = a as u64; 95 | let b = b as u64; 96 | if a != b { 97 | return b.cmp(&a); 98 | } 99 | } 100 | 101 | // compare bandwidth finally 102 | b.bandwidth.cmp(&a.bandwidth) 103 | }); 104 | let variant = variants.first().expect("No variant found"); 105 | let url = url.join(&variant.uri).expect("Invalid variant uri"); 106 | 107 | tracing::info!( 108 | "Best stream: {url}; Bandwidth: {bandwidth}", 109 | bandwidth = variant.bandwidth 110 | ); 111 | load_m3u8(client, url, total_retry).await 112 | } 113 | Playlist::MediaPlaylist(pl) => Ok((url, pl)), 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /crates/iori/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod decrypt; 3 | pub mod download; 4 | pub mod fetch; 5 | pub mod merge; 6 | pub mod raw; 7 | 8 | pub mod dash; 9 | pub mod hls; 10 | 11 | pub(crate) mod util; 12 | pub use crate::util::http::HttpClient; 13 | pub mod utils { 14 | pub use crate::util::detect_manifest_type; 15 | pub use crate::util::path::DuplicateOutputFileNamer; 16 | } 17 | 18 | mod segment; 19 | pub use segment::*; 20 | mod error; 21 | pub use error::*; 22 | pub use util::range::ByteRange; 23 | 24 | /// ┌───────────────────────┐ ┌────────────────────┐ 25 | /// │ │ Segment 1 │ │ 26 | /// │ ├────────────────► ├───┐ 27 | /// │ │ │ │ │fetch_segment 28 | /// │ │ Segment 2 │ ◄───┘ 29 | /// │ M3U8 Time#1 ├────────────────► Downloader │ 30 | /// │ │ │ ├───┐ 31 | /// │ │ Segment 3 │ [MPSC] │ │fetch_segment 32 | /// │ ├────────────────► ◄───┘ 33 | /// │ │ │ │ 34 | /// └───────────────────────┘ │ ├───┐ 35 | /// │ │ │fetch_segment 36 | /// ┌───────────────────────┐ │ ◄───┘ 37 | /// │ │ ... │ │ 38 | /// │ ├────────────────► │ 39 | /// │ │ │ │ 40 | /// │ M3U8 Time#N │ │ │ 41 | /// │ │ │ │ 42 | /// │ │ │ │ 43 | /// │ │ Segment Last │ │ 44 | /// │ ├────────────────► │ 45 | /// └───────────────────────┘ └────────────────────┘ 46 | pub trait StreamingSource { 47 | type Segment: StreamingSegment + Send + 'static; 48 | 49 | fn fetch_info( 50 | &self, 51 | ) -> impl std::future::Future< 52 | Output = error::IoriResult< 53 | tokio::sync::mpsc::UnboundedReceiver>>, 54 | >, 55 | > + Send; 56 | 57 | fn fetch_segment( 58 | &self, 59 | segment: &Self::Segment, 60 | writer: &mut W, 61 | ) -> impl std::future::Future> + Send 62 | where 63 | W: tokio::io::AsyncWrite + Unpin + Send + Sync + 'static; 64 | } 65 | 66 | pub trait StreamingSegment { 67 | /// Stream id 68 | fn stream_id(&self) -> u64; 69 | 70 | /// Sequence ID of the segment, starts from 0 71 | fn sequence(&self) -> u64; 72 | 73 | /// File name of the segment 74 | fn file_name(&self) -> &str; 75 | 76 | /// Optional initial segment data 77 | fn initial_segment(&self) -> InitialSegment { 78 | InitialSegment::None 79 | } 80 | 81 | /// Optional key for decryption 82 | fn key(&self) -> Option>; 83 | 84 | fn r#type(&self) -> SegmentType; 85 | 86 | /// Format hint for the segment 87 | fn format(&self) -> SegmentFormat; 88 | } 89 | -------------------------------------------------------------------------------- /crates/iori/src/merge.rs: -------------------------------------------------------------------------------- 1 | mod auto; 2 | mod concat; 3 | mod pipe; 4 | mod skip; 5 | 6 | pub use auto::AutoMerger; 7 | pub use concat::ConcatAfterMerger; 8 | pub use pipe::PipeMerger; 9 | pub use skip::SkipMerger; 10 | use tokio::io::AsyncWrite; 11 | 12 | use crate::{cache::CacheSource, error::IoriResult, SegmentInfo}; 13 | use std::{future::Future, path::PathBuf}; 14 | 15 | pub trait Merger { 16 | /// Result of the merge. 17 | type Result: Send + Sync + 'static; 18 | 19 | /// Add a segment to the merger. 20 | /// 21 | /// This method might not be called in order of segment sequence. 22 | /// Implementations should handle order of segments by calling 23 | /// [StreamingSegment::sequence]. 24 | fn update( 25 | &mut self, 26 | segment: SegmentInfo, 27 | cache: impl CacheSource, 28 | ) -> impl Future> + Send; 29 | 30 | /// Tell the merger that a segment has failed to download. 31 | fn fail( 32 | &mut self, 33 | segment: SegmentInfo, 34 | cache: impl CacheSource, 35 | ) -> impl Future> + Send; 36 | 37 | fn finish( 38 | &mut self, 39 | cache: impl CacheSource, 40 | ) -> impl std::future::Future> + Send; 41 | } 42 | 43 | pub enum IoriMerger { 44 | Pipe(PipeMerger), 45 | Skip(SkipMerger), 46 | Concat(ConcatAfterMerger), 47 | Auto(AutoMerger), 48 | } 49 | 50 | impl IoriMerger { 51 | pub fn pipe(recycle: bool) -> Self { 52 | Self::Pipe(PipeMerger::stdout(recycle)) 53 | } 54 | 55 | pub fn pipe_to_writer( 56 | recycle: bool, 57 | writer: impl AsyncWrite + Unpin + Send + Sync + 'static, 58 | ) -> Self { 59 | Self::Pipe(PipeMerger::writer(recycle, writer)) 60 | } 61 | 62 | pub fn pipe_to_file(recycle: bool, output_file: PathBuf) -> Self { 63 | Self::Pipe(PipeMerger::file(recycle, output_file)) 64 | } 65 | 66 | pub fn pipe_mux(recycle: bool, output_file: PathBuf, extra_commands: Option) -> Self { 67 | Self::Pipe(PipeMerger::mux(recycle, output_file, extra_commands)) 68 | } 69 | 70 | pub fn skip() -> Self { 71 | Self::Skip(SkipMerger) 72 | } 73 | 74 | pub fn concat(output_file: PathBuf, keep_segments: bool) -> Self { 75 | Self::Concat(ConcatAfterMerger::new(output_file, keep_segments)) 76 | } 77 | 78 | pub fn auto(output_file: PathBuf, keep_segments: bool) -> Self { 79 | Self::Auto(AutoMerger::new(output_file, keep_segments)) 80 | } 81 | } 82 | 83 | impl Merger for IoriMerger { 84 | type Result = (); // TODO: merger might have different result types 85 | 86 | async fn update(&mut self, segment: SegmentInfo, cache: impl CacheSource) -> IoriResult<()> { 87 | match self { 88 | Self::Pipe(merger) => merger.update(segment, cache).await, 89 | Self::Skip(merger) => merger.update(segment, cache).await, 90 | Self::Concat(merger) => merger.update(segment, cache).await, 91 | Self::Auto(merger) => merger.update(segment, cache).await, 92 | } 93 | } 94 | 95 | async fn fail(&mut self, segment: SegmentInfo, cache: impl CacheSource) -> IoriResult<()> { 96 | match self { 97 | Self::Pipe(merger) => merger.fail(segment, cache).await, 98 | Self::Skip(merger) => merger.fail(segment, cache).await, 99 | Self::Concat(merger) => merger.fail(segment, cache).await, 100 | Self::Auto(merger) => merger.fail(segment, cache).await, 101 | } 102 | } 103 | 104 | async fn finish(&mut self, cache: impl CacheSource) -> IoriResult { 105 | match self { 106 | Self::Pipe(merger) => merger.finish(cache).await, 107 | Self::Skip(merger) => merger.finish(cache).await, 108 | Self::Concat(merger) => merger.finish(cache).await, 109 | Self::Auto(merger) => merger.finish(cache).await, 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /crates/iori/src/merge/concat.rs: -------------------------------------------------------------------------------- 1 | use super::Merger; 2 | use crate::{ 3 | cache::CacheSource, error::IoriResult, util::path::DuplicateOutputFileNamer, SegmentInfo, 4 | }; 5 | use std::path::PathBuf; 6 | use tokio::fs::File; 7 | 8 | /// Concat all segments into a single file after all segments are downloaded. 9 | pub struct ConcatAfterMerger { 10 | segments: Vec, 11 | 12 | /// Final output file path. 13 | output_file: PathBuf, 14 | /// Keep downloaded segments after merging. 15 | keep_segments: bool, 16 | } 17 | 18 | impl ConcatAfterMerger { 19 | pub fn new(output_file: PathBuf, keep_segments: bool) -> Self { 20 | Self { 21 | segments: Vec::new(), 22 | output_file, 23 | keep_segments, 24 | } 25 | } 26 | } 27 | 28 | impl Merger for ConcatAfterMerger { 29 | type Result = (); 30 | 31 | async fn update(&mut self, segment: SegmentInfo, _cache: impl CacheSource) -> IoriResult<()> { 32 | self.segments.push(ConcatSegment { 33 | segment, 34 | success: true, 35 | }); 36 | Ok(()) 37 | } 38 | 39 | async fn fail(&mut self, segment: SegmentInfo, cache: impl CacheSource) -> IoriResult<()> { 40 | cache.invalidate(&segment).await?; 41 | self.segments.push(ConcatSegment { 42 | segment, 43 | success: false, 44 | }); 45 | Ok(()) 46 | } 47 | 48 | async fn finish(&mut self, cache: impl CacheSource) -> IoriResult { 49 | tracing::info!("Merging chunks..."); 50 | concat_merge(&mut self.segments, &cache, self.output_file.clone()).await?; 51 | 52 | if !self.keep_segments { 53 | tracing::info!("End of merging."); 54 | tracing::info!("Starting cleaning temporary files."); 55 | cache.clear().await?; 56 | } 57 | 58 | tracing::info!( 59 | "All finished. Please checkout your files at {}", 60 | self.output_file.display() 61 | ); 62 | Ok(()) 63 | } 64 | } 65 | 66 | fn trim_end(input: &[T], should_skip: fn(&T) -> bool) -> &[T] { 67 | let mut end = input.len(); 68 | while end > 0 && should_skip(&input[end - 1]) { 69 | end -= 1; 70 | } 71 | &input[..end] 72 | } 73 | 74 | pub(crate) struct ConcatSegment { 75 | pub segment: SegmentInfo, 76 | pub success: bool, 77 | } 78 | 79 | async fn concat_merge( 80 | segments: &mut [ConcatSegment], 81 | cache: &impl CacheSource, 82 | output_path: PathBuf, 83 | ) -> IoriResult<()> { 84 | segments.sort_by(|a, b| a.segment.sequence.cmp(&b.segment.sequence)); 85 | let segments = trim_end(segments, |s| !s.success); 86 | 87 | let mut namer = DuplicateOutputFileNamer::new(output_path.clone()); 88 | let mut output = File::create(output_path).await?; 89 | for segment in segments { 90 | let success = segment.success; 91 | let segment = &segment.segment; 92 | if !success { 93 | output = File::create(namer.next_path()).await?; 94 | } 95 | 96 | let mut reader = cache.open_reader(segment).await?; 97 | tokio::io::copy(&mut reader, &mut output).await?; 98 | } 99 | Ok(()) 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | #[test] 105 | fn test_trim_end() { 106 | let input = [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0]; 107 | let output = super::trim_end(&input, |&x| x == 0); 108 | assert_eq!(output, [1, 2, 3]); 109 | 110 | let input = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3]; 111 | let output = super::trim_end(&input, |&x| x == 0); 112 | assert_eq!(output, input); 113 | 114 | let input = [1, 2, 3, 0, 0, 3, 0, 0, 0]; 115 | let output = super::trim_end(&input, |&x| x == 0); 116 | assert_eq!(output, [1, 2, 3, 0, 0, 3]); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /crates/iori/src/merge/skip.rs: -------------------------------------------------------------------------------- 1 | use super::Merger; 2 | use crate::{cache::CacheSource, error::IoriResult, SegmentInfo}; 3 | 4 | pub struct SkipMerger; 5 | 6 | impl Merger for SkipMerger { 7 | type Result = (); 8 | 9 | async fn update(&mut self, _segment: SegmentInfo, _cache: impl CacheSource) -> IoriResult<()> { 10 | Ok(()) 11 | } 12 | 13 | async fn fail(&mut self, _segment: SegmentInfo, _cache: impl CacheSource) -> IoriResult<()> { 14 | Ok(()) 15 | } 16 | 17 | async fn finish(&mut self, cache: impl CacheSource) -> IoriResult { 18 | tracing::info!("Skip merging. Please merge video chunks manually."); 19 | tracing::info!("Temporary files are located at {:?}", cache.location_hint()); 20 | Ok(()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /crates/iori/src/raw/http.rs: -------------------------------------------------------------------------------- 1 | use reqwest::header::{ACCEPT, ACCEPT_RANGES, CONTENT_LENGTH, RANGE}; 2 | use std::{fmt::Display, sync::Arc}; 3 | use tokio::{io::AsyncWrite, sync::mpsc}; 4 | 5 | use crate::{ 6 | decrypt::IoriKey, HttpClient, IoriResult, SegmentFormat, SegmentType, StreamingSegment, 7 | StreamingSource, 8 | }; 9 | 10 | pub struct HttpFileSource { 11 | url: Arc, 12 | client: HttpClient, 13 | ext: String, 14 | } 15 | 16 | impl HttpFileSource { 17 | pub fn new(client: HttpClient, url: String, ext: String) -> Self { 18 | Self { 19 | url: Arc::new(url), 20 | client, 21 | ext, 22 | } 23 | } 24 | } 25 | 26 | pub struct HttpRange { 27 | start: u64, 28 | end: Option, 29 | } 30 | 31 | impl Display for HttpRange { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 | if let Some(end) = self.end { 34 | f.write_fmt(format_args!("bytes={}-{}", self.start, end)) 35 | } else { 36 | f.write_fmt(format_args!("bytes={}-", self.start)) 37 | } 38 | } 39 | } 40 | 41 | pub struct HttpSegment { 42 | url: Arc, 43 | filename: String, 44 | ext: String, 45 | 46 | sequence: u64, 47 | range: Option, 48 | } 49 | 50 | impl StreamingSegment for HttpSegment { 51 | fn stream_id(&self) -> u64 { 52 | 0 53 | } 54 | 55 | fn sequence(&self) -> u64 { 56 | self.sequence 57 | } 58 | 59 | fn file_name(&self) -> &str { 60 | &self.filename 61 | } 62 | 63 | fn key(&self) -> Option> { 64 | None 65 | } 66 | 67 | fn r#type(&self) -> SegmentType { 68 | SegmentType::Video 69 | } 70 | 71 | fn format(&self) -> SegmentFormat { 72 | SegmentFormat::Raw(self.ext.clone()) 73 | } 74 | } 75 | 76 | impl StreamingSource for HttpFileSource { 77 | type Segment = HttpSegment; 78 | 79 | async fn fetch_info( 80 | &self, 81 | ) -> IoriResult>>> { 82 | let (tx, rx) = mpsc::unbounded_channel(); 83 | 84 | // detect whether range is supported 85 | let response = self.client.get(self.url.as_str()).send().await?; 86 | let content_length = response 87 | .headers() 88 | .get(CONTENT_LENGTH) 89 | .and_then(|v| v.to_str().ok()) 90 | .and_then(|v| v.parse::().ok()) 91 | .unwrap_or(0); 92 | let accept_ranges = response 93 | .headers() 94 | .get(ACCEPT_RANGES) 95 | .map(|r| r.as_bytes() == b"bytes") 96 | .unwrap_or(false) 97 | && content_length > 0; 98 | drop(response); 99 | 100 | let mut segments = Vec::new(); 101 | if !accept_ranges { 102 | segments.push(HttpSegment { 103 | url: self.url.clone(), 104 | filename: "01".to_string(), 105 | ext: self.ext.clone(), 106 | sequence: 0, 107 | range: None, 108 | }); 109 | } else { 110 | let mut seq = 0; 111 | let mut now = 0; 112 | 113 | while now < content_length { 114 | let end = (now + 2 * 1024 * 1024).min(content_length); 115 | let range = HttpRange { 116 | start: now, 117 | end: Some(end - 1), // 5MiB per chunk 118 | }; 119 | now = end; 120 | segments.push(HttpSegment { 121 | url: self.url.clone(), 122 | filename: format!( 123 | "{}_{}", 124 | range.start, 125 | range.end.unwrap_or(content_length - 1) 126 | ), 127 | ext: self.ext.clone(), 128 | sequence: seq, 129 | range: Some(range), 130 | }); 131 | seq += 1; 132 | } 133 | } 134 | 135 | tx.send(Ok(segments)).unwrap(); 136 | 137 | Ok(rx) 138 | } 139 | 140 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 141 | where 142 | W: AsyncWrite + Unpin + Send + Sync + 'static, 143 | { 144 | use futures::stream::TryStreamExt; 145 | 146 | let mut request = self.client.get(segment.url.as_str()).header(ACCEPT, "*/*"); 147 | if let Some(range) = &segment.range { 148 | request = request.header(RANGE, range.to_string()); 149 | } 150 | 151 | let response = request.send().await?; 152 | 153 | let stream = response.bytes_stream().map_err(std::io::Error::other); 154 | let mut reader = tokio_util::io::StreamReader::new(stream); 155 | tokio::io::copy(&mut reader, writer).await?; 156 | 157 | Ok(()) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /crates/iori/src/raw/mod.rs: -------------------------------------------------------------------------------- 1 | use tokio::{ 2 | io::{AsyncWrite, AsyncWriteExt}, 3 | sync::mpsc, 4 | }; 5 | 6 | use crate::{IoriResult, StreamingSegment, StreamingSource}; 7 | 8 | mod http; 9 | pub use http::*; 10 | 11 | pub struct RawDataSource { 12 | data: String, 13 | ext: String, 14 | } 15 | 16 | impl RawDataSource { 17 | pub fn new(data: String, ext: String) -> Self { 18 | Self { data, ext } 19 | } 20 | } 21 | 22 | pub struct RawSegment { 23 | data: String, 24 | 25 | filename: String, 26 | ext: String, 27 | } 28 | 29 | impl RawSegment { 30 | pub fn new(data: String, ext: String) -> Self { 31 | Self { 32 | data, 33 | filename: format!("01.{ext}"), 34 | ext, 35 | } 36 | } 37 | } 38 | 39 | impl StreamingSegment for RawSegment { 40 | fn stream_id(&self) -> u64 { 41 | 0 42 | } 43 | 44 | fn sequence(&self) -> u64 { 45 | 0 46 | } 47 | 48 | fn file_name(&self) -> &str { 49 | &self.filename 50 | } 51 | 52 | fn key(&self) -> Option> { 53 | None 54 | } 55 | 56 | fn r#type(&self) -> crate::SegmentType { 57 | crate::SegmentType::Subtitle 58 | } 59 | 60 | fn format(&self) -> crate::SegmentFormat { 61 | crate::SegmentFormat::Raw(self.ext.clone()) 62 | } 63 | } 64 | 65 | impl StreamingSource for RawDataSource { 66 | type Segment = RawSegment; 67 | 68 | async fn fetch_info( 69 | &self, 70 | ) -> IoriResult>>> { 71 | let (tx, rx) = mpsc::unbounded_channel(); 72 | tx.send(Ok(vec![RawSegment::new( 73 | self.data.clone(), 74 | self.ext.clone(), 75 | )])) 76 | .unwrap(); 77 | Ok(rx) 78 | } 79 | 80 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 81 | where 82 | W: AsyncWrite + Unpin + Send + Sync + 'static, 83 | { 84 | writer.write_all(segment.data.as_bytes()).await?; 85 | Ok(()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /crates/iori/src/util/http.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::Deref, sync::Arc}; 2 | 3 | use reqwest::{Client, ClientBuilder, IntoUrl}; 4 | use reqwest_cookie_store::{CookieStore, CookieStoreMutex}; 5 | 6 | #[derive(Clone)] 7 | pub struct HttpClient { 8 | client: Client, 9 | cookies_store: Arc, 10 | } 11 | 12 | impl HttpClient { 13 | pub fn new(builder: ClientBuilder) -> Self { 14 | let cookies_store = Arc::new(CookieStoreMutex::new(CookieStore::default())); 15 | let client = builder 16 | .cookie_provider(cookies_store.clone()) 17 | .build() 18 | .unwrap(); 19 | 20 | Self { 21 | client, 22 | cookies_store, 23 | } 24 | } 25 | 26 | pub fn add_cookies(&self, cookies: Vec, url: impl IntoUrl) { 27 | if cookies.is_empty() { 28 | return; 29 | } 30 | 31 | let url = url.into_url().unwrap(); 32 | let mut lock = self.cookies_store.lock().unwrap(); 33 | for cookie in cookies { 34 | _ = lock.parse(&cookie, &url); 35 | } 36 | } 37 | } 38 | 39 | impl Default for HttpClient { 40 | fn default() -> Self { 41 | let cookies_store = Arc::new(CookieStoreMutex::new(CookieStore::default())); 42 | let client = Client::builder() 43 | .cookie_provider(cookies_store.clone()) 44 | .build() 45 | .unwrap(); 46 | 47 | Self { 48 | client, 49 | cookies_store, 50 | } 51 | } 52 | } 53 | 54 | impl Deref for HttpClient { 55 | type Target = Client; 56 | 57 | fn deref(&self) -> &Self::Target { 58 | &self.client 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/iori/src/util/mix.rs: -------------------------------------------------------------------------------- 1 | pub trait VecMix { 2 | type Item; 3 | 4 | fn mix(self) -> Vec; 5 | } 6 | 7 | impl VecMix for Vec> { 8 | type Item = T; 9 | 10 | fn mix(self) -> Vec { 11 | // Merge vectors by interleaving their elements 12 | // For example: [[a1, a2, a3], [b1, b2, b3]] -> [a1, b1, a2, b2, a3, b3] 13 | let max_len = self.iter().map(|v| v.len()).max().unwrap_or(0); 14 | let total_len = self.iter().map(|v| v.len()).sum(); 15 | let mut result = Vec::with_capacity(total_len); 16 | 17 | // Consume the input vectors 18 | let mut vecs = self; 19 | for _ in 0..max_len { 20 | for vec in &mut vecs { 21 | if !vec.is_empty() { 22 | // TODO: avoid array shift 23 | let item = vec.remove(0); 24 | result.push(item); 25 | } 26 | } 27 | } 28 | 29 | result 30 | } 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use super::*; 36 | 37 | #[test] 38 | fn test_mix_single_vec() { 39 | let mixed_vec = vec![vec![1, 3, 5]].mix(); 40 | assert_eq!(mixed_vec, vec![1, 3, 5]); 41 | } 42 | 43 | #[test] 44 | fn test_mix_vec() { 45 | let mixed_vec = vec![vec![1, 3, 5], vec![2, 4, 6]].mix(); 46 | assert_eq!(mixed_vec, vec![1, 2, 3, 4, 5, 6]); 47 | } 48 | 49 | #[test] 50 | fn test_mix_vec_empty() { 51 | let mixed_vec = vec![vec![], vec![1, 2, 3]].mix(); 52 | assert_eq!(mixed_vec, vec![1, 2, 3]); 53 | } 54 | 55 | #[test] 56 | fn test_mix_vec_different_length() { 57 | let mixed_vec: Vec = vec![vec![1, 2, 3, 4, 5, 6], vec![7, 8, 9]].mix(); 58 | assert_eq!(mixed_vec, vec![1, 7, 2, 8, 3, 9, 4, 5, 6]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/iori/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | use http::HttpClient; 2 | 3 | use crate::IoriResult; 4 | 5 | pub mod http; 6 | pub mod mix; 7 | pub mod ordered_stream; 8 | pub mod path; 9 | pub mod range; 10 | 11 | pub async fn detect_manifest_type(url: &str, client: HttpClient) -> IoriResult { 12 | // 1. chcek extension 13 | let url = reqwest::Url::parse(url)?; 14 | if url.path().to_lowercase().ends_with(".m3u8") { 15 | return Ok(true); 16 | } else if url.path().to_lowercase().ends_with(".mpd") { 17 | return Ok(false); 18 | } 19 | 20 | // 2. check content type 21 | let response = client.get(url).send().await?; 22 | let content_type = response 23 | .headers() 24 | .get("content-type") 25 | .and_then(|s| s.to_str().ok()) 26 | .map(|r| r.to_lowercase()); 27 | let initial_playlist_data = response.text().await.ok(); 28 | match content_type.as_deref() { 29 | Some("application/x-mpegurl" | "application/vnd.apple.mpegurl") => return Ok(true), 30 | Some("application/dash+xml") => return Ok(false), 31 | _ => {} 32 | } 33 | 34 | // 3. check by parsing 35 | if let Some(initial_playlist_data) = initial_playlist_data { 36 | let is_valid_m3u8 = m3u8_rs::parse_playlist_res(initial_playlist_data.as_bytes()).is_ok(); 37 | if is_valid_m3u8 { 38 | return Ok(is_valid_m3u8); 39 | } 40 | } 41 | 42 | Ok(false) 43 | } 44 | -------------------------------------------------------------------------------- /crates/iori/src/util/ordered_stream.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap}; 2 | use tokio::sync::mpsc; 3 | 4 | pub struct OrderedStream { 5 | // stream_id -> sequence -> item 6 | buffer: HashMap>, 7 | // stream_id -> next_seq 8 | next_seq: HashMap, 9 | // stream_id, sequence, item 10 | rx: mpsc::UnboundedReceiver<(u64, u64, T)>, 11 | } 12 | 13 | impl OrderedStream { 14 | pub fn new(rx: mpsc::UnboundedReceiver<(u64, u64, T)>) -> Self { 15 | Self { 16 | buffer: HashMap::new(), 17 | next_seq: HashMap::new(), 18 | rx, 19 | } 20 | } 21 | 22 | pub async fn next(&mut self) -> Option<(u64, T)> { 23 | loop { 24 | // Check if we have the next item in buffer for any stream 25 | for (stream_id, next_seq) in self.next_seq.iter_mut() { 26 | if let Some(stream_buffer) = self.buffer.get_mut(stream_id) { 27 | if let Some(item) = stream_buffer.remove(next_seq) { 28 | *next_seq += 1; 29 | return Some((*stream_id, item)); 30 | } 31 | } 32 | } 33 | 34 | // Receive new item 35 | match self.rx.recv().await { 36 | Some((stream_id, seq, item)) => { 37 | // Initialize next_seq for new streams 38 | let next_seq = self.next_seq.entry(stream_id).or_insert(0); 39 | 40 | if seq == *next_seq { 41 | *next_seq += 1; 42 | return Some((stream_id, item)); 43 | } else { 44 | // Store out-of-order item in the buffer 45 | let stream_buffer = self.buffer.entry(stream_id).or_default(); 46 | stream_buffer.insert(seq, item); 47 | } 48 | } 49 | None => return None, 50 | } 51 | } 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | 59 | #[tokio::test] 60 | async fn test_one_ordered_stream() { 61 | let (tx, rx) = mpsc::unbounded_channel(); 62 | let mut ordered = OrderedStream::new(rx); 63 | 64 | // Send items out of order 65 | tokio::spawn(async move { 66 | tx.send((0, 2, "c")).unwrap(); 67 | tx.send((0, 0, "a")).unwrap(); 68 | tx.send((0, 1, "b")).unwrap(); 69 | drop(tx); 70 | }); 71 | 72 | // Receive items in order 73 | assert_eq!(ordered.next().await.unwrap(), (0, "a")); 74 | assert_eq!(ordered.next().await.unwrap(), (0, "b")); 75 | assert_eq!(ordered.next().await.unwrap(), (0, "c")); 76 | assert_eq!(ordered.next().await, None); 77 | } 78 | 79 | #[tokio::test] 80 | async fn test_mixed_ordered_streams() { 81 | let (tx, rx) = mpsc::unbounded_channel(); 82 | let mut ordered = OrderedStream::new(rx); 83 | 84 | // Send items out of order 85 | tokio::spawn(async move { 86 | tx.send((0, 2, "c")).unwrap(); 87 | tx.send((1, 0, "a")).unwrap(); 88 | tx.send((0, 0, "a")).unwrap(); 89 | tx.send((1, 1, "b")).unwrap(); 90 | tx.send((0, 1, "b")).unwrap(); 91 | tx.send((1, 2, "c")).unwrap(); 92 | drop(tx); 93 | }); 94 | 95 | // Receive items in order 96 | assert_eq!(ordered.next().await.unwrap(), (1, "a")); 97 | assert_eq!(ordered.next().await.unwrap(), (0, "a")); 98 | assert_eq!(ordered.next().await.unwrap(), (1, "b")); 99 | assert_eq!(ordered.next().await.unwrap(), (0, "b")); 100 | assert_eq!(ordered.next().await.unwrap(), (0, "c")); 101 | assert_eq!(ordered.next().await.unwrap(), (1, "c")); 102 | assert_eq!(ordered.next().await, None); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /crates/iori/src/util/path.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::{OsStr, OsString}, 3 | path::PathBuf, 4 | }; 5 | 6 | pub struct DuplicateOutputFileNamer { 7 | output_path: PathBuf, 8 | /// The count of files that have been generated. 9 | file_count: u32, 10 | file_extension: String, 11 | } 12 | 13 | impl DuplicateOutputFileNamer { 14 | pub fn new(output_path: PathBuf) -> Self { 15 | let file_extension = output_path 16 | .extension() 17 | .unwrap_or_default() 18 | .to_str() 19 | .unwrap_or_default() 20 | .to_string(); 21 | 22 | Self { 23 | output_path, 24 | file_count: 0, 25 | file_extension, 26 | } 27 | } 28 | 29 | pub fn next_path(&mut self) -> PathBuf { 30 | self.file_count += 1; 31 | self.get_path(self.file_count) 32 | } 33 | 34 | fn get_path(&self, file_id: u32) -> PathBuf { 35 | self.output_path 36 | .with_extension(format!("{file_id}.{}", self.file_extension)) 37 | } 38 | } 39 | 40 | impl Drop for DuplicateOutputFileNamer { 41 | fn drop(&mut self) { 42 | if self.file_count == 1 { 43 | if let Err(e) = std::fs::rename(self.get_path(1), &self.output_path) { 44 | tracing::error!("Failed to rename file: {e}"); 45 | } 46 | } 47 | } 48 | } 49 | 50 | pub trait IoriPathExt { 51 | /// Add suffix to file name without changing extension. 52 | /// 53 | /// Note this function does not handle multiple suffixes. 54 | /// For example, `test.tar.gz` with `_suffix` will be `test.tar_suffix.gz`. 55 | fn add_suffix>(&mut self, suffix: T); 56 | } 57 | 58 | impl IoriPathExt for PathBuf { 59 | fn add_suffix>(&mut self, suffix: T) { 60 | let mut filename = OsString::new(); 61 | 62 | // {file_stem}_{suffix}.{ext} 63 | if let Some(file_stem) = self.file_stem() { 64 | filename.push(file_stem); 65 | } 66 | filename.push("_"); 67 | filename.push(suffix); 68 | 69 | if let Some(ext) = self.extension() { 70 | filename.push("."); 71 | filename.push(ext); 72 | } 73 | 74 | self.set_file_name(filename); 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | use std::path::PathBuf; 82 | 83 | #[test] 84 | fn test_file_names() { 85 | let mut namer = DuplicateOutputFileNamer::new(PathBuf::from("output.ts")); 86 | for i in 1..=100 { 87 | assert_eq!(namer.next_path(), PathBuf::from(format!("output.{i}.ts"))); 88 | } 89 | } 90 | 91 | #[test] 92 | fn test_filename_suffix() { 93 | let mut path = PathBuf::from("test.mp4"); 94 | path.add_suffix("suffix"); 95 | assert_eq!(path.to_string_lossy(), "test_suffix.mp4"); 96 | } 97 | 98 | #[test] 99 | fn test_filename_multiple_suffix() { 100 | let mut path = PathBuf::from("test.raw.mp4"); 101 | path.add_suffix("suffix"); 102 | assert_eq!(path.to_string_lossy(), "test.raw_suffix.mp4"); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /crates/iori/src/util/range.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, PartialEq)] 2 | pub struct ByteRange { 3 | pub offset: u64, 4 | pub length: Option, 5 | } 6 | 7 | impl ByteRange { 8 | pub fn new(offset: u64, length: Option) -> Self { 9 | Self { offset, length } 10 | } 11 | 12 | pub fn to_http_range(&self) -> String { 13 | if let Some(length) = self.length { 14 | format!("bytes={}-{}", self.offset, self.offset + length - 1) 15 | } else { 16 | format!("bytes={}-", self.offset) 17 | } 18 | } 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | 25 | #[test] 26 | fn test_to_http_range() { 27 | let range = ByteRange::new(10, Some(10)); 28 | assert_eq!(range.to_http_range(), "bytes=10-19"); 29 | 30 | let range = ByteRange::new(10, None); 31 | assert_eq!(range.to_http_range(), "bytes=10-"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/iori/tests/dash/dash_mpd_rs.rs: -------------------------------------------------------------------------------- 1 | use iori::{ 2 | dash::{archive::CommonDashArchiveSource, live::CommonDashLiveSource}, 3 | HttpClient, StreamingSource, 4 | }; 5 | 6 | use crate::{dash::setup_mock_server, AssertWrapper}; 7 | 8 | #[tokio::test] 9 | async fn test_static_a2d_tv() -> anyhow::Result<()> { 10 | let data = include_str!("../fixtures/dash/dash-mpd-rs/a2d-tv.mpd"); 11 | let (playlist_uri, _server) = setup_mock_server(data).await; 12 | 13 | let client = HttpClient::default(); 14 | let playlist = CommonDashLiveSource::new(client.clone(), playlist_uri.parse()?, None)?; 15 | 16 | let mut info = playlist.fetch_info().await?; 17 | 18 | let segments_live = info.recv().await.assert_success()?; 19 | assert_eq!(segments_live.len(), 1896); 20 | // no further segments 21 | info.recv().await.assert_error(); 22 | 23 | let playlist = CommonDashArchiveSource::new(client, playlist_uri.parse()?, None, None)?; 24 | let mut info = playlist.fetch_info().await?; 25 | 26 | let mut segments_archive = Vec::new(); 27 | let segments = info.recv().await.assert_success()?; 28 | assert_eq!(segments.len(), 644); 29 | segments_archive.extend(segments); 30 | let segments = info.recv().await.assert_success()?; 31 | assert_eq!(segments.len(), 636); 32 | segments_archive.extend(segments); 33 | let segments = info.recv().await.assert_success()?; 34 | assert_eq!(segments.len(), 616); 35 | segments_archive.extend(segments); 36 | // no further segments 37 | info.recv().await.assert_error(); 38 | 39 | for (i, segment) in segments_archive.iter().enumerate() { 40 | assert_eq!(segment.url, segments_live[i].url); 41 | assert_eq!(segment.initial_segment, segments_live[i].initial_segment); 42 | assert_eq!(segment.byte_range, segments_live[i].byte_range); 43 | } 44 | 45 | Ok(()) 46 | } 47 | 48 | #[tokio::test] 49 | async fn test_dash_testcases_5b_1_thomson() -> anyhow::Result<()> { 50 | let data = include_str!("../fixtures/dash/dash-mpd-rs/dash-testcases-5b-1-thomson.mpd"); 51 | let (playlist_uri, _server) = setup_mock_server(data).await; 52 | 53 | let client = HttpClient::default(); 54 | let playlist = CommonDashLiveSource::new(client.clone(), playlist_uri.parse()?, None)?; 55 | 56 | let mut info = playlist.fetch_info().await?; 57 | 58 | let segments_live = info.recv().await.assert_success()?; 59 | assert_eq!(segments_live.len(), 248); 60 | // no further segments 61 | info.recv().await.assert_error(); 62 | 63 | let playlist = CommonDashArchiveSource::new(client, playlist_uri.parse()?, None, None)?; 64 | let mut info = playlist.fetch_info().await?; 65 | 66 | let mut segments_archive = Vec::new(); 67 | let segments = info.recv().await.assert_success()?; 68 | assert_eq!(segments.len(), 45); 69 | segments_archive.extend(segments); 70 | 71 | let segments = info.recv().await.assert_success()?; 72 | assert_eq!(segments.len(), 45); 73 | segments_archive.extend(segments); 74 | 75 | let segments = info.recv().await.assert_success()?; 76 | assert_eq!(segments.len(), 30); 77 | segments_archive.extend(segments); 78 | 79 | let segments = info.recv().await.assert_success()?; 80 | assert_eq!(segments.len(), 30); 81 | segments_archive.extend(segments); 82 | 83 | let segments = info.recv().await.assert_success()?; 84 | assert_eq!(segments.len(), 49); 85 | segments_archive.extend(segments); 86 | 87 | let segments = info.recv().await.assert_success()?; 88 | assert_eq!(segments.len(), 49); 89 | segments_archive.extend(segments); 90 | 91 | info.recv().await.assert_error(); 92 | 93 | for (i, segment) in segments_archive.iter().enumerate() { 94 | assert_eq!(segment.url, segments_live[i].url); 95 | assert_eq!(segment.initial_segment, segments_live[i].initial_segment); 96 | assert_eq!(segment.byte_range, segments_live[i].byte_range); 97 | } 98 | 99 | Ok(()) 100 | } 101 | -------------------------------------------------------------------------------- /crates/iori/tests/dash/mod.rs: -------------------------------------------------------------------------------- 1 | mod dash_mpd_rs; 2 | mod r#static; 3 | 4 | use wiremock::{ 5 | matchers::{method, path}, 6 | Mock, MockServer, ResponseTemplate, 7 | }; 8 | 9 | async fn setup_mock_server(body: &str) -> (String, MockServer) { 10 | let mock_server = MockServer::start().await; 11 | 12 | Mock::given(method("GET")) 13 | .and(path("/manifest.mpd")) 14 | .respond_with(ResponseTemplate::new(200).set_body_string(body)) 15 | .mount(&mock_server) 16 | .await; 17 | 18 | (format!("{}/manifest.mpd", mock_server.uri()), mock_server) 19 | } 20 | -------------------------------------------------------------------------------- /crates/iori/tests/downloader/mod.rs: -------------------------------------------------------------------------------- 1 | mod parallel; 2 | -------------------------------------------------------------------------------- /crates/iori/tests/downloader/parallel.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicU8, Arc}; 2 | 3 | use iori::{cache::memory::MemoryCacheSource, download::ParallelDownloader, merge::SkipMerger}; 4 | 5 | use crate::source::{TestSegment, TestSource}; 6 | 7 | #[tokio::test] 8 | async fn test_parallel_downloader_with_failed_retry() -> anyhow::Result<()> { 9 | let source = TestSource::new(vec![TestSegment { 10 | stream_id: 1, 11 | sequence: 1, 12 | file_name: "test.ts".to_string(), 13 | fail_count: Arc::new(AtomicU8::new(2)), 14 | }]); 15 | 16 | let cache = Arc::new(MemoryCacheSource::new()); 17 | 18 | ParallelDownloader::builder() 19 | .merger(SkipMerger) 20 | .cache(cache.clone()) 21 | .retries(1) 22 | .download(source) 23 | .await?; 24 | 25 | let result = cache.into_inner(); 26 | let result = result.lock().unwrap(); 27 | assert_eq!(result.len(), 0); 28 | 29 | Ok(()) 30 | } 31 | 32 | #[tokio::test] 33 | async fn test_parallel_downloader_with_success_retry() -> anyhow::Result<()> { 34 | let source = TestSource::new(vec![TestSegment { 35 | stream_id: 1, 36 | sequence: 1, 37 | file_name: "test.ts".to_string(), 38 | fail_count: Arc::new(AtomicU8::new(2)), 39 | }]); 40 | 41 | let cache = Arc::new(MemoryCacheSource::new()); 42 | 43 | ParallelDownloader::builder() 44 | .merger(SkipMerger) 45 | .cache(cache.clone()) 46 | .retries(3) 47 | .download(source) 48 | .await?; 49 | 50 | let result = cache.into_inner(); 51 | let result = result.lock().unwrap(); 52 | assert_eq!(result.len(), 1); 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/dash/dash-mpd-rs/dash-testcases-5b-1-thomson.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | http://dash.edgesuite.net/dash264/TestCases/1b/thomson-networks/1/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | http://dash.edgesuite.net/dash264/TestCases/2b/thomson-networks/1/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | http://dash.edgesuite.net/dash264/TestCases/1b/thomson-networks/1/ 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/m3u8-rs/Readme.md: -------------------------------------------------------------------------------- 1 | # Disclaimer 2 | 3 | Test m3u8 files under this directory is copied from [m3u8-rs](https://github.com/rutgersc/m3u8-rs/tree/master/sample-playlists), which is licensed under MIT License. 4 | 5 | ## License 6 | 7 | MIT License 8 | 9 | Copyright (c) 2016 Rutger Schoorstra 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/m3u8-rs/media-playlist-with-byterange.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:10 3 | #EXT-X-VERSION:4 4 | #EXT-X-MEDIA-SEQUENCE:0 5 | #EXTINF:10.0, 6 | #EXT-X-BYTERANGE:75232@0 7 | video.ts 8 | #EXT-X-BYTERANGE:82112@752321 9 | #EXTINF:10.0, 10 | video.ts 11 | #EXTINF:10.0, 12 | #EXT-X-BYTERANGE:69864 13 | video.ts 14 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/m3u8-rs/mediaplaylist-byterange.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:11 3 | #EXT-X-VERSION:4 4 | #EXT-X-MEDIA-SEQUENCE:0 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:9.98458, 7 | #EXT-X-BYTERANGE:86920@0 8 | main.aac 9 | #EXTINF:10.00780, 10 | #EXT-X-BYTERANGE:136595@86920 11 | main.aac 12 | #EXTINF:9.98459, 13 | #EXT-X-BYTERANGE:136567@223515 14 | main.aac 15 | #EXTINF:10.00780, 16 | #EXT-X-BYTERANGE:136954@360082 17 | main.aac 18 | #EXTINF:10.00780, 19 | #EXT-X-BYTERANGE:137116@497036 20 | main.aac 21 | #EXTINF:9.98458, 22 | #EXT-X-BYTERANGE:136770@634152 23 | main.aac 24 | #EXTINF:10.00780, 25 | #EXT-X-BYTERANGE:137219@770922 26 | main.aac 27 | #EXTINF:10.00780, 28 | #EXT-X-BYTERANGE:137132@908141 29 | main.aac 30 | #EXT-X-ENDLIST 31 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-1-simple-media-playlist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:10 3 | #EXT-X-VERSION:3 4 | #EXTINF:9.009, 5 | http://media.example.com/first.ts 6 | #EXTINF:9.009, 7 | http://media.example.com/second.ts 8 | #EXTINF:3.003, 9 | http://media.example.com/third.ts 10 | #EXT-X-ENDLIST 11 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-2-live-media-playlist-using-https.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:8 4 | #EXT-X-MEDIA-SEQUENCE:2680 5 | 6 | #EXTINF:7.975, 7 | https://priv.example.com/fileSequence2680.ts 8 | #EXTINF:7.941, 9 | https://priv.example.com/fileSequence2681.ts 10 | #EXTINF:7.975, 11 | https://priv.example.com/fileSequence2682.ts 12 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-3-playlist-with-encrypted-media-segments.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-MEDIA-SEQUENCE:7794 4 | #EXT-X-TARGETDURATION:15 5 | 6 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" 7 | 8 | #EXTINF:2.833, 9 | http://media.example.com/fileSequence52-A.ts 10 | #EXTINF:15.0, 11 | http://media.example.com/fileSequence52-B.ts 12 | #EXTINF:13.333, 13 | http://media.example.com/fileSequence52-C.ts 14 | 15 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53" 16 | 17 | #EXTINF:15.0, 18 | http://media.example.com/fileSequence53-A.ts 19 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-4-master-playlist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000 3 | /low.m3u8 4 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000 5 | /mid.m3u8 6 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000 7 | /hi.m3u8 8 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" 9 | /audio-only.m3u8 10 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-6-master-playlist-with-alternative-audio.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",URI="main/english-audio.m3u8" 3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Deutsch",DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="de", \ 4 | URI="main/german-audio.m3u8" 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Commentary",DEFAULT=NO,AUTOSELECT=NO,LANGUAGE="en",URI="commentary/audio-only.m3u8" 6 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",AUDIO="aac" 7 | low/video-only.m3u8 8 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",AUDIO="aac" 9 | mid/video-only.m3u8 10 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",AUDIO="aac" 11 | hi/video-only.m3u8 12 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5",AUDIO="aac" 13 | main/english-audio.m3u8 14 | -------------------------------------------------------------------------------- /crates/iori/tests/fixtures/hls/rfc8216/8-7-master-playlist-with-alternative-video.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Main",DEFAULT=YES,URI="low/main/audio-video.m3u8" 3 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Centerfield",DEFAULT=NO,URI="low/centerfield/audio-video.m3u8" 4 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Dugout",DEFAULT=NO,URI="low/dugout/audio-video.m3u8" 5 | 6 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",VIDEO="low" 7 | low/main/audio-video.m3u8 8 | 9 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Main",DEFAULT=YES,URI="mid/main/audio-video.m3u8" 10 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Centerfield",DEFAULT=NO,URI="mid/centerfield/audio-video.m3u8" 11 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Dugout",DEFAULT=NO,URI="mid/dugout/audio-video.m3u8" 12 | 13 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",VIDEO="mid" 14 | mid/main/audio-video.m3u8 15 | 16 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Main",DEFAULT=YES,URI="hi/main/audio-video.m3u8" 17 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Centerfield",DEFAULT=NO,URI="hi/centerfield/audio-video.m3u8" 18 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Dugout",DEFAULT=NO,URI="hi/dugout/audio-video.m3u8" 19 | 20 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",VIDEO="hi" 21 | hi/main/audio-video.m3u8 22 | -------------------------------------------------------------------------------- /crates/iori/tests/hls/m3u8_rs.rs: -------------------------------------------------------------------------------- 1 | // crates/iori/tests/fixtures/hls/m3u8-rs/media-playlist-with-byterange.m3u8 2 | 3 | use iori::{hls::HlsPlaylistSource, ByteRange, HttpClient}; 4 | 5 | use crate::hls::setup_mock_server; 6 | 7 | #[tokio::test] 8 | async fn media_playlist_with_byterange() -> anyhow::Result<()> { 9 | let data = include_str!("../fixtures/hls/m3u8-rs/media-playlist-with-byterange.m3u8"); 10 | let (playlist_uri, server) = setup_mock_server(data).await; 11 | 12 | let client = HttpClient::default(); 13 | let mut playlist = HlsPlaylistSource::new(client, playlist_uri.parse()?, None); 14 | 15 | let latest_media_sequences = playlist.load_streams(1).await?; 16 | let (streams, is_end) = playlist.load_segments(&latest_media_sequences, 1).await?; 17 | 18 | assert!(!is_end); 19 | assert_eq!(streams.len(), 1); 20 | 21 | let segments = &streams[0]; 22 | assert_eq!(segments.len(), 3); 23 | 24 | let segment = &segments[0]; 25 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 26 | assert_eq!(segment.byte_range, Some(ByteRange::new(0, Some(75232)))); 27 | 28 | let segment = &segments[1]; 29 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 30 | assert_eq!( 31 | segment.byte_range, 32 | Some(ByteRange::new(752321, Some(82112))) 33 | ); 34 | 35 | let segment = &segments[2]; 36 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 37 | assert_eq!( 38 | segment.byte_range, 39 | Some(ByteRange::new(834433, Some(69864))) 40 | ); 41 | 42 | Ok(()) 43 | } 44 | 45 | #[tokio::test] 46 | async fn mediaplaylist_byterange() -> anyhow::Result<()> { 47 | let data = include_str!("../fixtures/hls/m3u8-rs/mediaplaylist-byterange.m3u8"); 48 | let (playlist_uri, server) = setup_mock_server(data).await; 49 | 50 | let client = HttpClient::default(); 51 | let mut playlist = HlsPlaylistSource::new(client, playlist_uri.parse()?, None); 52 | 53 | let latest_media_sequences = playlist.load_streams(1).await?; 54 | let (streams, is_end) = playlist.load_segments(&latest_media_sequences, 1).await?; 55 | 56 | assert!(is_end); 57 | assert_eq!(streams.len(), 1); 58 | 59 | let segments = &streams[0]; 60 | assert_eq!(segments.len(), 8); 61 | 62 | let segment = &segments[0]; 63 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 64 | assert_eq!(segment.byte_range, Some(ByteRange::new(0, Some(86920)))); 65 | 66 | let segment = &segments[1]; 67 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 68 | assert_eq!( 69 | segment.byte_range, 70 | Some(ByteRange::new(86920, Some(136595))) 71 | ); 72 | 73 | let segment = &segments[2]; 74 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 75 | assert_eq!( 76 | segment.byte_range, 77 | Some(ByteRange::new(223515, Some(136567))) 78 | ); 79 | 80 | let segment = &segments[3]; 81 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 82 | assert_eq!( 83 | segment.byte_range, 84 | Some(ByteRange::new(360082, Some(136954))) 85 | ); 86 | 87 | let segment = &segments[4]; 88 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 89 | assert_eq!( 90 | segment.byte_range, 91 | Some(ByteRange::new(497036, Some(137116))) 92 | ); 93 | 94 | let segment = &segments[5]; 95 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 96 | assert_eq!( 97 | segment.byte_range, 98 | Some(ByteRange::new(634152, Some(136770))) 99 | ); 100 | 101 | let segment = &segments[6]; 102 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 103 | assert_eq!( 104 | segment.byte_range, 105 | Some(ByteRange::new(770922, Some(137219))) 106 | ); 107 | 108 | let segment = &segments[7]; 109 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 110 | assert_eq!( 111 | segment.byte_range, 112 | Some(ByteRange::new(908141, Some(137132))) 113 | ); 114 | 115 | Ok(()) 116 | } 117 | -------------------------------------------------------------------------------- /crates/iori/tests/hls/mod.rs: -------------------------------------------------------------------------------- 1 | mod m3u8_rs; 2 | mod rfc8216; 3 | 4 | use wiremock::{ 5 | matchers::{method, path}, 6 | Mock, MockServer, ResponseTemplate, 7 | }; 8 | 9 | async fn setup_mock_server(body: &str) -> (String, MockServer) { 10 | let mock_server = MockServer::start().await; 11 | 12 | Mock::given(method("GET")) 13 | .and(path("/playlist.m3u8")) 14 | .respond_with(ResponseTemplate::new(200).set_body_string(body)) 15 | .mount(&mock_server) 16 | .await; 17 | 18 | (format!("{}/playlist.m3u8", mock_server.uri()), mock_server) 19 | } 20 | 21 | trait HlsMock { 22 | async fn mock(&self, mock_path: &str, body: S) -> &Self 23 | where 24 | S: AsRef; 25 | 26 | async fn mock_playlist(&self, mock_path: &str, url: &str) -> &Self; 27 | } 28 | 29 | impl HlsMock for MockServer { 30 | async fn mock(&self, mock_path: &str, body: S) -> &Self 31 | where 32 | S: AsRef, 33 | { 34 | Mock::given(method("GET")) 35 | .and(path(mock_path)) 36 | .respond_with(ResponseTemplate::new(200).set_body_string(body.as_ref())) 37 | .mount(self) 38 | .await; 39 | self 40 | } 41 | 42 | async fn mock_playlist(&self, mock_path: &str, url: &str) -> &Self { 43 | self.mock( 44 | mock_path, 45 | format!( 46 | "#EXTM3U 47 | #EXT-X-TARGETDURATION:10 48 | #EXT-X-VERSION:3 49 | #EXTINF:9.009, 50 | {url} 51 | #EXT-X-ENDLIST" 52 | ), 53 | ) 54 | .await 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crates/iori/tests/lib.rs: -------------------------------------------------------------------------------- 1 | mod dash; 2 | mod downloader; 3 | mod hls; 4 | mod source; 5 | 6 | pub trait AssertWrapper { 7 | type Success; 8 | 9 | fn assert_success(self) -> Self::Success; 10 | fn assert_error(self); 11 | } 12 | 13 | impl AssertWrapper for Result 14 | where 15 | E: std::fmt::Debug, 16 | { 17 | type Success = T; 18 | 19 | fn assert_success(self) -> Self::Success { 20 | assert!(self.is_ok()); 21 | 22 | self.unwrap() 23 | } 24 | 25 | fn assert_error(self) { 26 | assert!(self.is_err()); 27 | } 28 | } 29 | 30 | impl AssertWrapper for Option { 31 | type Success = T; 32 | 33 | fn assert_success(self) -> Self::Success { 34 | assert!(self.is_some()); 35 | self.unwrap() 36 | } 37 | 38 | fn assert_error(self) { 39 | assert!(self.is_none()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/iori/tests/source.rs: -------------------------------------------------------------------------------- 1 | use futures::executor::block_on; 2 | use iori::{ 3 | InitialSegment, IoriError, IoriResult, SegmentFormat, SegmentType, StreamingSegment, 4 | StreamingSource, 5 | }; 6 | use std::sync::atomic::{AtomicU8, Ordering}; 7 | use std::sync::Arc; 8 | use tokio::io::AsyncWriteExt; 9 | use tokio::sync::mpsc; 10 | 11 | #[derive(Clone)] 12 | pub struct TestSegment { 13 | pub stream_id: u64, 14 | pub sequence: u64, 15 | pub file_name: String, 16 | pub fail_count: Arc, 17 | } 18 | 19 | impl TestSegment { 20 | async fn write_data(&self, writer: &mut W) -> IoriResult<()> 21 | where 22 | W: tokio::io::AsyncWrite + Unpin + Send + Sync + 'static, 23 | { 24 | if self.fail_count.load(Ordering::Relaxed) > 0 { 25 | self.fail_count.fetch_sub(1, Ordering::Relaxed); 26 | return Err(IoriError::IOError(std::io::Error::new( 27 | std::io::ErrorKind::Other, 28 | "Failed to write data", 29 | ))); 30 | } 31 | 32 | let data = format!("Segment {} from stream {}", self.sequence, self.stream_id); 33 | writer.write_all(data.as_bytes()).await?; 34 | Ok(()) 35 | } 36 | } 37 | 38 | impl StreamingSegment for TestSegment { 39 | fn stream_id(&self) -> u64 { 40 | self.stream_id 41 | } 42 | 43 | fn sequence(&self) -> u64 { 44 | self.sequence 45 | } 46 | 47 | fn file_name(&self) -> &str { 48 | &self.file_name 49 | } 50 | 51 | fn initial_segment(&self) -> InitialSegment { 52 | InitialSegment::None 53 | } 54 | 55 | fn key(&self) -> Option> { 56 | None 57 | } 58 | 59 | fn r#type(&self) -> SegmentType { 60 | SegmentType::Video 61 | } 62 | 63 | fn format(&self) -> SegmentFormat { 64 | SegmentFormat::Mpeg2TS 65 | } 66 | } 67 | 68 | #[derive(Clone)] 69 | pub struct TestSource { 70 | segments: Vec, 71 | } 72 | 73 | impl TestSource { 74 | pub fn new(segments: Vec) -> Self { 75 | Self { segments } 76 | } 77 | } 78 | 79 | impl StreamingSource for TestSource { 80 | type Segment = TestSegment; 81 | 82 | async fn fetch_info( 83 | &self, 84 | ) -> IoriResult>>> { 85 | let (tx, rx) = mpsc::unbounded_channel(); 86 | tx.send(Ok(self.segments.clone())).unwrap(); 87 | Ok(rx) 88 | } 89 | 90 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 91 | where 92 | W: tokio::io::AsyncWrite + Unpin + Send + Sync + 'static, 93 | { 94 | segment.write_data(writer).await 95 | } 96 | } 97 | 98 | #[test] 99 | fn test_streaming_source_implementation() { 100 | let segments = vec![ 101 | TestSegment { 102 | stream_id: 1, 103 | sequence: 0, 104 | file_name: "segment0.ts".to_string(), 105 | fail_count: Arc::new(AtomicU8::new(0)), 106 | }, 107 | TestSegment { 108 | stream_id: 1, 109 | sequence: 1, 110 | file_name: "segment1.ts".to_string(), 111 | fail_count: Arc::new(AtomicU8::new(0)), 112 | }, 113 | ]; 114 | 115 | let source = TestSource::new(segments.clone()); 116 | let mut rx = block_on(source.fetch_info()).unwrap(); 117 | 118 | let received_segments: Vec = block_on(async { 119 | let mut all_segments = Vec::new(); 120 | while let Some(result) = rx.recv().await { 121 | all_segments.extend(result.unwrap()); 122 | } 123 | all_segments 124 | }); 125 | 126 | assert_eq!(received_segments.len(), segments.len()); 127 | for (received, expected) in received_segments.iter().zip(segments.iter()) { 128 | assert_eq!(received.stream_id(), expected.stream_id()); 129 | assert_eq!(received.sequence(), expected.sequence()); 130 | assert_eq!(received.file_name(), expected.file_name()); 131 | } 132 | } 133 | 134 | #[test] 135 | fn test_streaming_source_fetch_segment() { 136 | let segment = TestSegment { 137 | stream_id: 1, 138 | sequence: 0, 139 | file_name: "segment0.ts".to_string(), 140 | fail_count: Arc::new(AtomicU8::new(0)), 141 | }; 142 | 143 | let source = TestSource::new(vec![segment.clone()]); 144 | let mut writer = Vec::new(); 145 | block_on(source.fetch_segment(&segment, &mut writer)).unwrap(); 146 | 147 | let data = String::from_utf8(writer).unwrap(); 148 | assert_eq!(data, "Segment 0 from stream 1"); 149 | } 150 | -------------------------------------------------------------------------------- /crates/iori/tests/streaming.rs: -------------------------------------------------------------------------------- 1 | use iori::decrypt::IoriKey; 2 | use iori::{InitialSegment, SegmentFormat, SegmentType, StreamingSegment}; 3 | use std::sync::Arc; 4 | 5 | struct TestSegment { 6 | stream_id: u64, 7 | sequence: u64, 8 | file_name: String, 9 | initial_segment: InitialSegment, 10 | key: Option>, 11 | segment_type: SegmentType, 12 | format: SegmentFormat, 13 | } 14 | 15 | impl TestSegment { 16 | fn new( 17 | stream_id: u64, 18 | sequence: u64, 19 | file_name: String, 20 | initial_segment: InitialSegment, 21 | key: Option>, 22 | segment_type: SegmentType, 23 | format: SegmentFormat, 24 | ) -> Self { 25 | Self { 26 | stream_id, 27 | sequence, 28 | file_name, 29 | initial_segment, 30 | key, 31 | segment_type, 32 | format, 33 | } 34 | } 35 | } 36 | 37 | impl StreamingSegment for TestSegment { 38 | fn stream_id(&self) -> u64 { 39 | self.stream_id 40 | } 41 | 42 | fn sequence(&self) -> u64 { 43 | self.sequence 44 | } 45 | 46 | fn file_name(&self) -> &str { 47 | &self.file_name 48 | } 49 | 50 | fn initial_segment(&self) -> InitialSegment { 51 | self.initial_segment.clone() 52 | } 53 | 54 | fn key(&self) -> Option> { 55 | self.key.clone() 56 | } 57 | 58 | fn r#type(&self) -> SegmentType { 59 | self.segment_type 60 | } 61 | 62 | fn format(&self) -> SegmentFormat { 63 | self.format.clone() 64 | } 65 | } 66 | 67 | #[test] 68 | fn test_streaming_segment_implementation() { 69 | let segment = TestSegment::new( 70 | 1, 71 | 0, 72 | "test.ts".to_string(), 73 | InitialSegment::None, 74 | None, 75 | SegmentType::Video, 76 | SegmentFormat::Mpeg2TS, 77 | ); 78 | 79 | assert_eq!(segment.stream_id(), 1); 80 | assert_eq!(segment.sequence(), 0); 81 | assert_eq!(segment.file_name(), "test.ts"); 82 | assert!(matches!(segment.initial_segment(), InitialSegment::None)); 83 | assert!(segment.key().is_none()); 84 | assert_eq!(segment.r#type(), SegmentType::Video); 85 | assert!(matches!(segment.format(), SegmentFormat::Mpeg2TS)); 86 | } 87 | 88 | #[test] 89 | fn test_streaming_segment_with_initial_segment() { 90 | let initial_data = vec![1, 2, 3, 4]; 91 | let segment = TestSegment::new( 92 | 1, 93 | 0, 94 | "test.ts".to_string(), 95 | InitialSegment::Clear(Arc::new(initial_data.clone())), 96 | None, 97 | SegmentType::Video, 98 | SegmentFormat::Mpeg2TS, 99 | ); 100 | 101 | match segment.initial_segment() { 102 | InitialSegment::Clear(data) => assert_eq!(&*data, &initial_data), 103 | _ => panic!("Expected Clear initial segment"), 104 | } 105 | } 106 | 107 | #[test] 108 | fn test_streaming_segment_with_key() { 109 | let key = Arc::new(IoriKey::Aes128 { 110 | key: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], 111 | iv: [0; 16], 112 | }); 113 | let segment = TestSegment::new( 114 | 1, 115 | 0, 116 | "test.ts".to_string(), 117 | InitialSegment::None, 118 | Some(key.clone()), 119 | SegmentType::Video, 120 | SegmentFormat::Mpeg2TS, 121 | ); 122 | 123 | assert!(segment.key().is_some()); 124 | let segment_key = segment.key().unwrap(); 125 | match &*segment_key { 126 | IoriKey::Aes128 { key, .. } => { 127 | assert_eq!( 128 | key, 129 | &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] 130 | ); 131 | } 132 | _ => panic!("Expected Aes128 key"), 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /crates/ssa/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.1] - 2025-06-04 9 | 10 | ### Added 11 | 12 | - Supported Elementary Audio Stream Setup. 13 | 14 | ## [0.1.1] - 2025-02-09 15 | 16 | ### Fixed 17 | 18 | - Decrypted MPEG-TS segments now have the correct `continuity counter` and pass the [continuity check](https://github.com/FFmpeg/FFmpeg/blob/43be8d07281caca2e88bfd8ee2333633e1fb1a13/libavformat/mpegts.c#L2826-L2828). 19 | - Resolved some `clone` operations. 20 | 21 | ## 0.1.0 - 2025-02-01 22 | 23 | ### Added 24 | 25 | - `Sample-AES` decryption support. 26 | 27 | [0.1.1]: https://github.com/Yesterday17/iori/tree/ssa-v0.1.1 28 | [0.2.1]: https://github.com/Yesterday17/iori/tree/ssa-v0.2.1 -------------------------------------------------------------------------------- /crates/ssa/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-ssa" 3 | description = "Decrypt MPEG-TS encrypted using SAMPLE-AES." 4 | version = "0.2.1" 5 | edition.workspace = true 6 | authors.workspace = true 7 | repository.workspace = true 8 | license.workspace = true 9 | 10 | [dependencies] 11 | aes.workspace = true 12 | cbc = "0.1.2" 13 | memchr = "2.7.4" 14 | mpeg2ts = "0.3.1" 15 | 16 | thiserror = "1.0" 17 | log.workspace = true 18 | id3 = "1.16.2" 19 | 20 | [dev-dependencies] 21 | criterion = "0.5.1" 22 | 23 | [[bench]] 24 | name = "decrypt" 25 | harness = false 26 | -------------------------------------------------------------------------------- /crates/ssa/Readme.md: -------------------------------------------------------------------------------- 1 | ## iori-ssa 2 | 3 | Library to decrypt a Simple Sample-AES encrypted `MPEG-TS`. -------------------------------------------------------------------------------- /crates/ssa/benches/decrypt.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | use iori_ssa::decrypt; 3 | use std::fs::File; 4 | use std::io::{BufReader, BufWriter}; 5 | 6 | fn decrypt_benchmark(c: &mut Criterion) { 7 | let mut group = c.benchmark_group("decrypt"); 8 | 9 | let key = [ 10 | 0x4d, 0x69, 0x48, 0x1f, 0x17, 0x0b, 0x27, 0xf0, 0xd2, 0xf6, 0x8f, 0xe4, 0x66, 0xd2, 0x08, 11 | 0x58, 12 | ]; // 4d69481f170b27f0d2f68fe466d20858 13 | let iv = [ 14 | 0xeb, 0x1f, 0x93, 0x27, 0x0d, 0x59, 0x22, 0xb5, 0x91, 0xdb, 0x0e, 0xff, 0x85, 0x4b, 0xfd, 15 | 0x76, 16 | ]; // EB1F93270D5922B591DB0EFF854BFD76 17 | 18 | // 测试小文件 19 | group.bench_function("decrypt", |b| { 20 | b.iter(|| { 21 | let input = Box::new(BufReader::new(File::open("test/small.ts").unwrap())); 22 | let output = BufWriter::new(Vec::new()); 23 | decrypt( 24 | black_box(input), 25 | black_box(output), 26 | black_box(key), 27 | black_box(iv), 28 | ) 29 | .unwrap(); 30 | }); 31 | }); 32 | 33 | group.finish(); 34 | } 35 | 36 | criterion_group!(benches, decrypt_benchmark); 37 | criterion_main!(benches); 38 | -------------------------------------------------------------------------------- /crates/ssa/src/constant.rs: -------------------------------------------------------------------------------- 1 | /// https://www.atsc.org/wp-content/uploads/2016/03/a_52-2015.pdf 2 | pub(crate) const AC3_FRAME_SIZE_CODE_TABLE: [[usize; 3]; 38] = [ 3 | [64, 69, 96], 4 | [64, 70, 96], 5 | [80, 87, 120], 6 | [80, 88, 120], 7 | [96, 104, 144], 8 | [96, 105, 144], 9 | [112, 121, 168], 10 | [112, 122, 168], 11 | [128, 139, 192], 12 | [128, 140, 192], 13 | [160, 174, 240], 14 | [160, 175, 240], 15 | [192, 208, 288], 16 | [192, 209, 288], 17 | [224, 243, 336], 18 | [224, 244, 336], 19 | [256, 278, 384], 20 | [256, 279, 384], 21 | [320, 348, 480], 22 | [320, 349, 480], 23 | [384, 417, 576], 24 | [384, 418, 576], 25 | [448, 487, 672], 26 | [448, 488, 672], 27 | [512, 557, 768], 28 | [512, 558, 768], 29 | [640, 696, 960], 30 | [640, 697, 960], 31 | [768, 835, 1152], 32 | [768, 836, 1152], 33 | [896, 975, 1344], 34 | [896, 976, 1344], 35 | [1024, 1114, 1536], 36 | [1024, 1115, 1536], 37 | [1152, 1253, 1728], 38 | [1152, 1254, 1728], 39 | [1280, 1393, 1920], 40 | [1280, 1394, 1920], 41 | ]; 42 | -------------------------------------------------------------------------------- /crates/ssa/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(thiserror::Error, Debug)] 2 | pub enum Error { 3 | #[error("MPEG-TS error: {0}")] 4 | MpegTsError(#[from] mpeg2ts::Error), 5 | 6 | #[error("Invalid NAL unit start code")] 7 | InvalidStartCode, 8 | 9 | #[error("IO error: {0}")] 10 | IoError(#[from] std::io::Error), 11 | 12 | #[error("ID3 error: {0}")] 13 | Id3Error(#[from] id3::Error), 14 | } 15 | 16 | pub(crate) type Result = std::result::Result; 17 | -------------------------------------------------------------------------------- /crates/ssa/test/fixtures/eac3/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ffmpeg -f lavfi -i color=c=black:s=1920x1080:d=2 \ 3 | -f lavfi -i "sine=frequency=1000:duration=2" \ 4 | -c:a eac3 -b:a 384k output.mp4 5 | mp42hls --encryption-mode SAMPLE-AES --encryption-key a8cda0ee5390b716298ffad0a1f1a021E60C79C314E3C9B471E7E51ABAA0B24A --encryption-iv-mode fps output.mp4 6 | rm *.m3u8 *.mp4 7 | ssadecrypt --key a8cda0ee5390b716298ffad0a1f1a021 --iv E60C79C314E3C9B471E7E51ABAA0B24A segment-0.ts | ffplay - -------------------------------------------------------------------------------- /crates/ssa/tests/decrypt.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use iori_ssa::decrypt; 4 | 5 | const KEY: [u8; 16] = u128::to_be_bytes(0xa8cda0ee5390b716298ffad0a1f1a021); 6 | const IV: [u8; 16] = u128::to_be_bytes(0xE60C79C314E3C9B471E7E51ABAA0B24A); 7 | 8 | #[test] 9 | fn decrypt_ac3() { 10 | let mut encrypted = Cursor::new(include_bytes!("fixtures/ac3/segment-0.ts")); 11 | let mut decrypted = Vec::new(); 12 | let expected_decrypted = include_bytes!("fixtures/ac3/segment-0.ts.dec"); 13 | 14 | decrypt(&mut encrypted, &mut decrypted, KEY, IV).unwrap(); 15 | assert_eq!(decrypted, expected_decrypted); 16 | } 17 | 18 | #[test] 19 | fn decrypt_eac3() { 20 | let mut encrypted = Cursor::new(include_bytes!("fixtures/eac3/segment-0.ts")); 21 | let mut decrypted = Vec::new(); 22 | let expected_decrypted = include_bytes!("fixtures/eac3/segment-0.ts.dec"); 23 | 24 | decrypt(&mut encrypted, &mut decrypted, KEY, IV).unwrap(); 25 | assert_eq!(decrypted, expected_decrypted); 26 | } 27 | -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | !*.ts -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/ac3/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ffmpeg -f lavfi -i color=c=black:s=1920x1080:d=2 -f lavfi -i "sine=frequency=1000:duration=2" -c:a ac3 -b:a 192k output.mp4 3 | mp42hls --encryption-mode SAMPLE-AES --encryption-key a8cda0ee5390b716298ffad0a1f1a021E60C79C314E3C9B471E7E51ABAA0B24A --encryption-iv-mode fps output.mp4 4 | rm *.mp4 *.m3u8 5 | ssadecrypt --key a8cda0ee5390b716298ffad0a1f1a021 --iv E60C79C314E3C9B471E7E51ABAA0B24A segment-0.ts | ffplay - -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/ac3/segment-0.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yesterday17/iori/99b36bab168c34583cbe4d5603f0479046fd2ac9/crates/ssa/tests/fixtures/ac3/segment-0.ts -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/ac3/segment-0.ts.dec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yesterday17/iori/99b36bab168c34583cbe4d5603f0479046fd2ac9/crates/ssa/tests/fixtures/ac3/segment-0.ts.dec -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/eac3/segment-0.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yesterday17/iori/99b36bab168c34583cbe4d5603f0479046fd2ac9/crates/ssa/tests/fixtures/eac3/segment-0.ts -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/eac3/segment-0.ts.dec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yesterday17/iori/99b36bab168c34583cbe4d5603f0479046fd2ac9/crates/ssa/tests/fixtures/eac3/segment-0.ts.dec -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "naersk": { 22 | "inputs": { 23 | "nixpkgs": "nixpkgs" 24 | }, 25 | "locked": { 26 | "lastModified": 1743800763, 27 | "narHash": "sha256-YFKV+fxEpMgP5VsUcM6Il28lI0NlpM7+oB1XxbBAYCw=", 28 | "owner": "nix-community", 29 | "repo": "naersk", 30 | "rev": "ed0232117731a4c19d3ee93aa0c382a8fe754b01", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "naersk", 36 | "type": "github" 37 | } 38 | }, 39 | "nixpkgs": { 40 | "locked": { 41 | "lastModified": 1745377448, 42 | "narHash": "sha256-jhZDfXVKdD7TSEGgzFJQvEEZ2K65UMiqW5YJ2aIqxMA=", 43 | "owner": "NixOS", 44 | "repo": "nixpkgs", 45 | "rev": "507b63021ada5fee621b6ca371c4fca9ca46f52c", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "NixOS", 50 | "ref": "nixpkgs-unstable", 51 | "repo": "nixpkgs", 52 | "type": "github" 53 | } 54 | }, 55 | "nixpkgs_2": { 56 | "locked": { 57 | "lastModified": 1745526057, 58 | "narHash": "sha256-ITSpPDwvLBZBnPRS2bUcHY3gZSwis/uTe255QgMtTLA=", 59 | "owner": "NixOS", 60 | "repo": "nixpkgs", 61 | "rev": "f771eb401a46846c1aebd20552521b233dd7e18b", 62 | "type": "github" 63 | }, 64 | "original": { 65 | "owner": "NixOS", 66 | "ref": "nixos-unstable", 67 | "repo": "nixpkgs", 68 | "type": "github" 69 | } 70 | }, 71 | "nixpkgs_3": { 72 | "locked": { 73 | "lastModified": 1744536153, 74 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 75 | "owner": "NixOS", 76 | "repo": "nixpkgs", 77 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 78 | "type": "github" 79 | }, 80 | "original": { 81 | "owner": "NixOS", 82 | "ref": "nixpkgs-unstable", 83 | "repo": "nixpkgs", 84 | "type": "github" 85 | } 86 | }, 87 | "root": { 88 | "inputs": { 89 | "flake-utils": "flake-utils", 90 | "naersk": "naersk", 91 | "nixpkgs": "nixpkgs_2", 92 | "rust-overlay": "rust-overlay" 93 | } 94 | }, 95 | "rust-overlay": { 96 | "inputs": { 97 | "nixpkgs": "nixpkgs_3" 98 | }, 99 | "locked": { 100 | "lastModified": 1745807802, 101 | "narHash": "sha256-Aary9kzSx9QFgfK1CDu3ZqxhuoyHvf0F71j64gXZebA=", 102 | "owner": "oxalica", 103 | "repo": "rust-overlay", 104 | "rev": "9a6045615437787dfb9c1a3242fd75c6b6976b6b", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "oxalica", 109 | "repo": "rust-overlay", 110 | "type": "github" 111 | } 112 | }, 113 | "systems": { 114 | "locked": { 115 | "lastModified": 1681028828, 116 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 117 | "owner": "nix-systems", 118 | "repo": "default", 119 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 120 | "type": "github" 121 | }, 122 | "original": { 123 | "owner": "nix-systems", 124 | "repo": "default", 125 | "type": "github" 126 | } 127 | } 128 | }, 129 | "root": "root", 130 | "version": 7 131 | } 132 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Iori"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | naersk.url = "github:nix-community/naersk"; 8 | rust-overlay.url = "github:oxalica/rust-overlay"; 9 | }; 10 | 11 | outputs = 12 | { 13 | self, 14 | nixpkgs, 15 | flake-utils, 16 | naersk, 17 | rust-overlay, 18 | }: 19 | flake-utils.lib.eachDefaultSystem ( 20 | system: 21 | let 22 | overlays = [ (import rust-overlay) ]; 23 | pkgs = import nixpkgs { 24 | inherit system overlays; 25 | }; 26 | naersk-lib = pkgs.callPackage naersk { }; 27 | 28 | rustToolchain = pkgs.rust-bin.stable.latest.default.override { 29 | extensions = [ 30 | "rust-src" 31 | "rust-analyzer" 32 | ]; 33 | }; 34 | in 35 | { 36 | packages.default = naersk-lib.buildPackage { 37 | src = ./.; 38 | nativeBuildInputs = with pkgs; [ 39 | pkg-config 40 | ]; 41 | buildInputs = with pkgs; [ 42 | protobuf 43 | ]; 44 | cargoBuildOptions = opts: opts ++ [ "--workspace" ]; 45 | }; 46 | 47 | devShells.default = pkgs.mkShell { 48 | buildInputs = with pkgs; [ 49 | rustToolchain 50 | pkg-config 51 | rust-analyzer 52 | protobuf 53 | mkvtoolnix-cli 54 | ]; 55 | }; 56 | } 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /platforms/gigafile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-gigafile" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | fake_user_agent.workspace = true 11 | reqwest.workspace = true 12 | anyhow.workspace = true 13 | serde.workspace = true 14 | serde_json.workspace = true 15 | 16 | shiori-plugin.workspace = true 17 | regex.workspace = true 18 | 19 | [dev-dependencies] 20 | tokio = { workspace = true, features = ["full"] } 21 | -------------------------------------------------------------------------------- /platforms/gigafile/src/client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use fake_user_agent::get_chrome_rua; 3 | use reqwest::header::SET_COOKIE; 4 | use reqwest::Client; 5 | 6 | pub struct GigafileClient { 7 | client: Client, 8 | key: Option, 9 | } 10 | 11 | impl GigafileClient { 12 | pub fn new(key: Option) -> Self { 13 | let client = reqwest::Client::builder() 14 | .user_agent(get_chrome_rua()) 15 | .danger_accept_invalid_certs(true) 16 | .build() 17 | .unwrap(); 18 | 19 | Self { client, key } 20 | } 21 | 22 | pub async fn get_download_url( 23 | &self, 24 | url: &str, 25 | ) -> Result<(String /* url */, String /* cookies */)> { 26 | let response = self.client.head(url).send().await?; 27 | let mut cookie = String::new(); 28 | for s in response 29 | .headers() 30 | .get_all(SET_COOKIE) 31 | .iter() 32 | .map(|c| c.to_str()) 33 | { 34 | let s = s?; 35 | let (entry, _) = s.split_once(';').unwrap_or((s, "")); 36 | cookie += entry; 37 | cookie += "; "; 38 | } 39 | cookie.pop(); 40 | cookie.pop(); 41 | 42 | let (domain, file_id) = url.rsplit_once('/').unwrap(); 43 | let mut download_url = format!("{domain}/download.php?file={file_id}"); 44 | 45 | if let Some(key) = &self.key { 46 | download_url.push_str(&format!("&dlkey={}", key)); 47 | } 48 | 49 | Ok((download_url, cookie)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /platforms/gigafile/src/inspect.rs: -------------------------------------------------------------------------------- 1 | use fake_user_agent::get_chrome_rua; 2 | use regex::bytes::Regex; 3 | use reqwest::{ 4 | header::{CONTENT_DISPOSITION, COOKIE, USER_AGENT}, 5 | Client, 6 | }; 7 | use shiori_plugin::{ 8 | async_trait, Inspect, InspectPlaylist, InspectResult, InspectorBuilder, PlaylistType, 9 | }; 10 | 11 | use crate::client::GigafileClient; 12 | 13 | pub struct GigafileInspector; 14 | 15 | impl InspectorBuilder for GigafileInspector { 16 | fn name(&self) -> String { 17 | "gigafile".to_string() 18 | } 19 | 20 | fn help(&self) -> Vec { 21 | [ 22 | "Extracts raw download URL from Gigafile.", 23 | "", 24 | "Template:", 25 | "- https://*.gigafile.nu/*", 26 | ] 27 | .iter() 28 | .map(|s| s.to_string()) 29 | .collect() 30 | } 31 | 32 | fn arguments(&self, command: &mut dyn shiori_plugin::InspectorCommand) { 33 | command.add_argument("giga-key", Some("key"), "[Gigafile] Download key"); 34 | } 35 | 36 | fn build( 37 | &self, 38 | args: &dyn shiori_plugin::InspectorArguments, 39 | ) -> anyhow::Result> { 40 | Ok(Box::new(GigafileInspectorImpl(args.get_string("giga-key")))) 41 | } 42 | } 43 | 44 | struct GigafileInspectorImpl(Option); 45 | 46 | #[async_trait] 47 | impl Inspect for GigafileInspectorImpl { 48 | async fn matches(&self, url: &str) -> bool { 49 | let re = Regex::new(r"^https://\d+\.gigafile\.nu/.*").unwrap(); 50 | re.is_match(url.as_bytes()) 51 | } 52 | 53 | async fn inspect(&self, url: &str) -> anyhow::Result { 54 | let client = GigafileClient::new(self.0.clone()); 55 | let (url, cookie) = client.get_download_url(url).await?; 56 | 57 | let client = Client::builder() 58 | .danger_accept_invalid_certs(true) 59 | .build() 60 | .unwrap(); 61 | let response = client 62 | .get(&url) 63 | .header(COOKIE, &cookie) 64 | .header(USER_AGENT, get_chrome_rua()) 65 | .send() 66 | .await?; 67 | let filename = response.headers().get(CONTENT_DISPOSITION).and_then(|v| { 68 | // attachment; filename=""; 69 | let re = Regex::new(r#"filename="([^"]+)"#).unwrap(); 70 | let matched = re 71 | .captures(v.as_bytes()) 72 | .and_then(|c| c.get(1).map(|m| m.as_bytes()))?; 73 | let filename = String::from_utf8(matched.to_vec()).ok()?; 74 | Some(filename) 75 | }); 76 | drop(response); 77 | 78 | let filename = filename.map(|f| { 79 | let (name, ext) = f.rsplit_once('.').unwrap_or((&f, "raw")); 80 | (name.to_string(), ext.to_string()) 81 | }); 82 | let (title, ext) = match filename { 83 | Some((filename, ext)) => (Some(filename), ext), 84 | None => (None, "raw".to_string()), 85 | }; 86 | 87 | Ok(InspectResult::Playlist(InspectPlaylist { 88 | title, 89 | playlist_url: url, 90 | playlist_type: PlaylistType::Raw(ext), 91 | headers: vec![format!("Cookie: {cookie}")], 92 | ..Default::default() 93 | })) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /platforms/gigafile/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | mod inspect; 3 | 4 | pub use inspect::*; 5 | -------------------------------------------------------------------------------- /platforms/nicolive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-nicolive" 3 | version = "0.0.1" 4 | 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | anyhow.workspace = true 9 | reqwest.workspace = true 10 | serde.workspace = true 11 | serde_json.workspace = true 12 | log.workspace = true 13 | fake_user_agent.workspace = true 14 | iori.workspace = true 15 | 16 | regex.workspace = true 17 | tokio.workspace = true 18 | 19 | url = "2.4.1" 20 | html-escape = "0.2.13" 21 | reqwest-websocket = "0.4.4" 22 | futures-util = { version = "0.3.30", features = ["futures-sink"] } 23 | parking_lot = "0.12.1" 24 | shlex = "1.3.0" 25 | 26 | prost.workspace = true 27 | prost-types.workspace = true 28 | chrono = "0.4" 29 | shiori-plugin.workspace = true 30 | 31 | [dev-dependencies] 32 | tokio = { workspace = true, features = ["full"] } 33 | 34 | [build-dependencies] 35 | prost-build.workspace = true 36 | -------------------------------------------------------------------------------- /platforms/nicolive/build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> std::io::Result<()> { 2 | // std::env::set_var("PROTOC", protobuf_src::protoc()); 3 | 4 | prost_build::compile_protos( 5 | &[ 6 | "src/proto/dwango/nicolive/chat/data/atoms.proto", 7 | "src/proto/dwango/nicolive/chat/data/message.proto", 8 | // "src/proto/dwango/nicolive/chat/data/origin.proto", 9 | // "src/proto/dwango/nicolive/chat/data/state.proto", 10 | "src/proto/dwango/nicolive/chat/edge/payload.proto", 11 | ], 12 | &["src/proto/"], 13 | )?; 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /platforms/nicolive/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod danmaku; 2 | pub mod inspect; 3 | pub mod model; 4 | pub mod program; 5 | pub mod source; 6 | pub mod watch; 7 | pub mod xml2ass; 8 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/data/atoms/moderator.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.data.atoms; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | 8 | message ModeratorUserInfo { 9 | int64 user_id = 1; 10 | optional string nickname = 2; 11 | optional string iconUrl = 3; 12 | } 13 | 14 | 15 | message ModeratorUpdated { 16 | enum ModeratorOperation { 17 | ADD = 0; 18 | DELETE = 1; 19 | } 20 | 21 | ModeratorOperation operation = 1; 22 | 23 | ModeratorUserInfo operator = 2; 24 | 25 | google.protobuf.Timestamp updatedAt = 3; 26 | } 27 | 28 | 29 | message SSNGUpdated { 30 | enum SSNGOperation { 31 | ADD = 0; 32 | DELETE = 1; 33 | } 34 | enum SSNGType { 35 | USER = 0; 36 | WORD = 1; 37 | COMMAND = 2; 38 | } 39 | 40 | SSNGOperation operation = 1; 41 | 42 | int64 ssng_id = 2; 43 | 44 | ModeratorUserInfo operator = 3; 45 | 46 | optional SSNGType type = 4; 47 | 48 | optional string source = 5; 49 | 50 | optional google.protobuf.Timestamp updatedAt = 6; 51 | } 52 | 53 | 54 | message ModerationAnnouncement { 55 | enum GuidelineItem { 56 | UNKNOWN = 0; 57 | SEXUAL = 1; 58 | SPAM = 2; 59 | SLANDER = 3; 60 | PERSONAL_INFORMATION = 4; 61 | } 62 | 63 | optional string message = 1; 64 | 65 | repeated GuidelineItem guidelineItems = 2; 66 | 67 | google.protobuf.Timestamp updatedAt = 3; 68 | } 69 | 70 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/data/atoms/sensitive.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.data.atoms; 4 | 5 | 6 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/data/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.data; 4 | import "dwango/nicolive/chat/data/atoms.proto"; 5 | import "dwango/nicolive/chat/data/atoms/moderator.proto"; 6 | 7 | 8 | 9 | message NicoliveMessage { 10 | 11 | oneof data { 12 | 13 | Chat chat = 1; 14 | 15 | 16 | SimpleNotification simple_notification = 7; 17 | 18 | 19 | Gift gift = 8; 20 | 21 | 22 | Nicoad nicoad = 9; 23 | 24 | GameUpdate game_update = 13; 25 | 26 | 27 | TagUpdated tag_updated = 17; 28 | 29 | 30 | atoms.ModeratorUpdated moderator_updated = 18; 31 | 32 | atoms.SSNGUpdated ssng_updated = 19; 33 | 34 | 35 | Chat overflowed_chat = 20; 36 | 37 | 38 | } 39 | 40 | reserved 2 to 6, 10 to 12, 14 to 16; 41 | } 42 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/data/origin.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.data; 4 | 5 | message NicoliveOrigin { 6 | message Chat { 7 | int64 live_id = 1; 8 | } 9 | 10 | oneof origin { 11 | Chat chat = 1; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/data/state.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.data; 4 | 5 | import "dwango/nicolive/chat/data/atoms.proto"; 6 | import "dwango/nicolive/chat/data/atoms/moderator.proto"; 7 | 8 | message NicoliveState { 9 | 10 | 11 | optional Statistics statistics = 1; 12 | 13 | 14 | optional Enquete enquete = 2; 15 | 16 | 17 | optional MoveOrder move_order = 3; 18 | 19 | 20 | optional Marquee marquee = 4; 21 | 22 | 23 | optional CommentLock comment_lock = 5; 24 | 25 | 26 | optional CommentMode comment_mode = 6; 27 | 28 | 29 | optional TrialPanel trial_panel = 7; 30 | 31 | 32 | 33 | 34 | optional ProgramStatus program_status = 9; 35 | 36 | 37 | optional atoms.ModerationAnnouncement moderation_announcement = 10; 38 | } 39 | -------------------------------------------------------------------------------- /platforms/nicolive/src/proto/dwango/nicolive/chat/edge/payload.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dwango.nicolive.chat.service.edge; 4 | import "google/protobuf/timestamp.proto"; 5 | import "dwango/nicolive/chat/data/message.proto"; 6 | import "dwango/nicolive/chat/data/state.proto"; 7 | import "dwango/nicolive/chat/data/origin.proto"; 8 | 9 | 10 | 11 | message ChunkedMessage { 12 | message Meta { 13 | 14 | string id = 1; 15 | 16 | google.protobuf.Timestamp at = 2; 17 | 18 | data.NicoliveOrigin origin = 3; 19 | } 20 | Meta meta = 1; 21 | oneof payload { 22 | 23 | data.NicoliveMessage message = 2; 24 | 25 | data.NicoliveState state = 4; 26 | 27 | 28 | 29 | Signal signal = 5; 30 | } 31 | 32 | enum Signal { 33 | 34 | 35 | Flushed = 0; 36 | } 37 | } 38 | 39 | 40 | message PackedSegment { 41 | 42 | repeated ChunkedMessage messages = 1; 43 | 44 | message Next { 45 | string uri = 1; 46 | } 47 | 48 | Next next = 2; 49 | 50 | StateSnapshot snapshot = 3; 51 | 52 | message StateSnapshot { 53 | 54 | string uri = 1; 55 | } 56 | } 57 | 58 | 59 | 60 | message ChunkedEntry { 61 | 62 | 63 | oneof entry { 64 | 65 | BackwardSegment backward = 2; 66 | 67 | 68 | MessageSegment previous = 3; 69 | 70 | 71 | MessageSegment segment = 1; 72 | 73 | 74 | ReadyForNext next = 4; 75 | } 76 | message ReadyForNext { 77 | int64 at = 1; 78 | } 79 | } 80 | 81 | 82 | 83 | 84 | message MessageSegment { 85 | 86 | google.protobuf.Timestamp from = 1; 87 | 88 | 89 | google.protobuf.Timestamp until = 2; 90 | 91 | 92 | 93 | string uri = 3; 94 | } 95 | 96 | 97 | 98 | message BackwardSegment { 99 | google.protobuf.Timestamp until = 1; 100 | 101 | 102 | PackedSegment.Next segment = 2; 103 | 104 | PackedSegment.StateSnapshot snapshot = 3; 105 | } 106 | -------------------------------------------------------------------------------- /platforms/nicolive/src/source.rs: -------------------------------------------------------------------------------- 1 | use iori::{ 2 | hls::{segment::M3u8Segment, HlsLiveSource}, 3 | HttpClient, IoriResult, StreamingSource, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::{io::AsyncWrite, sync::mpsc}; 7 | use url::Url; 8 | 9 | use crate::model::WatchResponse; 10 | 11 | #[derive(Debug, Serialize, Deserialize)] 12 | pub struct NicoTimeshiftSegmentInfo { 13 | sequence: u64, 14 | file_name: String, 15 | } 16 | 17 | pub struct NicoTimeshiftSource { 18 | inner: HlsLiveSource, 19 | retry: u32, 20 | } 21 | 22 | impl NicoTimeshiftSource { 23 | pub async fn new( 24 | client: HttpClient, 25 | wss_url: String, 26 | quality: &str, 27 | chase_play: bool, 28 | ) -> anyhow::Result { 29 | let watcher = crate::watch::WatchClient::new(&wss_url).await?; 30 | watcher.start_watching(quality, chase_play).await?; 31 | 32 | let stream = loop { 33 | let msg = watcher.recv().await?; 34 | if let Some(WatchResponse::Stream(stream)) = msg { 35 | break stream; 36 | } 37 | }; 38 | 39 | log::info!("Playlist: {}", stream.uri); 40 | let url = Url::parse(&stream.uri)?; 41 | client.add_cookies(stream.cookies.into_cookies(), url); 42 | 43 | // keep seats 44 | tokio::spawn(async move { 45 | loop { 46 | tokio::select! { 47 | msg = watcher.recv() => { 48 | let Ok(msg) = msg else { 49 | break; 50 | }; 51 | log::debug!("message: {:?}", msg); 52 | } 53 | _ = watcher.keep_seat() => (), 54 | } 55 | } 56 | log::info!("watcher disconnected"); 57 | }); 58 | 59 | Ok(Self { 60 | inner: HlsLiveSource::new(client, stream.uri, None, None), 61 | retry: 3, 62 | }) 63 | } 64 | 65 | pub fn with_retry(mut self, retry: u32) -> Self { 66 | self.retry = retry; 67 | self 68 | } 69 | } 70 | 71 | impl StreamingSource for NicoTimeshiftSource { 72 | type Segment = M3u8Segment; 73 | 74 | async fn fetch_info( 75 | &self, 76 | ) -> IoriResult>>> { 77 | self.inner.fetch_info().await 78 | } 79 | 80 | async fn fetch_segment(&self, segment: &Self::Segment, writer: &mut W) -> IoriResult<()> 81 | where 82 | W: AsyncWrite + Unpin + Send + Sync + 'static, 83 | { 84 | self.inner.fetch_segment(segment, writer).await 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /platforms/showroom/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-showroom" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | fake_user_agent.workspace = true 11 | reqwest.workspace = true 12 | anyhow.workspace = true 13 | serde.workspace = true 14 | serde_json.workspace = true 15 | 16 | shiori-plugin.workspace = true 17 | regex.workspace = true 18 | 19 | [dev-dependencies] 20 | tokio.workspace = true 21 | -------------------------------------------------------------------------------- /platforms/showroom/src/constants.rs: -------------------------------------------------------------------------------- 1 | pub const S46_RINA_UEMURA: &str = "46_RINA_UEMURA"; 2 | // pub const S46_RIKA_OZEKI: &str = "46_RIKA_OZEKI"; 3 | pub const S46_MINAMI_KOIKE: &str = "46_MINAMI_KOIKE"; 4 | pub const S46_YUI_KOBAYASHI: &str = "46_YUI_KOBAYASHI"; 5 | pub const S46_FUYUKA_SAITO: &str = "46_FUYUKA_SAITO"; 6 | // pub const S46_YUUKA_SUGAI: &str = "46_YUUKA_SUGAI"; 7 | pub const S46_MIZUHO_HABU: &str = "46_MIZUHO_HABU"; 8 | // pub const S46_AOI_HARADA: &str = "46_AOI_HARADA"; 9 | // pub const S46_AKANE_MORIYA: &str = "46_AKANE_MORIYA"; 10 | // pub const S46_RIKA_WATANABE: &str = "46_RIKA_WATANABE"; 11 | // pub const S46_RISA_WATANABE: &str = "46_RISA_WATANABE"; 12 | 13 | pub const S46_RINA_INOUE: &str = "46_RINA_INOUE"; 14 | pub const S46_HIKARI_ENDO: &str = "46_HIKARI_ENDO"; 15 | pub const S46_REI_OZONO: &str = "46_REI_OZONO"; 16 | pub const S46_AKIHO_ONUMA: &str = "46_AKIHO_ONUMA"; 17 | pub const S46_MARINO_KOUSAKA: &str = "46_MARINO_KOUSAKA"; 18 | pub const S46_YUMIKO_SEKI: &str = "46_YUMIKO_SEKI"; 19 | pub const S46_YUI_TAKEMOTO: &str = "46_YUI_TAKEMOTO"; 20 | pub const S46_HONO_TAMURA: &str = "46_HONO_TAMURA"; 21 | pub const S46_KARIN_FUJIYOSHI: &str = "46_KARIN_FUJIYOSHI"; 22 | pub const S46_KIRA_MASUMOTO: &str = "46_KIRA_MASUMOTO"; 23 | pub const S46_RINA_MATSUDA: &str = "46_RINA_MATSUDA"; 24 | // pub const S46_RIKO_MATSUDAIRA: &str = "46_RIKO_MATSUDAIRA"; 25 | pub const S46_HIKARU_MORITA: &str = "46_HIKARU_MORITA"; 26 | pub const S46_RENA_MORIYA: &str = "46_RENA_MORIYA"; 27 | pub const S46_TEN_YAMASAKI: &str = "46_TEN_YAMASAKI"; 28 | 29 | pub const S46_RIKA_ISHIMORI: &str = "46_RIKA_ISHIMORI"; 30 | pub const S46_RIKO_ENDO: &str = "46_RIKO_ENDO"; 31 | pub const S46_REINA_ODAKURA: &str = "46_REINA_ODAKURA"; 32 | pub const S46_NAGISA_KOJIMA: &str = "46_NAGISA_KOJIMA"; 33 | pub const S46_AIRI_TANIGUCHI: &str = "46_AIRI_TANIGUCHI"; 34 | pub const S46_YUZUKI_NAKASHIMA: &str = "46_YUZUKI_NAKASHIMA"; 35 | pub const S46_MIO_MATONO: &str = "46_MIO_MATONO"; 36 | pub const S46_ITOHA_MUKAI: &str = "46_ITOHA_MUKAI"; 37 | pub const S46_YU_MURAI: &str = "46_YU_MURAI"; 38 | pub const S46_MIU_MURAYAMA: &str = "46_MIU_MURAYAMA"; 39 | pub const S46_SHIZUKI_YAMASHITA: &str = "46_SHIZUKI_YAMASHITA"; 40 | -------------------------------------------------------------------------------- /platforms/showroom/src/inspect.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Url; 2 | use shiori_plugin::*; 3 | 4 | use crate::ShowRoomClient; 5 | 6 | pub struct ShowroomInspector; 7 | 8 | impl InspectorBuilder for ShowroomInspector { 9 | fn name(&self) -> String { 10 | "showroom".to_string() 11 | } 12 | 13 | fn help(&self) -> Vec { 14 | [ 15 | "Extracts Showroom playlists from the given URL.", 16 | "", 17 | "Template:", 18 | "- https://www.showroom-live.com/r/*", 19 | "- https://www.showroom-live.com/timeshift/*", 20 | ] 21 | .iter() 22 | .map(|s| s.to_string()) 23 | .collect() 24 | } 25 | 26 | fn arguments(&self, command: &mut dyn InspectorCommand) { 27 | command.add_argument( 28 | "showroom-user-session", 29 | Some("sr_id"), 30 | "[Showroom] Your Showroom user session key.", 31 | ); 32 | } 33 | 34 | fn build(&self, args: &dyn InspectorArguments) -> anyhow::Result> { 35 | Ok(Box::new(ShowroomInspectorImpl(args.get_string("sr-id")))) 36 | } 37 | } 38 | 39 | struct ShowroomInspectorImpl(Option); 40 | 41 | impl ShowroomInspectorImpl { 42 | fn is_timeshift_url(&self, url: &str) -> bool { 43 | let re = regex::Regex::new(r"^https://www\.showroom-live\.com/timeshift/([^/]+)/([^/]+)$") 44 | .unwrap(); 45 | re.is_match(url) 46 | } 47 | 48 | fn extract_timeshift_keys(&self, url: &str) -> Option<(String, String)> { 49 | let re = regex::Regex::new(r"^https://www\.showroom-live\.com/timeshift/([^/]+)/([^/]+)$") 50 | .unwrap(); 51 | re.captures(url).map(|caps| { 52 | let room_url_key = caps.get(1).unwrap().as_str().to_string(); 53 | let view_url_key = caps.get(2).unwrap().as_str().to_string(); 54 | (room_url_key, view_url_key) 55 | }) 56 | } 57 | } 58 | 59 | #[async_trait] 60 | impl Inspect for ShowroomInspectorImpl { 61 | async fn matches(&self, url: &str) -> bool { 62 | url.starts_with("https://www.showroom-live.com/r/") 63 | || url.starts_with("https://www.showroom-live.com/timeshift/") 64 | } 65 | 66 | async fn inspect(&self, url: &str) -> anyhow::Result { 67 | let client = ShowRoomClient::new(self.0.clone()).await?; 68 | 69 | if self.is_timeshift_url(url) { 70 | let (room_url_key, view_url_key) = self.extract_timeshift_keys(url).unwrap(); 71 | let timeshift_info = client.timeshift_info(&room_url_key, &view_url_key).await?; 72 | let timeshift_streaming_url = client 73 | .timeshift_streaming_url( 74 | timeshift_info.timeshift.room_id, 75 | timeshift_info.timeshift.live_id, 76 | ) 77 | .await?; 78 | let stream = timeshift_streaming_url.best(); 79 | return Ok(InspectResult::Playlist(InspectPlaylist { 80 | title: Some(timeshift_info.timeshift.title), 81 | playlist_url: stream.url().to_string(), 82 | playlist_type: PlaylistType::HLS, 83 | ..Default::default() 84 | })); 85 | } else { 86 | // live 87 | let url: Url = Url::parse(url)?; 88 | let room_name = url.path().trim_start_matches("/r/"); 89 | 90 | let room_id = match room_name.parse::() { 91 | Ok(room_id) => room_id, 92 | Err(_) => client.get_id_by_room_slug(room_name).await?, 93 | }; 94 | 95 | let info = client.live_info(room_id).await?; 96 | if !info.is_living() { 97 | return Ok(InspectResult::None); 98 | } 99 | 100 | let streams = client.live_streaming_url(room_id).await?; 101 | let Some(stream) = streams.best(false) else { 102 | return Ok(InspectResult::None); 103 | }; 104 | 105 | Ok(InspectResult::Playlist(InspectPlaylist { 106 | title: Some(info.room_name), 107 | playlist_url: stream.url.clone(), 108 | playlist_type: PlaylistType::HLS, 109 | ..Default::default() 110 | })) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /platforms/showroom/src/model.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub struct LiveInfo { 7 | pub live_id: u64, 8 | pub room_id: u64, 9 | 10 | /// 1: Not Living 11 | /// 2: Living 12 | live_status: u64, 13 | 14 | pub room_name: String, 15 | } 16 | 17 | impl LiveInfo { 18 | pub fn is_living(&self) -> bool { 19 | self.live_status == 2 20 | } 21 | } 22 | 23 | #[derive(Debug, Deserialize)] 24 | pub struct LiveStreamlingList { 25 | #[serde(default)] 26 | pub streaming_url_list: Vec, 27 | } 28 | 29 | impl LiveStreamlingList { 30 | pub fn best(&self, prefer_lhls: bool) -> Option<&LiveStream> { 31 | let mut streams = self.streaming_url_list.iter().collect::>(); 32 | streams.sort_by_key(|k| { 33 | k.quality.unwrap_or(0) 34 | + if (prefer_lhls && k.r#type == "lhls") || (!prefer_lhls && k.r#type == "hls") { 35 | 1000000 36 | } else { 37 | 0 38 | } 39 | }); 40 | 41 | streams.last().copied() 42 | } 43 | } 44 | 45 | #[derive(Debug, Deserialize)] 46 | pub struct LiveStream { 47 | pub label: String, 48 | pub url: String, 49 | pub quality: Option, // usually 1000 for normal, 100 for low 50 | 51 | pub id: u8, 52 | pub r#type: String, // hls, lhls 53 | #[serde(default)] 54 | pub is_default: bool, 55 | } 56 | 57 | // {"timeshift":{"entrance_url":"https://www.showroom-live.com/premium_live/stu48_8th_Empathy_/j36328","is_private":false,"can_watch_to":1746025140,"status":2,"start_position":0,"can_watch_from":1743908400,"view_url_key":"K86763","live_id":21142701,"room_name":"STU48 8周年コンサート 〜Empathy〜","live_ended_at":1743853916,"timeshift_id":2967,"view_url":"https://www.showroom-live.com/timeshift/stu48_8th_Empathy_/K86763","description":"4月5日(土)
\n広島国際会議場 フェニックスホール行われる『STU48 8th Anniversary
\nConcert THE STU SHOW〜Empathy〜』コンサート本編&後日配信される“メンバーと8周年コンサートを振り返ろう”「同時視聴コメンタリー生配信(〜Empathy〜)」の計2配信が視聴できるチケットです。
\n
\n1️⃣見逃し配信アリ⭕️
\n2️⃣メンバーと振り返るコメンタリー生配信🎥
\n※出演メンバーは後日お知らせいたします
\n
\n会場にお越しいただけない方は勿論、来場した方も楽しめる内容盛り沢山です⛴💙
\n
\n■注意事項
\n・チケットのキャンセル及び払戻しについては、理由の如何を問わずお受けできません。
\n・当日の状況により、開演・終演時間は変動する場合がございます。
\n・機材トラブルにより配信時間が変動する場合がございます。
\n・配信の録画・撮影・録音は禁止といたします。","live_type":3,"default_status":2,"live_started_at":1743841813,"title":"STU48 8周年コンサート 〜Empathy〜","room_id":546080}} 58 | #[derive(Debug, Deserialize)] 59 | pub struct TimeshiftInfo { 60 | pub timeshift: Timeshift, 61 | } 62 | 63 | #[derive(Debug, Deserialize)] 64 | pub struct Timeshift { 65 | pub title: String, 66 | pub description: String, 67 | pub room_id: u64, 68 | pub live_id: u64, 69 | } 70 | 71 | #[derive(Debug, Deserialize)] 72 | pub struct TimeshiftStreamingList { 73 | pub streaming_url_list: HashMap, 74 | } 75 | 76 | impl TimeshiftStreamingList { 77 | pub fn best(&self) -> &TimeshiftStream { 78 | self.streaming_url_list.get("hls_all").unwrap_or_else(|| { 79 | self.streaming_url_list 80 | .get("hls_source") 81 | .unwrap_or_else(|| { 82 | self.streaming_url_list 83 | .values() 84 | .next() 85 | .expect("no timeshift stream") 86 | }) 87 | }) 88 | } 89 | } 90 | 91 | #[derive(Debug, Deserialize)] 92 | #[serde(untagged)] 93 | pub enum TimeshiftStream { 94 | Hls { 95 | hls: String, 96 | /// source, medium, low 97 | quality: String, 98 | }, 99 | HlsAll { 100 | hls_all: String, 101 | /// all 102 | quality: String, 103 | }, 104 | } 105 | 106 | impl TimeshiftStream { 107 | pub fn url(&self) -> &str { 108 | match self { 109 | TimeshiftStream::Hls { hls, .. } => hls, 110 | TimeshiftStream::HlsAll { hls_all, .. } => hls_all, 111 | } 112 | } 113 | } 114 | 115 | #[derive(Deserialize)] 116 | pub struct RoomProfile { 117 | pub room_name: String, 118 | pub live_id: u64, // 0 for not live 119 | pub current_live_started_at: i64, // 0 for not live 120 | } 121 | 122 | impl RoomProfile { 123 | pub fn is_live(&self) -> bool { 124 | self.live_id != 0 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /plugins/shiori-plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | anyhow.workspace = true 11 | async-trait = "0.1.86" 12 | serde.workspace = true 13 | -------------------------------------------------------------------------------- /plugins/shiori-plugin/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use async_trait::async_trait; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub trait InspectorCommand { 5 | fn add_argument( 6 | &mut self, 7 | long: &'static str, 8 | value_name: Option<&'static str>, 9 | help: &'static str, 10 | ); 11 | 12 | fn add_boolean_argument(&mut self, long: &'static str, help: &'static str); 13 | } 14 | 15 | pub trait InspectorArguments: Send + Sync { 16 | fn get_string(&self, argument: &'static str) -> Option; 17 | fn get_boolean(&self, argument: &'static str) -> bool; 18 | } 19 | 20 | pub trait InspectorBuilder { 21 | fn name(&self) -> String; 22 | 23 | fn help(&self) -> Vec { 24 | vec!["No help available".to_string()] 25 | } 26 | 27 | fn arguments(&self, _command: &mut dyn InspectorCommand) {} 28 | 29 | fn build(&self, args: &dyn InspectorArguments) -> anyhow::Result>; 30 | } 31 | 32 | #[async_trait] 33 | pub trait Inspect: Send + Sync { 34 | /// Check if this handler can handle the URL 35 | async fn matches(&self, url: &str) -> bool; 36 | 37 | /// Inspect the URL and return the result 38 | async fn inspect(&self, url: &str) -> anyhow::Result; 39 | 40 | /// Inspect a previously returned candidate and return the result 41 | async fn inspect_candidate( 42 | &self, 43 | _candidate: InspectCandidate, 44 | ) -> anyhow::Result { 45 | Ok(InspectResult::None) 46 | } 47 | } 48 | 49 | #[derive(Serialize, Deserialize, Debug)] 50 | pub enum InspectResult { 51 | /// This site handler can not handle this URL 52 | NotMatch, 53 | /// Found multiple available sources to choose 54 | Candidates(Vec), 55 | /// Inspect data is found 56 | Playlist(InspectPlaylist), 57 | /// Multiple playlists are found and need to be downloaded 58 | Playlists(Vec), 59 | /// Redirect happens 60 | Redirect(String), 61 | /// Inspect data is not found 62 | None, 63 | } 64 | 65 | #[derive(Serialize, Deserialize, Debug)] 66 | pub struct InspectCandidate { 67 | pub title: String, 68 | 69 | pub playlist_type: Option, 70 | } 71 | 72 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 73 | pub enum PlaylistType { 74 | #[default] 75 | HLS, 76 | DASH, 77 | Raw(String), 78 | } 79 | 80 | #[derive(Serialize, Deserialize, Debug, Default)] 81 | pub struct InspectPlaylist { 82 | /// Metadata of the resource 83 | pub title: Option, 84 | 85 | /// URL of the playlist 86 | pub playlist_url: String, 87 | 88 | /// Type of the playlist 89 | pub playlist_type: PlaylistType, 90 | 91 | /// Key used to decrypt the media 92 | pub key: Option, 93 | 94 | /// Headers to use when requesting 95 | pub headers: Vec, 96 | 97 | /// Cookies to use when requesting 98 | pub cookies: Vec, 99 | 100 | /// Initial data of the playlist 101 | /// 102 | /// Inspector may have already sent a request to the server, in which case we can reuse the data 103 | pub initial_playlist_data: Option, 104 | 105 | /// Hints how many streams does this playlist contains. 106 | pub streams_hint: Option, 107 | } 108 | 109 | pub trait InspectorApp { 110 | fn choose_candidates(&self, candidates: Vec) -> Vec; 111 | } 112 | --------------------------------------------------------------------------------